mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 15:32:24 -06:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 701c3fc717 | |||
| 92ad5bd439 | |||
| 58c81b2b46 | |||
| a2d4598012 | |||
| 4f83dba1b9 | |||
| 2629f217d2 | |||
| d1162b2eb9 | |||
| 217688547e | |||
| 5dc98f75fb | |||
| 6980ba5aae |
@@ -0,0 +1,40 @@
|
||||
# Bare-metal overlay — expose PostgreSQL and let the console reach
|
||||
# a turnstone-server running outside Docker on the host machine.
|
||||
#
|
||||
# Requires TURNSTONE_HOST_IP set to the host's routable IP address.
|
||||
#
|
||||
# Usage:
|
||||
# export TURNSTONE_HOST_IP="$(hostname -I | awk '{print $1}')"
|
||||
# docker compose --profile production \
|
||||
# -f compose.yaml -f deploy/docker-compose.bare-metal.yml up
|
||||
#
|
||||
# Then on the host:
|
||||
# export TURNSTONE_JWT_SECRET="<same as .env>"
|
||||
# export TURNSTONE_DB_BACKEND=postgresql
|
||||
# export TURNSTONE_DB_URL="postgresql://turnstone:<pw>@localhost:5432/turnstone"
|
||||
# export TURNSTONE_NODE_ID="bare-metal-1"
|
||||
# export TURNSTONE_ADVERTISE_URL="http://${TURNSTONE_HOST_IP}:8080"
|
||||
# python -m turnstone.server --host 0.0.0.0 --port 8080 \
|
||||
# --base-url http://localhost:8000/v1 --api-key "$OPENAI_API_KEY"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
|
||||
console:
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
# Console needs to reach the bare-metal server on the host
|
||||
TURNSTONE_SERVER_URL: "http://${TURNSTONE_HOST_IP}:${SERVER_PORT:-8080}"
|
||||
|
||||
channel:
|
||||
ports:
|
||||
- "${CHANNEL_PORT:-8091}:8091"
|
||||
environment:
|
||||
# Channel gateway advertises with host-routable IP so the
|
||||
# bare-metal server can reach it for schedule notifications
|
||||
TURNSTONE_CHANNEL_ADVERTISE_URL: "http://${TURNSTONE_HOST_IP}:${CHANNEL_PORT:-8091}"
|
||||
# Channel needs to reach the bare-metal server on the host
|
||||
TURNSTONE_SERVER_URL: "http://${TURNSTONE_HOST_IP}:${SERVER_PORT:-8080}"
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "1.2.0a4"
|
||||
version = "1.2.0a5"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "BUSL-1.1"
|
||||
|
||||
Generated
+3
-33
@@ -179,9 +179,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -199,9 +196,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -219,9 +213,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -239,9 +230,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -259,9 +247,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -279,9 +264,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -762,9 +744,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -786,9 +765,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -810,9 +786,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -834,9 +807,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1120,9 +1090,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.4.tgz",
|
||||
"integrity": "sha512-baBr4jUVSLJ0RPyZ2nK0zS2+W8hNHbM4hEzfvllukmRPVS3xDG5ATTNtbRXrKIOE2b8/FsPWJAOnuIxcs7g3cw==",
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.5.tgz",
|
||||
"integrity": "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+3
-6
@@ -14,6 +14,7 @@ from turnstone.core.auth import (
|
||||
check_request,
|
||||
create_jwt,
|
||||
is_public_path,
|
||||
load_jwt_secret,
|
||||
make_clear_cookie,
|
||||
make_set_cookie,
|
||||
required_scope,
|
||||
@@ -1420,13 +1421,11 @@ class TestIsSecureRequest:
|
||||
|
||||
class TestSecretStrength:
|
||||
def test_short_secret_exits(self):
|
||||
import turnstone.core.auth as auth_mod
|
||||
|
||||
old = os.environ.get("TURNSTONE_JWT_SECRET", "")
|
||||
os.environ["TURNSTONE_JWT_SECRET"] = "short"
|
||||
try:
|
||||
with pytest.raises(SystemExit):
|
||||
auth_mod.load_jwt_secret()
|
||||
load_jwt_secret()
|
||||
finally:
|
||||
if old:
|
||||
os.environ["TURNSTONE_JWT_SECRET"] = old
|
||||
@@ -1434,14 +1433,12 @@ class TestSecretStrength:
|
||||
os.environ.pop("TURNSTONE_JWT_SECRET", None)
|
||||
|
||||
def test_missing_secret_exits(self):
|
||||
import turnstone.core.auth as auth_mod
|
||||
|
||||
with (
|
||||
patch("turnstone.core.config.load_config", return_value={}),
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
auth_mod.load_jwt_secret()
|
||||
load_jwt_secret()
|
||||
|
||||
|
||||
class TestCorsConfigurable:
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
import argparse
|
||||
|
||||
import turnstone.core.config as config_mod
|
||||
from turnstone.core.config import apply_config, load_config, set_config_path
|
||||
|
||||
apply_config = config_mod.apply_config
|
||||
load_config = config_mod.load_config
|
||||
set_config_path = config_mod.set_config_path
|
||||
|
||||
|
||||
def _reset_cache():
|
||||
|
||||
@@ -349,11 +349,14 @@ class TestFireNotifyTargets:
|
||||
mock_deliver.assert_not_called()
|
||||
|
||||
@patch("turnstone.server._deliver_notification")
|
||||
def test_empty_content_skipped(self, mock_deliver):
|
||||
def test_empty_content_delivers_fallback(self, mock_deliver):
|
||||
"""Empty content should still deliver with a fallback message."""
|
||||
ws = MagicMock()
|
||||
ws.notify_targets = '[{"channel_type":"discord","channel_id":"1"}]'
|
||||
_fire_notify_targets(ws, "")
|
||||
mock_deliver.assert_not_called()
|
||||
mock_deliver.assert_called_once()
|
||||
payload = mock_deliver.call_args[0][1]
|
||||
assert "no output captured" in payload["message"]
|
||||
|
||||
@patch("turnstone.server._deliver_notification")
|
||||
def test_invalid_json_targets_skipped(self, mock_deliver):
|
||||
|
||||
@@ -1140,6 +1140,215 @@ class TestProviderFactory:
|
||||
|
||||
assert lookup_model_capabilities("google", "gemini-2.5-pro") is None
|
||||
|
||||
def test_resolve_openai_provider_googleapis(self) -> None:
|
||||
from turnstone.core.model_registry import _resolve_openai_provider
|
||||
|
||||
assert (
|
||||
_resolve_openai_provider(
|
||||
"openai",
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
)
|
||||
== "google"
|
||||
)
|
||||
|
||||
def test_resolve_openai_provider_not_spoofable(self) -> None:
|
||||
from turnstone.core.model_registry import _resolve_openai_provider
|
||||
|
||||
# evil-googleapis.com must NOT match — requires the dot prefix
|
||||
assert (
|
||||
_resolve_openai_provider("openai", "https://evil-googleapis.com/v1")
|
||||
== "openai-compatible"
|
||||
)
|
||||
|
||||
def test_resolve_openai_provider_api_openai_unchanged(self) -> None:
|
||||
from turnstone.core.model_registry import _resolve_openai_provider
|
||||
|
||||
assert _resolve_openai_provider("openai", "https://api.openai.com/v1") == "openai"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Google provider fidelity
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestGoogleProviderFidelity:
|
||||
"""Tests for thought_signature round-trip via provider_blocks."""
|
||||
|
||||
def test_prepare_messages_strips_provider_content(self) -> None:
|
||||
from turnstone.core.providers._google import GoogleProvider
|
||||
|
||||
prov = GoogleProvider()
|
||||
msgs = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "c1", "type": "function", "function": {"name": "f", "arguments": "{}"}},
|
||||
],
|
||||
"_provider_content": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "function",
|
||||
"function": {"name": "f", "arguments": "{}"},
|
||||
"thought_signature": "sig123",
|
||||
},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "c1", "content": "ok"},
|
||||
]
|
||||
cleaned = prov._prepare_messages(msgs)
|
||||
# _provider_content must be stripped
|
||||
for m in cleaned:
|
||||
assert "_provider_content" not in m
|
||||
# tool_calls must be reconstructed with thought_signature
|
||||
tc = cleaned[0]["tool_calls"][0]
|
||||
assert tc["thought_signature"] == "sig123"
|
||||
|
||||
def test_prepare_messages_passthrough_without_provider_content(self) -> None:
|
||||
from turnstone.core.providers._google import GoogleProvider
|
||||
|
||||
prov = GoogleProvider()
|
||||
msgs = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi"},
|
||||
]
|
||||
cleaned = prov._prepare_messages(msgs)
|
||||
assert len(cleaned) == 2
|
||||
assert cleaned[0]["content"] == "hello"
|
||||
|
||||
def test_non_streaming_captures_provider_blocks(self) -> None:
|
||||
from turnstone.core.providers._google import GoogleProvider
|
||||
|
||||
prov = GoogleProvider()
|
||||
|
||||
# Build a mock response with thought_signature in __pydantic_extra__
|
||||
mock_tc = MagicMock()
|
||||
mock_tc.id = "c1"
|
||||
mock_tc.function.name = "write_file"
|
||||
mock_tc.function.arguments = '{"path":"test.txt"}'
|
||||
mock_tc.model_dump.return_value = {
|
||||
"id": "c1",
|
||||
"type": "function",
|
||||
"function": {"name": "write_file", "arguments": '{"path":"test.txt"}'},
|
||||
"thought_signature": "sig_abc",
|
||||
}
|
||||
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.tool_calls = [mock_tc]
|
||||
mock_msg.content = ""
|
||||
mock_msg.annotations = None
|
||||
|
||||
mock_choice = MagicMock()
|
||||
mock_choice.message = mock_msg
|
||||
mock_choice.finish_reason = "tool_calls"
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [mock_choice]
|
||||
mock_response.usage = None
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.chat.completions.create.return_value = mock_response
|
||||
|
||||
result = prov.create_completion(
|
||||
client=mock_client,
|
||||
model="gemini-2.5-pro",
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
)
|
||||
|
||||
# Normalised tool_calls should NOT have thought_signature
|
||||
assert result.tool_calls is not None
|
||||
assert "thought_signature" not in result.tool_calls[0]
|
||||
# provider_blocks should have the raw dict WITH thought_signature
|
||||
assert len(result.provider_blocks) == 1
|
||||
assert result.provider_blocks[0]["thought_signature"] == "sig_abc"
|
||||
|
||||
def test_prepare_messages_base_class_unchanged(self) -> None:
|
||||
"""Base class _prepare_messages just calls sanitize_messages."""
|
||||
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
|
||||
|
||||
prov = OpenAIChatCompletionsProvider()
|
||||
msgs = [
|
||||
{"role": "assistant", "content": None}, # should get content=""
|
||||
{"role": "user", "content": "hi"},
|
||||
]
|
||||
cleaned = prov._prepare_messages(msgs)
|
||||
assert cleaned[0]["content"] == ""
|
||||
|
||||
def test_streaming_captures_thought_signature(self) -> None:
|
||||
"""Streaming _iter_stream taps raw deltas and emits provider_blocks."""
|
||||
from turnstone.core.providers._google import GoogleProvider
|
||||
|
||||
prov = GoogleProvider()
|
||||
|
||||
# Build a minimal mock stream with 2 chunks:
|
||||
# chunk 1: tool call header with thought_signature
|
||||
# chunk 2: finish reason
|
||||
mock_fn = MagicMock()
|
||||
mock_fn.name = "write_file"
|
||||
mock_fn.arguments = '{"path":"test.txt"}'
|
||||
|
||||
mock_tc_delta = MagicMock()
|
||||
mock_tc_delta.index = 0
|
||||
mock_tc_delta.id = "call_abc"
|
||||
mock_tc_delta.function = mock_fn
|
||||
mock_tc_delta.__pydantic_extra__ = {"thought_signature": "sig_stream"}
|
||||
|
||||
mock_delta1 = MagicMock()
|
||||
mock_delta1.content = None
|
||||
mock_delta1.tool_calls = [mock_tc_delta]
|
||||
mock_delta1.annotations = None
|
||||
# reasoning fields
|
||||
mock_delta1.reasoning = None
|
||||
mock_delta1.reasoning_content = None
|
||||
|
||||
mock_choice1 = MagicMock()
|
||||
mock_choice1.finish_reason = None
|
||||
mock_choice1.delta = mock_delta1
|
||||
|
||||
mock_chunk1 = MagicMock()
|
||||
mock_chunk1.choices = [mock_choice1]
|
||||
mock_chunk1.usage = None
|
||||
|
||||
# Finish chunk
|
||||
mock_delta2 = MagicMock()
|
||||
mock_delta2.content = None
|
||||
mock_delta2.tool_calls = None
|
||||
mock_delta2.annotations = None
|
||||
mock_delta2.reasoning = None
|
||||
mock_delta2.reasoning_content = None
|
||||
|
||||
mock_choice2 = MagicMock()
|
||||
mock_choice2.finish_reason = "tool_calls"
|
||||
mock_choice2.delta = mock_delta2
|
||||
|
||||
mock_chunk2 = MagicMock()
|
||||
mock_chunk2.choices = [mock_choice2]
|
||||
mock_chunk2.usage = None
|
||||
|
||||
chunks = list(prov._iter_stream([mock_chunk1, mock_chunk2]))
|
||||
|
||||
# Find the chunk with finish_reason
|
||||
finish_chunks = [c for c in chunks if c.finish_reason]
|
||||
assert len(finish_chunks) == 1
|
||||
fc = finish_chunks[0]
|
||||
assert len(fc.provider_blocks) == 1
|
||||
assert fc.provider_blocks[0]["thought_signature"] == "sig_stream"
|
||||
assert fc.provider_blocks[0]["id"] == "call_abc"
|
||||
assert fc.provider_blocks[0]["function"]["name"] == "write_file"
|
||||
|
||||
def test_base_extract_tool_calls_returns_empty_provider_blocks(self) -> None:
|
||||
"""Base class _extract_tool_calls returns empty provider_blocks."""
|
||||
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
|
||||
|
||||
prov = OpenAIChatCompletionsProvider()
|
||||
mock_tc = MagicMock()
|
||||
mock_tc.id = "c1"
|
||||
mock_tc.function.name = "test"
|
||||
mock_tc.function.arguments = "{}"
|
||||
tool_calls, provider_blocks = prov._extract_tool_calls([mock_tc])
|
||||
assert len(tool_calls) == 1
|
||||
assert provider_blocks == []
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestDataclasses
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "1.2.0a4"
|
||||
__version__ = "1.2.0a5"
|
||||
|
||||
@@ -1073,7 +1073,7 @@ async def proxy_api(request: Request) -> Response:
|
||||
if request.method == "GET" and path in ("events", "events/global"):
|
||||
return await _proxy_sse(request, server_url, path, api_prefix=api_prefix)
|
||||
|
||||
if request.method in ("POST", "PUT"):
|
||||
if request.method in ("POST", "PUT", "DELETE"):
|
||||
return await _proxy_post(request, server_url, path, api_prefix=api_prefix)
|
||||
|
||||
return await _proxy_get(request, server_url, f"{api_prefix}/{path}")
|
||||
@@ -1110,7 +1110,7 @@ async def _proxy_get(request: Request, server_url: str, path: str) -> Response:
|
||||
async def _proxy_post(
|
||||
request: Request, server_url: str, path: str, *, api_prefix: str = "api"
|
||||
) -> Response:
|
||||
"""Forward a POST/PUT request to the target server."""
|
||||
"""Forward a non-GET request (POST/PUT/DELETE) to the target server."""
|
||||
client: httpx.AsyncClient = request.app.state.proxy_client
|
||||
body = await request.body()
|
||||
content_type = request.headers.get("content-type", "application/json")
|
||||
@@ -7738,8 +7738,16 @@ def create_app(
|
||||
Route("/node/{node_id}/", proxy_index),
|
||||
Route("/node/{node_id}/static/{path:path}", proxy_static),
|
||||
Route("/node/{node_id}/shared/{path:path}", proxy_shared_static),
|
||||
Route("/node/{node_id}/v1/api/{path:path}", proxy_api, methods=["GET", "POST"]),
|
||||
Route("/node/{node_id}/api/{path:path}", proxy_api, methods=["GET", "POST"]),
|
||||
Route(
|
||||
"/node/{node_id}/v1/api/{path:path}",
|
||||
proxy_api,
|
||||
methods=["GET", "POST", "PUT", "DELETE"],
|
||||
),
|
||||
Route(
|
||||
"/node/{node_id}/api/{path:path}",
|
||||
proxy_api,
|
||||
methods=["GET", "POST", "PUT", "DELETE"],
|
||||
),
|
||||
Route("/node/{node_id}/{path:path}", proxy_non_api),
|
||||
],
|
||||
middleware=_build_console_middleware(cors_origins),
|
||||
|
||||
@@ -1109,7 +1109,7 @@ class IntentJudge:
|
||||
if hasattr(client, "close"):
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
log.debug("judge.client_close_failed", exc_info=True)
|
||||
|
||||
def _deliver_fallbacks(
|
||||
self,
|
||||
|
||||
@@ -207,6 +207,14 @@ def _resolve_openai_provider(provider: str, base_url: str) -> str:
|
||||
and should use the Chat Completions provider (``"openai-compatible"``).
|
||||
"""
|
||||
if provider == "openai" and base_url and "api.openai.com" not in base_url:
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
hostname = urlparse(base_url).hostname or ""
|
||||
except Exception:
|
||||
hostname = ""
|
||||
if hostname.endswith(".googleapis.com"):
|
||||
return "google"
|
||||
return "openai-compatible"
|
||||
return provider
|
||||
|
||||
|
||||
@@ -8,12 +8,24 @@ The caller must provide a ``base_url`` pointing at the Gemini endpoint
|
||||
(e.g. ``https://generativelanguage.googleapis.com/v1beta/openai/``);
|
||||
:func:`~turnstone.core.providers.create_client` fills in this default
|
||||
automatically when ``provider_name="google"`` and no URL is given.
|
||||
|
||||
Gemini requires provider-specific fields (e.g. ``thought_signature``)
|
||||
to survive the tool-call → tool-result round-trip. This adapter captures
|
||||
the raw SDK tool-call objects via ``provider_blocks`` and reconstructs
|
||||
them in ``_prepare_messages`` — the same fidelity pattern used by the
|
||||
Anthropic provider.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
|
||||
from turnstone.core.providers._protocol import ModelCapabilities
|
||||
from turnstone.core.providers._openai_common import sanitize_messages
|
||||
from turnstone.core.providers._protocol import ModelCapabilities, StreamChunk
|
||||
|
||||
# Default endpoint used when no base_url is configured.
|
||||
GOOGLE_DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"
|
||||
@@ -35,7 +47,12 @@ _GOOGLE_DEFAULT = ModelCapabilities(
|
||||
|
||||
|
||||
class GoogleProvider(OpenAIChatCompletionsProvider):
|
||||
"""Provider for Google models using the OpenAI-compatible endpoint."""
|
||||
"""Provider for Google models using the OpenAI-compatible endpoint.
|
||||
|
||||
Overrides message preparation and tool-call extraction to preserve
|
||||
Gemini-specific fields (``thought_signature``) through the round-trip
|
||||
via the ``provider_blocks`` / ``_provider_content`` fidelity lane.
|
||||
"""
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
@@ -47,3 +64,97 @@ class GoogleProvider(OpenAIChatCompletionsProvider):
|
||||
# (caps is default) to correctly return None for Google,
|
||||
# signalling "no static per-model entry".
|
||||
return _GOOGLE_DEFAULT
|
||||
|
||||
# -- message preparation (round-trip fidelity) ---------------------------
|
||||
|
||||
def _prepare_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Reconstruct tool_calls from ``_provider_content`` before sending.
|
||||
|
||||
When ``_provider_content`` is present on an assistant message, it
|
||||
contains the raw tool-call dicts (including ``thought_signature``).
|
||||
We replace the normalised ``tool_calls`` with the raw versions and
|
||||
strip ``_provider_content`` so it never reaches the wire.
|
||||
"""
|
||||
cleaned: list[dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
pc = msg.get("_provider_content")
|
||||
if msg.get("role") == "assistant" and pc and isinstance(pc, list):
|
||||
# Rebuild the message without _provider_content
|
||||
msg = {k: v for k, v in msg.items() if k != "_provider_content"}
|
||||
# Extract raw tool-call dicts from provider_blocks.
|
||||
# Only type=="function" is expected today; if Gemini adds
|
||||
# other tool types (e.g. code_execution) they will need
|
||||
# their own round-trip handling here.
|
||||
raw_tcs = [b for b in pc if b.get("type") == "function"]
|
||||
if raw_tcs:
|
||||
msg["tool_calls"] = raw_tcs
|
||||
cleaned.append(msg)
|
||||
return sanitize_messages(cleaned)
|
||||
|
||||
# -- tool-call extraction (non-streaming fidelity) -------------------------
|
||||
|
||||
def _extract_tool_calls(
|
||||
self, sdk_tool_calls: list[Any]
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Capture raw tool-call dicts alongside the normalised ones.
|
||||
|
||||
``model_dump()`` includes ``thought_signature`` and any other
|
||||
provider-specific fields. The raw dicts are returned as
|
||||
``provider_blocks`` so the session stores them in
|
||||
``_provider_content`` for round-trip fidelity.
|
||||
"""
|
||||
tool_calls, _ = super()._extract_tool_calls(sdk_tool_calls)
|
||||
# model_dump() on the Pydantic SDK objects captures thought_signature
|
||||
# and any other provider-specific fields alongside the standard ones.
|
||||
provider_blocks = [tc.model_dump(exclude_none=True) for tc in sdk_tool_calls]
|
||||
return tool_calls, provider_blocks
|
||||
|
||||
# -- streaming -----------------------------------------------------------
|
||||
|
||||
def _iter_stream(self, stream: Any) -> Iterator[StreamChunk]:
|
||||
"""Wrap the base stream to capture raw tool-call metadata.
|
||||
|
||||
Taps the raw SDK stream to accumulate provider-specific fields
|
||||
(e.g. ``thought_signature``) from each tool-call delta, then
|
||||
delegates all chunk processing to the base class. The accumulated
|
||||
raw tool-call dicts are emitted as ``provider_blocks`` on the
|
||||
final chunk so the session stores them as ``_provider_content``.
|
||||
"""
|
||||
raw_tool_calls: dict[int, dict[str, Any]] = {}
|
||||
|
||||
def _tap(raw_stream: Any) -> Any:
|
||||
"""Pass-through iterator that captures tool-call extras."""
|
||||
for chunk in raw_stream:
|
||||
if chunk.choices:
|
||||
delta = chunk.choices[0].delta
|
||||
if delta.tool_calls:
|
||||
for tc_delta in delta.tool_calls:
|
||||
idx = tc_delta.index
|
||||
if idx not in raw_tool_calls:
|
||||
raw_tool_calls[idx] = {
|
||||
"id": "",
|
||||
"type": "function",
|
||||
"function": {"name": "", "arguments": ""},
|
||||
}
|
||||
raw_tc = raw_tool_calls[idx]
|
||||
if tc_delta.id:
|
||||
raw_tc["id"] = tc_delta.id
|
||||
if tc_delta.function:
|
||||
if tc_delta.function.name:
|
||||
raw_tc["function"]["name"] = tc_delta.function.name
|
||||
if tc_delta.function.arguments:
|
||||
raw_tc["function"]["arguments"] += tc_delta.function.arguments
|
||||
# Capture provider-specific extras (e.g. thought_signature)
|
||||
extras = getattr(tc_delta, "__pydantic_extra__", None)
|
||||
if extras:
|
||||
for k, v in extras.items():
|
||||
if k not in ("index", "id", "type", "function"):
|
||||
raw_tc.setdefault(k, v)
|
||||
yield chunk
|
||||
|
||||
# Delegate all chunk processing to the base class
|
||||
for sc in super()._iter_stream(_tap(stream)):
|
||||
# Attach provider_blocks on the finish-reason chunk
|
||||
if sc.finish_reason and raw_tool_calls:
|
||||
sc.provider_blocks = [raw_tool_calls[i] for i in sorted(raw_tool_calls)]
|
||||
yield sc
|
||||
|
||||
@@ -46,6 +46,43 @@ class OpenAIChatCompletionsProvider:
|
||||
def get_capabilities(self, model: str) -> ModelCapabilities:
|
||||
return lookup_openai_capabilities(model)
|
||||
|
||||
# -- message preparation --------------------------------------------------
|
||||
|
||||
def _prepare_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Prepare messages for the API request.
|
||||
|
||||
Subclasses (e.g. GoogleProvider) override this to reconstruct
|
||||
provider-specific content from ``_provider_content`` before
|
||||
sending. The base implementation just calls ``sanitize_messages``.
|
||||
"""
|
||||
return sanitize_messages(messages)
|
||||
|
||||
# -- tool-call extraction -------------------------------------------------
|
||||
|
||||
def _extract_tool_calls(
|
||||
self, sdk_tool_calls: list[Any]
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Extract normalised tool-call dicts from SDK objects.
|
||||
|
||||
Returns ``(tool_calls, provider_blocks)``. The base implementation
|
||||
returns an empty ``provider_blocks`` list. Subclasses (e.g.
|
||||
``GoogleProvider``) override this to capture provider-specific
|
||||
fields (like ``thought_signature``) in ``provider_blocks`` for
|
||||
round-trip fidelity.
|
||||
"""
|
||||
tool_calls = [
|
||||
{
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
},
|
||||
}
|
||||
for tc in sdk_tool_calls
|
||||
]
|
||||
return tool_calls, []
|
||||
|
||||
# -- web search ----------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
@@ -88,7 +125,7 @@ class OpenAIChatCompletionsProvider:
|
||||
cancel_ref: list[Any] | None = None,
|
||||
) -> Iterator[StreamChunk]:
|
||||
caps = self.get_capabilities(model)
|
||||
messages = sanitize_messages(messages)
|
||||
messages = self._prepare_messages(messages)
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
@@ -215,7 +252,7 @@ class OpenAIChatCompletionsProvider:
|
||||
deferred_names: frozenset[str] | None = None,
|
||||
) -> CompletionResult:
|
||||
caps = self.get_capabilities(model)
|
||||
messages = sanitize_messages(messages)
|
||||
messages = self._prepare_messages(messages)
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
@@ -244,18 +281,9 @@ class OpenAIChatCompletionsProvider:
|
||||
msg = choice.message
|
||||
|
||||
tool_calls = None
|
||||
provider_blocks: list[dict[str, Any]] = []
|
||||
if msg.tool_calls:
|
||||
tool_calls = [
|
||||
{
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
},
|
||||
}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
tool_calls, provider_blocks = self._extract_tool_calls(msg.tool_calls)
|
||||
|
||||
# Extract url_citation annotations from web search models
|
||||
content = msg.content or ""
|
||||
@@ -270,6 +298,7 @@ class OpenAIChatCompletionsProvider:
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=choice.finish_reason or "stop",
|
||||
usage=usage,
|
||||
provider_blocks=provider_blocks,
|
||||
)
|
||||
log.debug(
|
||||
"openai.chat.response",
|
||||
|
||||
@@ -487,6 +487,7 @@ class ChatSession:
|
||||
read_only_tools=cs.get("judge.read_only_tools"),
|
||||
output_guard=cs.get("judge.output_guard"),
|
||||
redact_secrets=cs.get("judge.redact_secrets"),
|
||||
cancel_on_approval=cs.get("judge.cancel_on_approval"),
|
||||
)
|
||||
|
||||
def _get_web_search_backend(self) -> str:
|
||||
|
||||
+7
-6
@@ -1735,8 +1735,10 @@ def _extract_last_assistant_content(session: Any) -> str:
|
||||
|
||||
def _fire_notify_targets(ws: Any, content: str) -> None:
|
||||
"""Send completion notifications to all configured targets."""
|
||||
if not content or not ws.notify_targets:
|
||||
if not ws.notify_targets:
|
||||
return
|
||||
if not content:
|
||||
content = "(Task completed — no output captured)"
|
||||
|
||||
try:
|
||||
targets = json.loads(ws.notify_targets)
|
||||
@@ -1928,22 +1930,21 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
resumed = False
|
||||
message_count = 0
|
||||
if resume_ws_id and ws.session is not None:
|
||||
from turnstone.core.memory import get_workstream_display_name, resolve_workstream
|
||||
from turnstone.core.memory import resolve_workstream
|
||||
|
||||
target_id = resolve_workstream(resume_ws_id)
|
||||
if target_id and ws.session.resume(target_id, fork=True):
|
||||
resumed = True
|
||||
message_count = len(ws.session.messages)
|
||||
# If the user provided a custom name, set it as the fork's alias
|
||||
# so it takes priority in display. Otherwise inherit the source name.
|
||||
# so it takes priority in display. Otherwise keep the
|
||||
# auto-generated name so auto-title can run fresh.
|
||||
user_name = body.get("name", "").strip()
|
||||
if user_name:
|
||||
from turnstone.core.memory import set_workstream_alias
|
||||
|
||||
set_workstream_alias(ws.id, user_name)
|
||||
ws.name = user_name
|
||||
else:
|
||||
ws.name = get_workstream_display_name(target_id) or ws.name
|
||||
ui = ws.ui
|
||||
if isinstance(ui, WebUI):
|
||||
ui._enqueue({"type": "clear_ui"})
|
||||
@@ -2032,7 +2033,7 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
def _run_initial() -> None:
|
||||
try:
|
||||
session.send(initial_message)
|
||||
except Exception:
|
||||
except (Exception, GenerationCancelled):
|
||||
if isinstance(ws.ui, WebUI):
|
||||
ws.ui.on_stream_end()
|
||||
ws.ui.on_state_change("idle")
|
||||
|
||||
+216
-79
@@ -1976,7 +1976,12 @@ var _ctxMenu = null;
|
||||
var _ctxCloseHandler = null;
|
||||
var _ctxTriggerElement = null;
|
||||
|
||||
var _tabDropdown = null;
|
||||
var _tabDropdownCloseHandler = null;
|
||||
var _tabDropdownTrigger = null;
|
||||
|
||||
function showPaneContextMenu(x, y, paneId) {
|
||||
closeTabDropdown();
|
||||
closePaneContextMenu();
|
||||
_ctxTriggerElement = document.activeElement;
|
||||
|
||||
@@ -2123,6 +2128,178 @@ function closePaneContextMenu() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3c. Tab dropdown menu (per-tab workstream actions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function showTabDropdown(chevronEl, wsId) {
|
||||
closePaneContextMenu();
|
||||
closeTabDropdown();
|
||||
_tabDropdownTrigger = chevronEl;
|
||||
chevronEl.setAttribute("aria-expanded", "true");
|
||||
|
||||
var menu = document.createElement("div");
|
||||
menu.className = "ws-tab-dropdown";
|
||||
menu.setAttribute("role", "menu");
|
||||
menu.setAttribute("aria-label", "Workstream actions");
|
||||
menu.addEventListener("contextmenu", function (e) {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
var isLastWs = Object.keys(workstreams).length <= 1;
|
||||
var items = [
|
||||
{
|
||||
label: "Refresh title",
|
||||
cls: "mobile-hide",
|
||||
action: function () {
|
||||
refreshWorkstreamTitle(wsId);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Edit title",
|
||||
key: "Ctrl+Shift+E",
|
||||
action: function () {
|
||||
editWorkstreamTitle(wsId);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Fork",
|
||||
key: "Ctrl+Shift+F",
|
||||
action: function () {
|
||||
forkWorkstream(wsId);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Close",
|
||||
key: "Ctrl+W",
|
||||
disabled: isLastWs,
|
||||
action: function () {
|
||||
closeWorkstream(wsId);
|
||||
},
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: "Delete",
|
||||
key: "Ctrl+Shift+X",
|
||||
cls: "destructive",
|
||||
disabled: isLastWs,
|
||||
action: function () {
|
||||
confirmDeleteWorkstream(wsId);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
items.forEach(function (item) {
|
||||
if (item.separator) {
|
||||
var sep = document.createElement("div");
|
||||
sep.className = "ws-tab-dropdown-sep";
|
||||
sep.setAttribute("role", "separator");
|
||||
menu.appendChild(sep);
|
||||
return;
|
||||
}
|
||||
var btn = document.createElement("button");
|
||||
btn.className = "ws-tab-dropdown-item" + (item.cls ? " " + item.cls : "");
|
||||
btn.setAttribute("role", "menuitem");
|
||||
btn.setAttribute("tabindex", "-1");
|
||||
if (item.disabled) {
|
||||
btn.setAttribute("aria-disabled", "true");
|
||||
btn.setAttribute(
|
||||
"title",
|
||||
"Cannot " + item.label.toLowerCase() + " the last workstream",
|
||||
);
|
||||
}
|
||||
var labelSpan = document.createElement("span");
|
||||
labelSpan.className = "ws-tab-dropdown-label";
|
||||
labelSpan.textContent = item.label;
|
||||
btn.appendChild(labelSpan);
|
||||
if (item.key) {
|
||||
var keySpan = document.createElement("span");
|
||||
keySpan.className = "ws-tab-dropdown-key";
|
||||
keySpan.textContent = item.key;
|
||||
keySpan.setAttribute("aria-hidden", "true");
|
||||
btn.appendChild(keySpan);
|
||||
}
|
||||
btn.onclick = function () {
|
||||
if (this.getAttribute("aria-disabled") === "true") return;
|
||||
closeTabDropdown();
|
||||
item.action();
|
||||
};
|
||||
menu.appendChild(btn);
|
||||
});
|
||||
|
||||
document.body.appendChild(menu);
|
||||
|
||||
// Position below chevron, right-aligned
|
||||
var cr = chevronEl.getBoundingClientRect();
|
||||
var mr = menu.getBoundingClientRect();
|
||||
var mx = cr.right - mr.width;
|
||||
var my = cr.bottom + 2;
|
||||
if (mx < 0) mx = 4;
|
||||
if (my + mr.height > window.innerHeight) my = cr.top - mr.height - 2;
|
||||
if (mx + mr.width > window.innerWidth) mx = window.innerWidth - mr.width - 4;
|
||||
menu.style.left = mx + "px";
|
||||
menu.style.top = my + "px";
|
||||
_tabDropdown = menu;
|
||||
|
||||
_tabDropdownCloseHandler = function (e) {
|
||||
if (e.type === "keydown") {
|
||||
if (e.key === "Escape" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
closeTabDropdown();
|
||||
} else if (
|
||||
e.key === "ArrowDown" ||
|
||||
e.key === "ArrowUp" ||
|
||||
e.key === "Home" ||
|
||||
e.key === "End"
|
||||
) {
|
||||
e.preventDefault();
|
||||
var btns = Array.from(menu.querySelectorAll(".ws-tab-dropdown-item"));
|
||||
if (!btns.length) return;
|
||||
var idx = btns.indexOf(document.activeElement);
|
||||
if (e.key === "ArrowDown") btns[(idx + 1) % btns.length].focus();
|
||||
else if (e.key === "ArrowUp")
|
||||
btns[(idx - 1 + btns.length) % btns.length].focus();
|
||||
else if (e.key === "Home") btns[0].focus();
|
||||
else if (e.key === "End") btns[btns.length - 1].focus();
|
||||
}
|
||||
} else if (
|
||||
e.type === "mousedown" &&
|
||||
!menu.contains(e.target) &&
|
||||
e.target !== chevronEl
|
||||
) {
|
||||
closeTabDropdown();
|
||||
}
|
||||
};
|
||||
var closeHandler = _tabDropdownCloseHandler;
|
||||
var activeMenu = menu;
|
||||
setTimeout(function () {
|
||||
if (_tabDropdown !== activeMenu || !closeHandler) return;
|
||||
document.addEventListener("mousedown", closeHandler);
|
||||
document.addEventListener("keydown", closeHandler);
|
||||
var first = activeMenu.querySelector(".ws-tab-dropdown-item");
|
||||
if (first) first.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function closeTabDropdown() {
|
||||
if (_tabDropdown) {
|
||||
_tabDropdown.remove();
|
||||
_tabDropdown = null;
|
||||
}
|
||||
if (_tabDropdownCloseHandler) {
|
||||
document.removeEventListener("mousedown", _tabDropdownCloseHandler);
|
||||
document.removeEventListener("keydown", _tabDropdownCloseHandler);
|
||||
_tabDropdownCloseHandler = null;
|
||||
}
|
||||
if (_tabDropdownTrigger) {
|
||||
_tabDropdownTrigger.setAttribute("aria-expanded", "false");
|
||||
if (document.contains(_tabDropdownTrigger)) {
|
||||
_tabDropdownTrigger.focus();
|
||||
}
|
||||
_tabDropdownTrigger = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 4. Global state
|
||||
// ===========================================================================
|
||||
@@ -2278,6 +2455,7 @@ var tabList = document.getElementById("tab-list");
|
||||
var newTabBtn = document.getElementById("new-tab-btn");
|
||||
|
||||
function renderTabBar() {
|
||||
closeTabDropdown();
|
||||
tabList.querySelectorAll(".ws-tab").forEach(function (t) {
|
||||
t.remove();
|
||||
});
|
||||
@@ -2292,7 +2470,7 @@ function renderTabBar() {
|
||||
tab.setAttribute("tabindex", "0");
|
||||
tab.setAttribute("aria-selected", wsId === currentWsId ? "true" : "false");
|
||||
tab.onclick = function (e) {
|
||||
if (e.target.classList.contains("tab-close")) return;
|
||||
if (e.target.classList.contains("tab-chevron")) return;
|
||||
switchTab(wsId);
|
||||
};
|
||||
tab.onkeydown = function (e) {
|
||||
@@ -2318,23 +2496,28 @@ function renderTabBar() {
|
||||
wsidBadge.textContent = wsId.substring(0, 7);
|
||||
tab.appendChild(wsidBadge);
|
||||
|
||||
var close = document.createElement("button");
|
||||
close.className = "tab-close";
|
||||
close.innerHTML = "×";
|
||||
close.title = "Close workstream";
|
||||
close.setAttribute(
|
||||
var chevron = document.createElement("button");
|
||||
chevron.className = "tab-chevron";
|
||||
chevron.textContent = "\u25BE";
|
||||
chevron.title = "Workstream actions";
|
||||
chevron.setAttribute(
|
||||
"aria-label",
|
||||
"Close " + (ws.name || wsId.substring(0, 6)),
|
||||
"Actions for " + (ws.name || wsId.substring(0, 6)),
|
||||
);
|
||||
close.onclick = function (e) {
|
||||
chevron.setAttribute("aria-haspopup", "menu");
|
||||
chevron.setAttribute("aria-expanded", "false");
|
||||
chevron.onclick = function (e) {
|
||||
e.stopPropagation();
|
||||
closeWorkstream(wsId);
|
||||
if (_tabDropdown && _tabDropdownTrigger === chevron) {
|
||||
closeTabDropdown();
|
||||
} else {
|
||||
showTabDropdown(chevron, wsId);
|
||||
}
|
||||
};
|
||||
tab.appendChild(close);
|
||||
tab.appendChild(chevron);
|
||||
|
||||
tabList.appendChild(tab);
|
||||
});
|
||||
updateWsActionButtons();
|
||||
}
|
||||
|
||||
function updateTabIndicator(wsId, state, extra) {
|
||||
@@ -2387,6 +2570,7 @@ function updateTabIndicator(wsId, state, extra) {
|
||||
}
|
||||
|
||||
function switchTab(wsId) {
|
||||
closeTabDropdown();
|
||||
var pane = getFocusedPane();
|
||||
if (!pane) return;
|
||||
if (wsId === pane.wsId && !dashboardVisible) return;
|
||||
@@ -2416,8 +2600,6 @@ function switchTab(wsId) {
|
||||
pane.updateWsName();
|
||||
renderTabBar();
|
||||
pane.connectSSE(wsId);
|
||||
updateWsActionButtons();
|
||||
_applyTitleButtonState();
|
||||
|
||||
if (!_historyNavigation) {
|
||||
history.pushState({ turnstone: "workstream", wsId: wsId }, "");
|
||||
@@ -2809,7 +2991,6 @@ function closeWorkstream(wsId) {
|
||||
loadDashboard();
|
||||
showDashboard();
|
||||
}
|
||||
updateWsActionButtons();
|
||||
} else if (data.error) {
|
||||
showToast(data.error, "warning");
|
||||
}
|
||||
@@ -3328,12 +3509,10 @@ function confirmWsDelete() {
|
||||
|
||||
var _lastActiveWsId = null;
|
||||
|
||||
function refreshWorkstreamTitle() {
|
||||
var wsId = getCurrentWsId();
|
||||
function refreshWorkstreamTitle(optWsId) {
|
||||
var wsId = optWsId || getCurrentWsId();
|
||||
if (!wsId) return;
|
||||
|
||||
_setTitleState(wsId, "refreshing");
|
||||
|
||||
var url =
|
||||
"/v1/api/workstreams/" + encodeURIComponent(wsId) + "/refresh-title";
|
||||
|
||||
@@ -3348,47 +3527,13 @@ function refreshWorkstreamTitle() {
|
||||
})
|
||||
.catch(function (err) {
|
||||
showToast(err.message || "Failed to refresh title", "error");
|
||||
_setTitleState(wsId, "idle");
|
||||
});
|
||||
}
|
||||
|
||||
// --- Per-workstream title state tracking ---
|
||||
|
||||
var _wsTitleState = {}; // { wsId: "idle" | "refreshing" | "error" }
|
||||
|
||||
function _setTitleState(wsId, state) {
|
||||
if (state === "idle" || state === "error") delete _wsTitleState[wsId];
|
||||
else _wsTitleState[wsId] = state;
|
||||
_applyTitleButtonState();
|
||||
}
|
||||
|
||||
function _applyTitleButtonState() {
|
||||
var btn = document.getElementById("refresh-title-btn");
|
||||
if (!btn) return;
|
||||
var wsId = getCurrentWsId();
|
||||
var state = _wsTitleState[wsId] || "idle";
|
||||
if (state === "refreshing") {
|
||||
btn.innerHTML = "⏳";
|
||||
btn.disabled = true;
|
||||
} else if (state === "error") {
|
||||
btn.innerHTML = "✗";
|
||||
btn.disabled = false;
|
||||
btn.onclick = function () {
|
||||
_setTitleState(wsId, "idle");
|
||||
refreshWorkstreamTitle();
|
||||
};
|
||||
return;
|
||||
} else {
|
||||
btn.innerHTML = "↻";
|
||||
btn.disabled = false;
|
||||
btn.onclick = refreshWorkstreamTitle;
|
||||
}
|
||||
}
|
||||
|
||||
var _editTitleTrap = null;
|
||||
|
||||
function editWorkstreamTitle() {
|
||||
var wsId = getCurrentWsId();
|
||||
function editWorkstreamTitle(optWsId) {
|
||||
var wsId = optWsId || getCurrentWsId();
|
||||
if (!wsId) return;
|
||||
var currentTitle = "";
|
||||
var tabEl = document.querySelector(
|
||||
@@ -3440,8 +3585,8 @@ function cancelEditTitle() {
|
||||
document.removeEventListener("keydown", _editTitleTrap);
|
||||
_editTitleTrap = null;
|
||||
}
|
||||
var btn = document.getElementById("edit-title-btn");
|
||||
if (btn) btn.focus();
|
||||
var chevron = document.querySelector(".ws-tab.active .tab-chevron");
|
||||
if (chevron) chevron.focus();
|
||||
}
|
||||
|
||||
function submitEditTitle() {
|
||||
@@ -3487,9 +3632,10 @@ function submitEditTitle() {
|
||||
var _pendingDeleteWsId = null;
|
||||
var _deleteWsTrap = null;
|
||||
|
||||
function confirmDeleteWorkstream() {
|
||||
var wsId = getCurrentWsId();
|
||||
function confirmDeleteWorkstream(optWsId) {
|
||||
var wsId = optWsId || getCurrentWsId();
|
||||
if (!wsId) return;
|
||||
if (Object.keys(workstreams).length <= 1) return;
|
||||
var tabEl = document.querySelector(
|
||||
'.ws-tab[data-ws-id="' + wsId + '"] .tab-name',
|
||||
);
|
||||
@@ -3536,9 +3682,9 @@ function cancelDeleteWs() {
|
||||
document.removeEventListener("keydown", _deleteWsTrap);
|
||||
_deleteWsTrap = null;
|
||||
}
|
||||
var btn = document.getElementById("delete-ws-btn");
|
||||
if (btn && btn.offsetParent !== null) {
|
||||
btn.focus();
|
||||
var chevron = document.querySelector(".ws-tab.active .tab-chevron");
|
||||
if (chevron) {
|
||||
chevron.focus();
|
||||
} else {
|
||||
var fallback = document.getElementById("new-tab-btn");
|
||||
if (fallback) fallback.focus();
|
||||
@@ -3568,7 +3714,6 @@ function executeDeleteWs() {
|
||||
loadDashboard();
|
||||
showDashboard();
|
||||
}
|
||||
updateWsActionButtons();
|
||||
showToast("Workstream deleted", "success");
|
||||
})
|
||||
.catch(function (err) {
|
||||
@@ -3582,15 +3727,8 @@ function getCurrentWsId() {
|
||||
return "";
|
||||
}
|
||||
|
||||
function updateWsActionButtons() {
|
||||
var group = document.getElementById("ws-action-group");
|
||||
if (group) {
|
||||
group.classList.toggle("hidden", !getCurrentWsId());
|
||||
}
|
||||
}
|
||||
|
||||
function forkWorkstream() {
|
||||
var wsId = getCurrentWsId();
|
||||
function forkWorkstream(optWsId) {
|
||||
var wsId = optWsId || getCurrentWsId();
|
||||
if (!wsId) return;
|
||||
showNewWsModal(wsId);
|
||||
}
|
||||
@@ -3742,8 +3880,6 @@ function connectGlobalSSE() {
|
||||
for (var id in panes) {
|
||||
if (panes[id].wsId === data.ws_id) panes[id].updateWsName();
|
||||
}
|
||||
// Title generation completed — reset state
|
||||
_setTitleState(data.ws_id, "idle");
|
||||
} else if (data.type === "ws_created") {
|
||||
workstreams[data.ws_id] = workstreams[data.ws_id] || {};
|
||||
workstreams[data.ws_id].name = data.name || data.ws_id.slice(0, 6);
|
||||
@@ -4548,13 +4684,9 @@ document.addEventListener("keydown", function (e) {
|
||||
// is active, so native browser shortcuts (e.g. Ctrl+Shift+R hard reload)
|
||||
// still work when no workstream is focused.
|
||||
if (e.ctrlKey && e.shiftKey) {
|
||||
closeTabDropdown();
|
||||
var wsActionKey = e.key.toLowerCase();
|
||||
var activeWsId = !dashboardVisible && getCurrentWsId();
|
||||
if (wsActionKey === "r" && activeWsId) {
|
||||
e.preventDefault();
|
||||
refreshWorkstreamTitle();
|
||||
return;
|
||||
}
|
||||
if (wsActionKey === "e" && activeWsId) {
|
||||
e.preventDefault();
|
||||
editWorkstreamTitle();
|
||||
@@ -4566,7 +4698,11 @@ document.addEventListener("keydown", function (e) {
|
||||
return;
|
||||
}
|
||||
// X not D — D conflicts with Chrome DevTools
|
||||
if (wsActionKey === "x" && activeWsId) {
|
||||
if (
|
||||
wsActionKey === "x" &&
|
||||
activeWsId &&
|
||||
Object.keys(workstreams).length > 1
|
||||
) {
|
||||
e.preventDefault();
|
||||
confirmDeleteWorkstream();
|
||||
return;
|
||||
@@ -4574,6 +4710,7 @@ document.addEventListener("keydown", function (e) {
|
||||
}
|
||||
// Ctrl+W: close current workstream tab
|
||||
if (e.ctrlKey && !e.shiftKey && e.key === "w") {
|
||||
closeTabDropdown();
|
||||
if (Object.keys(workstreams).length > 1) {
|
||||
e.preventDefault();
|
||||
closeWorkstream(currentWsId);
|
||||
|
||||
@@ -22,14 +22,6 @@
|
||||
|
||||
<div id="tab-bar" role="toolbar" aria-label="Workstreams">
|
||||
<div id="tab-list" role="tablist"></div>
|
||||
<div id="ws-action-group" class="ws-action-group hidden">
|
||||
<span class="tab-bar-sep"></span>
|
||||
<button id="refresh-title-btn" class="tab-bar-btn ws-action-btn" onclick="refreshWorkstreamTitle()" aria-label="Regenerate title" title="Regenerate title">↻</button>
|
||||
<button id="edit-title-btn" class="tab-bar-btn ws-action-btn" onclick="editWorkstreamTitle()" aria-label="Edit title" title="Edit title">✎</button>
|
||||
<button id="fork-ws-btn" class="tab-bar-btn ws-action-btn" onclick="forkWorkstream()" aria-label="Fork workstream" title="Fork workstream">⑂</button>
|
||||
<button id="delete-ws-btn" class="tab-bar-btn ws-action-btn ws-action-btn-danger" onclick="confirmDeleteWorkstream()" aria-label="Delete workstream" title="Delete workstream"><span aria-hidden="true">🗑</span></button>
|
||||
<span class="tab-bar-sep"></span>
|
||||
</div>
|
||||
<button id="new-tab-btn" onclick="newWorkstream()" title="New workstream (Ctrl+T)" aria-label="New workstream" aria-keyshortcuts="Control+t">+</button>
|
||||
<button id="split-btn" onclick="splitFocusedPane()" title="Split pane (Ctrl+\)" aria-label="Split pane" aria-keyshortcuts="Control+Backslash">⧉</button>
|
||||
</div>
|
||||
|
||||
@@ -51,10 +51,9 @@
|
||||
Mobile overrides
|
||||
========================================================================== */
|
||||
@media (max-width: 600px) {
|
||||
.ws-tab .tab-close { opacity: 1; padding: 4px 6px; font-size: 16px; }
|
||||
.ws-tab .tab-chevron { opacity: 1; padding: 8px 10px; font-size: 14px; min-width: 36px; min-height: 36px; }
|
||||
.ws-tab-dropdown-item.mobile-hide { display: none; }
|
||||
#split-btn { display: none; }
|
||||
#refresh-title-btn { display: none !important; }
|
||||
.ws-action-btn { padding: 4px 6px; margin: 0 1px; font-size: 13px; }
|
||||
.tab-wsid { display: none; }
|
||||
}
|
||||
|
||||
@@ -111,19 +110,23 @@
|
||||
.ws-tab .tab-indicator[data-state="attention"] { background: var(--yellow); border-radius: 1px; transform: rotate(45deg); box-shadow: 0 0 6px var(--yellow-glow); animation: pulse 1s ease-in-out infinite; will-change: opacity; }
|
||||
.ws-tab .tab-indicator[data-state="error"] { background: var(--red); box-shadow: 0 0 4px var(--red-glow); }
|
||||
|
||||
.ws-tab .tab-close {
|
||||
.ws-tab .tab-chevron {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fg-dim);
|
||||
font-size: 14px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
padding: 4px 6px;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.1s;
|
||||
transition: opacity 0.15s, color 0.1s, background 0.1s;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-right: -4px;
|
||||
}
|
||||
.ws-tab:hover .tab-close, .ws-tab:focus-within .tab-close, .ws-tab .tab-close:focus-visible { opacity: 1; }
|
||||
.ws-tab .tab-close:hover { color: var(--red); }
|
||||
.ws-tab:hover .tab-chevron, .ws-tab:focus-within .tab-chevron, .ws-tab .tab-chevron:focus-visible { opacity: 1; }
|
||||
.ws-tab.active .tab-chevron { opacity: 0.7; }
|
||||
.ws-tab .tab-chevron[aria-expanded="true"] { opacity: 1; color: var(--fg-bright); }
|
||||
.ws-tab .tab-chevron:hover { color: var(--fg-bright); background: rgba(255, 255, 255, 0.06); }
|
||||
|
||||
/* Subtle ws_id badge in tabs */
|
||||
.tab-wsid {
|
||||
@@ -181,24 +184,53 @@
|
||||
#split-btn:hover { background: var(--bg-highlight); color: var(--accent); border-color: var(--accent); }
|
||||
#split-btn.hidden { display: none; }
|
||||
|
||||
.ws-action-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
margin: 0 2px;
|
||||
/* Tab dropdown menu */
|
||||
@keyframes dropdown-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.ws-tab-dropdown {
|
||||
position: fixed;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
min-width: 160px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
z-index: 300;
|
||||
overflow: hidden;
|
||||
padding: 4px 0;
|
||||
animation: dropdown-in 0.1s ease-out;
|
||||
}
|
||||
.ws-action-btn:hover { background: var(--bg-highlight); color: var(--fg-bright); border-color: var(--accent); }
|
||||
.ws-action-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.ws-action-btn-danger:hover { color: var(--red); border-color: var(--red); }
|
||||
.ws-action-group { display: flex; align-items: center; flex-shrink: 0; }
|
||||
.ws-action-group.hidden { display: none; }
|
||||
.tab-bar-sep { width: 1px; height: 16px; background: var(--border-strong); margin: 0 4px; flex-shrink: 0; }
|
||||
[data-theme="light"] .ws-tab-dropdown { box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); }
|
||||
.ws-tab-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
padding: 7px 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.ws-tab-dropdown-item:hover:not([aria-disabled="true"]) { background: var(--bg-highlight); color: var(--fg-bright); }
|
||||
.ws-tab-dropdown-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.ws-tab-dropdown-item[aria-disabled="true"] { color: var(--fg-dim); opacity: 0.55; cursor: not-allowed; }
|
||||
.ws-tab-dropdown-item.destructive:hover:not([aria-disabled="true"]),
|
||||
.ws-tab-dropdown-item.destructive:focus-visible:not([aria-disabled="true"]) { color: var(--red); background: rgba(248, 113, 113, 0.08); }
|
||||
.ws-tab-dropdown-item.destructive:focus-visible:not([aria-disabled="true"]) { outline-color: var(--red); }
|
||||
.ws-tab-dropdown-label { flex: 1; }
|
||||
.ws-tab-dropdown-key {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--fg-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ws-tab-dropdown-sep { height: 1px; background: var(--border-strong); margin: 6px 0; }
|
||||
.header-spacer { flex: 1; }
|
||||
|
||||
/* Edit title & delete modals */
|
||||
@@ -1807,7 +1839,7 @@ audio.media-player {
|
||||
.tool-output-stream { animation: none; border-left-color: var(--accent); }
|
||||
.judge-spinner-dot { animation: none; opacity: 1; }
|
||||
.thinking-indicator::after { animation: none; content: '...'; }
|
||||
.ws-tab, .ws-tab .tab-close, #new-tab-btn, #split-btn,
|
||||
.ws-tab, .ws-tab .tab-chevron, #new-tab-btn, #split-btn,
|
||||
.dashboard-card,
|
||||
.approval-btn, .approval-feedback-input,
|
||||
#plan-buttons button, .pane-input-area button,
|
||||
@@ -1819,5 +1851,6 @@ audio.media-player {
|
||||
#new-ws-cancel, #new-ws-submit,
|
||||
#new-ws-box input, #new-ws-box select,
|
||||
.split-handle, .pane-action-btn,
|
||||
.pane-ctx-item { transition: none; }
|
||||
.pane-ctx-item, .ws-tab-dropdown-item { transition: none; }
|
||||
.ws-tab-dropdown { animation: none; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user