mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 07:22:24 -06:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52bea510e2 | |||
| 1f700bf72a | |||
| 6b353ea225 | |||
| caafac901e | |||
| 745d6ece59 | |||
| 75b9222a7f | |||
| 70cc8dd97d | |||
| 019d13d930 | |||
| 8fbcbff566 | |||
| a27738867f | |||
| 1d0be9773f | |||
| ad56e1ec96 | |||
| 8f0115ee2e | |||
| 7aba631201 | |||
| f3f5e84f2d | |||
| ff1e3e5c1c | |||
| f0d7305b28 | |||
| 84a545cb21 | |||
| 8e11929ba0 | |||
| d9e9a41b17 | |||
| 848b2cc1fb | |||
| 1946002618 | |||
| 4bce6abc7c | |||
| c19432f12a |
+1
-1
@@ -8,7 +8,7 @@ FROM python:3.14-slim
|
||||
LABEL org.opencontainers.image.title="turnstone" \
|
||||
org.opencontainers.image.description="Multi-node AI orchestration platform"
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.19 /uv /usr/local/bin/uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.21 /uv /usr/local/bin/uv
|
||||
|
||||
# Remove the slim image's man page exclusion so man-db has actual content
|
||||
RUN rm -f /etc/dpkg/dpkg.cfg.d/docker
|
||||
|
||||
+58
-1
@@ -702,7 +702,7 @@ agent_model = "claude"
|
||||
|
||||
Each `[models.*]` entry produces a `ModelConfig` with a `provider` field
|
||||
(default: `"openai"`). Supported values: `"openai"`, `"anthropic"`, `"google"`,
|
||||
and `"openai-compatible"`.
|
||||
`"openai-compatible"`, and `"anthropic-compatible"`.
|
||||
|
||||
**Per-model sampling overrides:** Each model can specify `temperature`,
|
||||
`max_tokens`, and `reasoning_effort` to override the global defaults from
|
||||
@@ -765,6 +765,63 @@ model = "qwen-3.5-vl"
|
||||
supports_vision = true
|
||||
```
|
||||
|
||||
**Anthropic-compatible local servers (vLLM `/v1/messages`):** the
|
||||
`"anthropic-compatible"` provider drives local servers that expose
|
||||
Anthropic's Messages API for arbitrary checkpoints — vLLM's
|
||||
`/v1/messages` endpoint, which requires a release with thinking-block
|
||||
support in the Anthropic endpoint (post-2026-02-28; verified against
|
||||
v0.22.1rc1). The lane reuses `AnthropicProvider` in compat mode: same
|
||||
wire translation as the real Anthropic lane, but every model resolves to
|
||||
the `_ANTHROPIC_COMPAT_DEFAULT` capabilities (200K context, 64K output,
|
||||
`token_param=max_tokens`, `thinking_mode=none`, no native
|
||||
web_search/tool_search, no vision) — the static Claude table never
|
||||
applies to local checkpoints. `base_url` is required — the server root
|
||||
WITHOUT `/v1` (the Anthropic SDK appends `/v1/messages`); a trailing
|
||||
`/v1` pasted out of openai-compatible habit is stripped automatically,
|
||||
and an empty value fails at client construction rather than falling
|
||||
back to the commercial endpoint. Set a
|
||||
placeholder `api_key` (e.g. `"dummy"`) for unauthenticated servers. Tool calling
|
||||
needs the server started with `--enable-auto-tool-choice
|
||||
--tool-call-parser <family>` plus the matching reasoning parser.
|
||||
Per-model capability overrides opt in to what the checkpoint actually
|
||||
supports:
|
||||
|
||||
```toml
|
||||
[models.vllm-claude]
|
||||
provider = "anthropic-compatible"
|
||||
base_url = "http://localhost:8000" # no /v1 — the SDK appends /v1/messages
|
||||
api_key = "dummy"
|
||||
model = "deepseek-ai/DeepSeek-V4-Flash"
|
||||
|
||||
[models.vllm-claude.capabilities]
|
||||
supports_vision = true # multimodal checkpoints only
|
||||
supports_mid_conversation_system = true # template-dependent
|
||||
context_window = 131072
|
||||
```
|
||||
|
||||
The reasoning toggle does NOT use Anthropic's `thinking` request param.
|
||||
Toggle it through the chat template instead: set `{"chat_template_kwargs":
|
||||
{"thinking": false}}` as extra body params in the admin Models
|
||||
server-compat section (for this provider the section shows only the
|
||||
extra-body field — server type, API surface, and thinking mode are
|
||||
openai-compatible-only knobs); the provider forwards it via the SDK's
|
||||
`extra_body`.
|
||||
|
||||
Verified quirks of vLLM's Anthropic endpoint:
|
||||
|
||||
* The `thinking` request param is silently dropped — use
|
||||
`chat_template_kwargs` (above) to control reasoning.
|
||||
* `stop_sequences` cut the raw stream wherever the text appears —
|
||||
including inside thinking — and report `end_turn` with
|
||||
`stop_sequence=None`. Turnstone does not send stop sequences from
|
||||
this provider.
|
||||
* No cache telemetry: `usage` carries input/output token counts only
|
||||
(no `cache_creation_input_tokens` / `cache_read_input_tokens`).
|
||||
* Images require a multimodal checkpoint — text-only models return a
|
||||
500 on image blocks, so `supports_vision` stays opt-in per model.
|
||||
* Mid-conversation `role: "system"` turns are template-dependent —
|
||||
opt in per model via `supports_mid_conversation_system`.
|
||||
|
||||
**Database model definitions:** On server entry points, models can also be
|
||||
defined in the `model_definitions` table (admin Models tab). DB models support
|
||||
the same per-model sampling overrides. Config.toml models override DB models
|
||||
|
||||
+11
-2
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "1.6.0"
|
||||
version = "1.6.3"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
@@ -26,7 +26,7 @@ dependencies = [
|
||||
"openai>=2.37",
|
||||
"anthropic>=0.108", # claude-fable-5 support; hard runtime floor is 0.105 (mid-conversation system blocks)
|
||||
"httpx>=0.28",
|
||||
"mcp>=1.27",
|
||||
"mcp>=1.27,<2", # v2 is a breaking rewrite (2.0.0a1 live 2026-06-11; stable ~2026-07-27) — streamablehttp_client removed, 2-tuple transport, snake_case types; migrate deliberately
|
||||
"starlette>=1.0.1", # PYSEC-2026-161: host-header path-injection in URL reconstruction (auth-bypass on apps comparing reconstructed URL paths)
|
||||
"uvicorn>=0.34",
|
||||
"sse-starlette>=2.0",
|
||||
@@ -94,6 +94,15 @@ include = [
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
markers = ["live: requires a running LLM backend"]
|
||||
filterwarnings = [
|
||||
# mcp v1 deprecates streamablehttp_client for an entry point whose call
|
||||
# shape only settles in v2 — adoption rides the deliberate v2 migration
|
||||
# (pin capped <2); silence exactly this message until then.
|
||||
"ignore:Use `streamable_http_client` instead",
|
||||
# starlette deprecates the httpx-backed TestClient; revisit at the next
|
||||
# starlette floor bump.
|
||||
"ignore:Using `httpx` with `starlette.testclient` is deprecated",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
|
||||
@@ -39,6 +39,12 @@ Console harness (?open=): schedule-create · schedule-edit · model-create ·
|
||||
title instead of passing silently.
|
||||
Governance surfaces (roles/HR/OGP/memory/skill) need fixtures that are not
|
||||
canned yet — add a fixture + driver branch below when you need one.
|
||||
Shell harness (?split=): right (default) · down · three · none — boots the
|
||||
REAL shell.js + pane.js split-view engine over stubbed seams (two demo
|
||||
conversational panes; ?split=three adds the Dashboard cell). + &theme=light.
|
||||
document.title stamps SPLIT-READY-<visible cells> on success and
|
||||
SPLIT-FAILED-<reason> when a driven split was denied — judge the focused
|
||||
cell's top accent bar, the separators, and the .shown tab marker.
|
||||
|
||||
Rebuild after ANY markup change: the dialog blocks are embedded at build
|
||||
time. Assets are symlinked, so CSS/JS edits are live on refresh.
|
||||
@@ -455,6 +461,135 @@ CONSOLE_TEMPLATE = """<!doctype html>
|
||||
"""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Shell harness — the SPLIT-VIEW surface. Unlike the ui/console pages (which
|
||||
# embed extracted markup), this one boots the REAL shell.js + pane.js over
|
||||
# stubbed classic seams and drives the split engine via ?split=. Two demo
|
||||
# conversational panes give the cells plausible content; the Dashboard pane
|
||||
# (registered by the shell itself) fills the third cell in ?split=three.
|
||||
# Loud-failure rule: the title stamps SPLIT-READY-<cells> only when the built
|
||||
# state matches the request — a denied/failed split stamps SPLIT-FAILED-<why>.
|
||||
# --------------------------------------------------------------------------
|
||||
SHELL_TEMPLATE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>shell livepass</title>
|
||||
<link rel="stylesheet" href="shared/base.css" />
|
||||
<link rel="stylesheet" href="shared/ui-base.css" />
|
||||
<link rel="stylesheet" href="shared/chat.css" />
|
||||
<link rel="stylesheet" href="shared/conversation.css" />
|
||||
<link rel="stylesheet" href="shared/cards.css" />
|
||||
<link rel="stylesheet" href="static/style.css" />
|
||||
<link rel="stylesheet" href="shared/shell.css" />
|
||||
<link rel="stylesheet" href="shared/interactive.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="header"><div id="status-bar"></div><button id="theme-toggle">☾</button></div>
|
||||
<div id="breadcrumb"></div>
|
||||
<div id="main" style="padding: 18px">
|
||||
<h2 style="margin: 0 0 8px">Dashboard</h2>
|
||||
<p style="color: var(--ink-3)">
|
||||
Launcher + workstreams table live here (livepass stub).
|
||||
</p>
|
||||
</div>
|
||||
<div id="view-admin" style="display: none"></div>
|
||||
<script>
|
||||
window.TURNSTONE_SHELL_CAPS = { cluster: false, brandSub: "console" };
|
||||
window.TS_APP = {
|
||||
boot() {},
|
||||
getClusterState() { return { nodes: {} }; },
|
||||
onRender() {},
|
||||
};
|
||||
window.TS_ADMIN = {};
|
||||
var q = new URLSearchParams(location.search);
|
||||
if (q.get("theme") === "light")
|
||||
document.documentElement.dataset.theme = "light";
|
||||
</script>
|
||||
<script type="module" src="shared/shell.js"></script>
|
||||
<script type="module">
|
||||
const q = new URLSearchParams(location.search);
|
||||
for (let i = 0; i < 100 && !window.TS_SHELL; i++)
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
if (!window.TS_SHELL) {
|
||||
document.title = "SPLIT-FAILED-no-shell";
|
||||
} else {
|
||||
sessionStorage.clear();
|
||||
const pm = window.TS_SHELL.panes;
|
||||
const { ShellPane } = await import("./shared/pane.js");
|
||||
const mkConv = (type, title, lines) => {
|
||||
pm.registerType(type, () => {
|
||||
const p = new ShellPane({ type, title });
|
||||
p.tabMenu = () => [
|
||||
{ label: "Close pane", action: () => pm.close(p.id) },
|
||||
];
|
||||
p.onMount = function () {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.style.cssText =
|
||||
"flex:1;min-height:0;padding:16px;display:flex;flex-direction:column;gap:10px;overflow:auto;";
|
||||
for (const [role, text] of lines) {
|
||||
const d = document.createElement("div");
|
||||
d.className = "msg " + role;
|
||||
d.textContent = text;
|
||||
wrap.append(d);
|
||||
}
|
||||
// Edge-touching opaque chrome — the strip that occluded the
|
||||
// focus ring before the ::after overlay; keeps the bug class
|
||||
// visible in every future pass.
|
||||
const sb = document.createElement("div");
|
||||
sb.className = "ws-status-bar";
|
||||
sb.textContent = "17,418 / 393,216 (4.4%) · max 9 tools";
|
||||
this.bodyEl.append(wrap, sb);
|
||||
};
|
||||
return p;
|
||||
});
|
||||
};
|
||||
mkConv("repro", "repro-flaky-suite", [
|
||||
["user", "Track down the flaky retry in the channel gateway tests."],
|
||||
[
|
||||
"assistant",
|
||||
"Three suspects so far — the debounce window in mcp_client, the " +
|
||||
"circuit-breaker reset, and the socket-mode reconnect. Bisecting now.",
|
||||
],
|
||||
[
|
||||
"assistant",
|
||||
"Found it: the breaker reset races the stream pre-close. Patch incoming.",
|
||||
],
|
||||
]);
|
||||
mkConv("relnotes", "draft-1.6.2-notes", [
|
||||
["user", "Draft the 1.6.2 patch notes from the merged PR list."],
|
||||
[
|
||||
"assistant",
|
||||
"Pulling #657–#662. Consent badge, orphan verb, MCP task hygiene, " +
|
||||
"the anthropic-compatible lane, and the mcp<2 cap.",
|
||||
],
|
||||
]);
|
||||
pm.openPane("repro");
|
||||
pm.openPane("relnotes");
|
||||
const want = q.get("split") || "right";
|
||||
let failed = null;
|
||||
if (want !== "none") {
|
||||
const r1 = pm.splitFocused("right");
|
||||
if (!r1.ok) failed = r1.reason;
|
||||
if (!failed && (want === "three" || want === "down")) {
|
||||
const r2 = pm.splitFocused("down");
|
||||
if (!r2.ok) failed = r2.reason;
|
||||
}
|
||||
}
|
||||
const cells = document.querySelectorAll(
|
||||
".panes > section.pane:not([hidden])",
|
||||
).length;
|
||||
document.title = failed
|
||||
? "SPLIT-FAILED-" + failed
|
||||
: "SPLIT-READY-" + cells;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def build(out: Path) -> None:
|
||||
ui = out / "ui"
|
||||
con = out / "console"
|
||||
@@ -483,6 +618,13 @@ def build(out: Path) -> None:
|
||||
(con / "livepass.html").write_text(page, encoding="utf-8")
|
||||
print(f"{con}/livepass.html — admin fragment + {len(riders)} rider dialogs")
|
||||
|
||||
sh = out / "shell"
|
||||
sh.mkdir(parents=True, exist_ok=True)
|
||||
symlink(sh / "shared", ROOT / "turnstone/shared_static")
|
||||
symlink(sh / "static", ROOT / "turnstone/console/static")
|
||||
(sh / "livepass.html").write_text(SHELL_TEMPLATE, encoding="utf-8")
|
||||
print(f"{sh}/livepass.html — split-view shell surface")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
|
||||
+95
-7
@@ -285,6 +285,38 @@ def test_system_turn_dedups_against_history_by_event_id() -> None:
|
||||
"replayHistory (and the live handler) must record system-turn ids for the dedup set."
|
||||
)
|
||||
|
||||
# Pin the wiring on BOTH read paths, scoped to its method — a refactor that
|
||||
# keeps the Set but drops the live-handler consultation (or the
|
||||
# replayHistory-side record) silently re-opens the double-render while the
|
||||
# file-global checks above still pass.
|
||||
live_start = body.index('case "system_turn":')
|
||||
# End at the NEXT switch case, not the first ``break;`` — the dedup-skip
|
||||
# path breaks before the ``.add(``, so a ``break;``-bounded slice would
|
||||
# drop the record half and false-fail the ``.add(`` assertion below.
|
||||
# Whitespace-tolerant so a reformat can't silently break the bound.
|
||||
next_case = re.search(r'\n\s*case "', body[live_start + 1 :])
|
||||
assert next_case, (
|
||||
"no switch case found after system_turn to bound the pin slice — if "
|
||||
"system_turn became the last case, re-anchor this pin's end marker."
|
||||
)
|
||||
live_block = body[live_start : live_start + 1 + next_case.start()]
|
||||
assert re.search(r"_renderedSystemEventIds[\s\S]*?\.\s*has\(", live_block), (
|
||||
"the live system_turn handler must CONSULT the dedup set (skip an id "
|
||||
"already painted from /history), not merely reference the Set elsewhere."
|
||||
)
|
||||
assert re.search(r"_renderedSystemEventIds[\s\S]*?\.\s*add\(", live_block), (
|
||||
"the live system_turn handler must RECORD the id it renders so a later "
|
||||
"/history re-render (clear_ui) doesn't repaint it."
|
||||
)
|
||||
|
||||
replay_start = _pane_method_offset(body, "replayHistory")
|
||||
replay_end = _pane_method_offset(body, "_attachRetryToLastAssistant")
|
||||
replay_block = body[replay_start:replay_end]
|
||||
assert re.search(r"_renderedSystemEventIds[\s\S]*?\.\s*add\(", replay_block), (
|
||||
"replayHistory must record each replayed system row's event_id so the "
|
||||
"live system_turn handler can dedup against it."
|
||||
)
|
||||
|
||||
|
||||
def test_retry_walk_skips_operator_context_cards() -> None:
|
||||
"""Interactive twin of the coord retry-skip guard.
|
||||
@@ -396,9 +428,11 @@ def test_phase8_mcp_error_helpers_defined() -> None:
|
||||
(interactive consent / forbidden / operator card) moved into the shared
|
||||
interactive module with the Pane. The consent-badge state
|
||||
(``_pendingConsentServers`` / ``_onConsentDetected``) stays in the
|
||||
standalone shell — it drives the settings-gear badge — and the pane reaches
|
||||
it through the ``host.onConsentDetected`` seam (a no-op in the console,
|
||||
which has no gear badge). Pin both halves and the seam."""
|
||||
standalone shell — it drives the rail's Manage-row badge — and the pane
|
||||
reaches it through the ``host.onConsentDetected`` seam. The shared host
|
||||
bridges that seam to the standalone via ``window.TS_APP.onConsentDetected``
|
||||
(undefined on the console, so it stays a no-op there). Pin both halves and
|
||||
the bridge."""
|
||||
inter = _INTERACTIVE_JS.read_text(encoding="utf-8")
|
||||
assert "function tryParseMcpError" in inter
|
||||
assert "function buildMcpErrorEmbed" in inter
|
||||
@@ -408,14 +442,62 @@ def test_phase8_mcp_error_helpers_defined() -> None:
|
||||
assert "onConsentDetected(s)" in inter, (
|
||||
"the pane must notify consent through host.onConsentDetected"
|
||||
)
|
||||
# The shared host bridges the seam to the standalone subsystem (feature-
|
||||
# detected, so the console — which never defines the hook — no-ops).
|
||||
assert "window.TS_APP.onConsentDetected(server)" in inter, (
|
||||
"the shared interactive host must bridge onConsentDetected to the TS_APP seam"
|
||||
)
|
||||
app = _APP_JS.read_text(encoding="utf-8")
|
||||
assert "_pendingConsentServers" in app
|
||||
assert "function _onConsentDetected" in app
|
||||
assert "onConsentDetected(server)" in app, (
|
||||
"STANDALONE_HOST must wire host.onConsentDetected -> _onConsentDetected"
|
||||
assert "window.TS_APP.onConsentDetected = _onConsentDetected" in app, (
|
||||
"the standalone must expose _onConsentDetected on the TS_APP seam for the pane bridge"
|
||||
)
|
||||
|
||||
|
||||
def test_consent_badge_drives_rail_manage_row() -> None:
|
||||
"""The pending-consent badge was re-homed off the retired settings gear
|
||||
(``#settings-btn``, deleted in the L-shell renovation, which silently made
|
||||
the badge invisible) onto the rail's Manage > Connections row. Classic
|
||||
app.js can't import the ESM rail module, so it drives the rail's generic
|
||||
``setRowBadge`` hook through the ``window.TS_SHELL`` bridge — keyed on the
|
||||
standalone's Connections tab. Pin the new lane and the absence of the dead
|
||||
gear lookup."""
|
||||
app = _APP_JS.read_text(encoding="utf-8")
|
||||
# The badge refresh must drive the rail bridge, not the deleted gear.
|
||||
assert 'getElementById("settings-btn")' not in app, (
|
||||
"the consent badge must no longer target the retired #settings-btn gear"
|
||||
)
|
||||
assert "shell.setRowBadge(_CONSENT_BADGE_TAB" in app, (
|
||||
"_refreshConsentBadge must drive the rail Manage-row badge via the TS_SHELL bridge"
|
||||
)
|
||||
assert 'const _CONSENT_BADGE_TAB = "connections"' in app, (
|
||||
"the standalone badge rides the Connections Manage tab (its MCP surface)"
|
||||
)
|
||||
# The hydrate + clear paths must still funnel through the single refresh.
|
||||
assert "function loadPendingConsents" in app and "_refreshConsentBadge()" in app
|
||||
|
||||
|
||||
def test_media_player_activation_not_duplicated_in_standalone() -> None:
|
||||
"""The media-player activation (``_loadHls`` / ``_activatePlayer`` + the
|
||||
click/keydown delegate) moved into the shared interactive pane so BOTH the
|
||||
standalone server and the console activate the Play button. The standalone
|
||||
app.js must NOT keep its own copy — a duplicate document-level listener
|
||||
would double-fire on the standalone (two players swapped in) while the lift
|
||||
is what fixed the console (where app.js was never the host). Pin the
|
||||
standalone clean so the stale copy can't drift back in."""
|
||||
app = _APP_JS.read_text(encoding="utf-8")
|
||||
for name in ("_loadHls", "_activatePlayer", "_isHlsUrl", "media-play-btn"):
|
||||
assert name not in app, (
|
||||
f"standalone app.js must not re-declare the lifted media player "
|
||||
f"({name!r}) — it lives in shared_static/interactive.js now"
|
||||
)
|
||||
# The lift target carries the real implementation (the click delegate too).
|
||||
inter = _INTERACTIVE_JS.read_text(encoding="utf-8")
|
||||
assert "function _activatePlayer(" in inter
|
||||
assert "activateMediaPlayButton(btn)" in inter
|
||||
|
||||
|
||||
def test_phase8_settings_panel_handlers_defined() -> None:
|
||||
"""The settings modal exposes four entry points that the inline
|
||||
``onclick`` attributes in index.html depend on. Renaming or
|
||||
@@ -676,7 +758,9 @@ def test_phase8_css_classes_present_in_stylesheet() -> None:
|
||||
connections render in the Admin pane's Connections panel (#view-admin), not a
|
||||
floating dialog — so #settings-overlay / #settings-box are no longer pinned.
|
||||
The revoke confirm's chrome moved to /shared/hatch.css with the dialog-tier
|
||||
conversion, so no #revoke-mcp-* rule is pinned here either."""
|
||||
conversion, so no #revoke-mcp-* rule is pinned here either. The pending-
|
||||
consent badge moved off the retired settings gear onto the rail's Manage row
|
||||
(shell.css `.rail-badge`), so `.settings-consent-badge` is gone from here."""
|
||||
css = _STYLE_CSS.read_text(encoding="utf-8")
|
||||
for selector in [
|
||||
".mcp-error-card",
|
||||
@@ -684,9 +768,13 @@ def test_phase8_css_classes_present_in_stylesheet() -> None:
|
||||
".mcp-error-action-btn",
|
||||
".mcp-scope-pill",
|
||||
".settings-revoke-btn",
|
||||
".settings-consent-badge",
|
||||
]:
|
||||
assert selector in css, f"Missing CSS rule for {selector}"
|
||||
# The dead gear-badge rule must be GONE (its host #settings-btn was retired).
|
||||
assert ".settings-consent-badge" not in css, (
|
||||
"the retired settings-gear consent badge CSS must be removed "
|
||||
"(the badge now lives on the rail Manage row — shell.css .rail-badge)"
|
||||
)
|
||||
|
||||
|
||||
def test_phase8_consent_url_prefix_check_in_click_handler() -> None:
|
||||
|
||||
@@ -8,6 +8,8 @@ visitor lands on the page but all API calls fail).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
@@ -401,6 +403,49 @@ def test_coord_dedups_system_turn_against_history_by_event_id():
|
||||
"false-skip after clear_ui / replay_truncated."
|
||||
)
|
||||
|
||||
# The seam must be wired on BOTH read paths, not merely present somewhere
|
||||
# in the file — a refactor that keeps the Set but drops the live-handler
|
||||
# consultation (or the history-side record) silently re-opens the
|
||||
# double-render. Scope each assertion to its block so the wiring, not the
|
||||
# bare symbol, is pinned. (A dedupe-neutered factory — guard short-circuited
|
||||
# to ``false`` — still contains ``renderedSystemEventIds.has(`` and so
|
||||
# passes the file-global checks above; these slice checks catch it.)
|
||||
sys_case = body.index('case "system_turn":')
|
||||
# End at the NEXT switch case, not the first ``break;`` — the dedup-skip
|
||||
# ``...has(sysEid)) break;`` is itself a break that precedes the ``.add(``,
|
||||
# so a ``break;``-bounded slice would drop the record half.
|
||||
# Whitespace-tolerant so a reformat can't silently break the bound.
|
||||
next_case = re.search(r'\n\s*case "', body[sys_case + 1 :])
|
||||
assert next_case, (
|
||||
"no switch case found after system_turn to bound the pin slice — if "
|
||||
"system_turn became the last case, re-anchor this pin's end marker."
|
||||
)
|
||||
live_block = body[sys_case : sys_case + 1 + next_case.start()]
|
||||
assert "renderedSystemEventIds.has(" in live_block, (
|
||||
"the live system_turn handler must CONSULT the dedup set (skip an id "
|
||||
"already painted from /history) — not just reference the Set elsewhere."
|
||||
)
|
||||
assert "renderedSystemEventIds.add(" in live_block, (
|
||||
"the live system_turn handler must RECORD the id it renders so a later "
|
||||
"/history re-render (clear_ui) doesn't repaint it."
|
||||
)
|
||||
|
||||
# The history render path must seed the set from each replayed system row's
|
||||
# event_id, so a subsequent live replay of the same id is skipped. Bound
|
||||
# the slice structurally — from the system-role branch to the next role
|
||||
# branch in the same chain (falling back to a generous window when it's
|
||||
# the last branch) — so adding comments/fields inside the branch can't
|
||||
# false-fail a pin that only cares about the wiring.
|
||||
assert 'role === "system"' in body
|
||||
sys_replay = body.index('role === "system"', body.index("refetchHistory"))
|
||||
next_role = re.search(r"role\s*===", body[sys_replay + 1 :])
|
||||
replay_end = sys_replay + 1 + next_role.start() if next_role else sys_replay + 1500
|
||||
replay_window = body[sys_replay:replay_end]
|
||||
assert "renderedSystemEventIds.add(" in replay_window, (
|
||||
"the history render's system-role branch must record each replayed "
|
||||
"turn's event_id so the live system_turn handler can dedup against it."
|
||||
)
|
||||
|
||||
|
||||
def test_coord_retry_walk_skips_operator_context_cards():
|
||||
"""Retry must NOT regenerate a stale assistant turn when the last DOM row is
|
||||
@@ -596,10 +641,12 @@ def test_coordinator_chrome_builder_and_thin_page():
|
||||
|
||||
|
||||
def test_coord_child_links_open_interactive_pane():
|
||||
"""Step 5c: a coordinator child ws link (children tree + linkified tool
|
||||
output) opens the child as a node-proxied interactive pane in the console
|
||||
L-shell. A delegated handler on the pane root reads data-ws-id/data-node-id
|
||||
and calls openPane('interactive', ...) with the CHILD's node; the link's
|
||||
"""Step 5c (+ split revival): a coordinator child ws link (children tree +
|
||||
linkified tool output) opens the child as a node-proxied interactive pane
|
||||
in the console L-shell — in a split cell BESIDE the coordinator
|
||||
(openPaneBeside; the parent stays on screen, and the click's pointerdown
|
||||
focused the coordinator's cell first). A delegated handler on the pane
|
||||
root reads data-ws-id/data-node-id and passes the CHILD's node; the link's
|
||||
href stays the standalone fallback (the standalone coordinator page has no
|
||||
PaneManager, so the new-tab nav stands)."""
|
||||
from pathlib import Path
|
||||
@@ -611,7 +658,7 @@ def test_coord_child_links_open_interactive_pane():
|
||||
# Delegated handler, gated on the pane host so standalone keeps the href nav.
|
||||
assert '.closest(".ws-link, .coord-ws-link")' in coord_js
|
||||
assert "window.TS_SHELL && window.TS_SHELL.panes" in coord_js
|
||||
assert 'pm.openPane("interactive", childWs, { nodeId: childNode })' in coord_js
|
||||
assert 'pm.openPaneBeside("interactive", childWs, { nodeId: childNode })' in coord_js
|
||||
# Both link sites carry the ids the handler reads.
|
||||
assert "a.dataset.wsId = safeWs;" in coord_js # renderChildRow (DOM)
|
||||
assert "a.dataset.nodeId = safeNode;" in coord_js
|
||||
|
||||
@@ -184,6 +184,42 @@ def test_approval_keyboard_shortcuts_wired() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_media_playback_lifted_and_pane_owned() -> None:
|
||||
"""The media Play affordance is rendered by the pane (buildPlayButton /
|
||||
buildMediaEmbed), so its activation must live in the pane too — the old
|
||||
standalone wired a DOCUMENT-level click/keydown listener in app.js, which
|
||||
the console host never loaded (so the button was dead in console-hosted
|
||||
panes). The fix mirrors the approval-keydown pattern: a pane-owned listener
|
||||
on this.el, root-scoped via closest(".media-play-btn"). Pin both the
|
||||
lifted helpers and the pane wiring so the document-level regression can't
|
||||
silently come back."""
|
||||
body = _INTERACTIVE.read_text(encoding="utf-8")
|
||||
# The lifted activation machinery now lives in the shared module.
|
||||
for fn in (
|
||||
"function _loadHls(",
|
||||
"function _isHlsUrl(",
|
||||
"function _activatePlayer(",
|
||||
"function activateMediaPlayButton(",
|
||||
):
|
||||
assert fn in body, f"media player helper must be lifted into the pane: {fn}"
|
||||
# The HLS vendor is fetched by absolute /shared/ URL (resolves in BOTH the
|
||||
# standalone server and the console, where /shared is mounted at the root).
|
||||
assert 'script.src = "/shared/hls-1.6.16/hls.min.js";' in body
|
||||
# Pane-owned + root-scoped — NOT a document-level delegated listener.
|
||||
assert 'this.el.addEventListener("click"' in body, (
|
||||
"media play must be wired on this.el (pane-owned), not document"
|
||||
)
|
||||
assert 'e.target.closest(".media-play-btn")' in body, (
|
||||
"the play handler must be root-scoped via closest, not a document-wide id"
|
||||
)
|
||||
assert "activateMediaPlayButton(btn)" in body
|
||||
collapsed = _strip_comments(body)
|
||||
assert 'document.addEventListener("click"' not in collapsed, (
|
||||
"the pane must not register a document-level click delegate — that is "
|
||||
"the standalone regression that left console panes dead"
|
||||
)
|
||||
|
||||
|
||||
def test_controller_terminal_dead_state() -> None:
|
||||
"""Lifecycle round 2: the console controller must STOP reconnect-polling a
|
||||
session that is gone (closed / evicted / node restarted) — three consecutive
|
||||
|
||||
+116
-17
@@ -4,9 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import inspect
|
||||
import json
|
||||
import time
|
||||
from contextlib import AsyncExitStack
|
||||
from contextlib import AsyncExitStack, suppress
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -26,6 +27,26 @@ from turnstone.core.tools import INTERACTIVE_TOOLS, TOOLS, merge_mcp_tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _dispatch_stub(mock_future: MagicMock) -> Any:
|
||||
"""Stand-in for ``asyncio.run_coroutine_threadsafe`` in sync-bridge tests.
|
||||
|
||||
Closes the never-scheduled coroutine before handing back the canned
|
||||
future — a mocked dispatch never awaits it, and an unawaited coroutine
|
||||
GC-fires "coroutine ... was never awaited" inside whatever unrelated
|
||||
test happens to be running when collection finally occurs (cross-test
|
||||
bleed that per-test filterwarnings markers cannot catch).
|
||||
"""
|
||||
|
||||
def _rct(coro: Any, _loop: Any) -> MagicMock:
|
||||
# Only real coroutines need (or survive) closing — several tests
|
||||
# dispatch a plain MagicMock return value through this seam.
|
||||
if inspect.iscoroutine(coro):
|
||||
coro.close()
|
||||
return mock_future
|
||||
|
||||
return _rct
|
||||
|
||||
|
||||
def _fake_mcp_tool(name: str = "search", description: str = "Search stuff") -> MagicMock:
|
||||
"""Create a mock MCP tool object matching the SDK's Tool type."""
|
||||
tool = MagicMock()
|
||||
@@ -153,8 +174,24 @@ def running_loop_mgr():
|
||||
try:
|
||||
yield mgr, loop, thread
|
||||
finally:
|
||||
# Drain BEFORE stopping: a task left pending (or finished-but-
|
||||
# unretrieved) on a stopped loop becomes cross-test global state —
|
||||
# asyncio reports it at GC time, mid-suite, onto whatever stream
|
||||
# pytest has attached THEN (the "I/O operation on closed file"
|
||||
# spew), and a silently-abandoned loop thread keeps running
|
||||
# manager code against torn-down mocks.
|
||||
async def _cancel_pending() -> None:
|
||||
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
with suppress(Exception):
|
||||
asyncio.run_coroutine_threadsafe(_cancel_pending(), loop).result(timeout=5)
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
thread.join(timeout=2)
|
||||
thread.join(timeout=5)
|
||||
assert not thread.is_alive(), "mcp test loop thread failed to stop within 5s"
|
||||
loop.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2024,6 +2061,37 @@ class TestShutdownCleanup:
|
||||
assert mgr._resource_map == {}
|
||||
assert mgr._prompt_map == {}
|
||||
|
||||
def test_shutdown_closes_owned_loop_and_clears_refs(self):
|
||||
"""When the manager owns the loop thread, shutdown must close the loop
|
||||
(selector resources leak otherwise) and drop both refs; a second
|
||||
shutdown is then a clean no-op."""
|
||||
import threading as _threading
|
||||
|
||||
mgr = MCPClientManager({})
|
||||
loop = asyncio.new_event_loop()
|
||||
thread = _threading.Thread(target=loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
mgr._loop = loop
|
||||
mgr._thread = thread
|
||||
|
||||
mgr.shutdown()
|
||||
assert loop.is_closed()
|
||||
assert mgr._loop is None
|
||||
assert mgr._thread is None
|
||||
mgr.shutdown() # idempotent
|
||||
|
||||
def test_shutdown_leaves_unowned_loop_open(self):
|
||||
"""Tests (and any embedder) that wire ``_loop`` directly without a
|
||||
thread own the loop's lifecycle — shutdown must not close it."""
|
||||
mgr = MCPClientManager({})
|
||||
loop = asyncio.new_event_loop()
|
||||
mgr._loop = loop
|
||||
try:
|
||||
mgr.shutdown()
|
||||
assert not loop.is_closed()
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TCP probe and unreachable server handling
|
||||
@@ -2181,7 +2249,7 @@ class TestFutureCancellation:
|
||||
mock_future = MagicMock()
|
||||
mock_future.result.side_effect = concurrent.futures.TimeoutError()
|
||||
with (
|
||||
patch("asyncio.run_coroutine_threadsafe", return_value=mock_future),
|
||||
patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)),
|
||||
pytest.raises(TimeoutError, match="timed out"),
|
||||
):
|
||||
mgr.call_tool_sync("mcp__test__search", {"query": "x"}, timeout=1)
|
||||
@@ -2192,7 +2260,7 @@ class TestFutureCancellation:
|
||||
mock_future = MagicMock()
|
||||
mock_future.result.side_effect = concurrent.futures.TimeoutError()
|
||||
with (
|
||||
patch("asyncio.run_coroutine_threadsafe", return_value=mock_future),
|
||||
patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)),
|
||||
pytest.raises(TimeoutError, match="timed out"),
|
||||
):
|
||||
mgr.read_resource_sync("file:///a.txt", timeout=1)
|
||||
@@ -2203,7 +2271,7 @@ class TestFutureCancellation:
|
||||
mock_future = MagicMock()
|
||||
mock_future.result.side_effect = concurrent.futures.TimeoutError()
|
||||
with (
|
||||
patch("asyncio.run_coroutine_threadsafe", return_value=mock_future),
|
||||
patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)),
|
||||
pytest.raises(TimeoutError, match="timed out"),
|
||||
):
|
||||
mgr.get_prompt_sync("mcp__test__review", timeout=1)
|
||||
@@ -2216,7 +2284,7 @@ class TestFutureCancellation:
|
||||
mock_future.result.side_effect = concurrent.futures.TimeoutError()
|
||||
with (
|
||||
patch.object(mgr, "_refresh_all", return_value=MagicMock()),
|
||||
patch("asyncio.run_coroutine_threadsafe", return_value=mock_future),
|
||||
patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)),
|
||||
pytest.raises(TimeoutError, match="timed out"),
|
||||
):
|
||||
mgr.refresh_sync(timeout=1)
|
||||
@@ -2331,8 +2399,6 @@ class TestCircuitBreaker:
|
||||
assert "srv" not in mgr._circuit_open_until
|
||||
assert "srv" not in mgr._circuit_trip_count
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
|
||||
@pytest.mark.filterwarnings("ignore:coroutine.*was never awaited:RuntimeWarning")
|
||||
def test_call_tool_sync_records_failure_on_timeout(self):
|
||||
mgr = MCPClientManager({"test": {"type": "stdio", "command": "echo"}})
|
||||
mock_session = MagicMock()
|
||||
@@ -2343,7 +2409,7 @@ class TestCircuitBreaker:
|
||||
mock_future = MagicMock()
|
||||
mock_future.result.side_effect = concurrent.futures.TimeoutError()
|
||||
with (
|
||||
patch("asyncio.run_coroutine_threadsafe", return_value=mock_future),
|
||||
patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)),
|
||||
pytest.raises(TimeoutError),
|
||||
):
|
||||
mgr.call_tool_sync("mcp__test__ping", {}, timeout=1)
|
||||
@@ -2363,7 +2429,7 @@ class TestCircuitBreaker:
|
||||
mock_result.isError = False
|
||||
mock_future = MagicMock()
|
||||
mock_future.result.return_value = mock_result
|
||||
with patch("asyncio.run_coroutine_threadsafe", return_value=mock_future):
|
||||
with patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)):
|
||||
mgr.call_tool_sync("mcp__test__ping", {}, timeout=5)
|
||||
assert mgr._consecutive_failures.get("test") is None
|
||||
|
||||
@@ -2380,7 +2446,7 @@ class TestCircuitBreaker:
|
||||
mock_future = MagicMock()
|
||||
mock_future.result.side_effect = BrokenPipeError("dead")
|
||||
with (
|
||||
patch("asyncio.run_coroutine_threadsafe", return_value=mock_future),
|
||||
patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)),
|
||||
pytest.raises(BrokenPipeError),
|
||||
):
|
||||
mgr.call_tool_sync("mcp__test__ping", {}, timeout=5)
|
||||
@@ -2414,7 +2480,7 @@ class TestCircuitBreaker:
|
||||
mock_future = MagicMock()
|
||||
mock_future.result.side_effect = McpError(ErrorData(code=-32601, message="tool not found"))
|
||||
with (
|
||||
patch("asyncio.run_coroutine_threadsafe", return_value=mock_future),
|
||||
patch("asyncio.run_coroutine_threadsafe", new=_dispatch_stub(mock_future)),
|
||||
pytest.raises(McpError),
|
||||
):
|
||||
mgr.call_tool_sync("mcp__test__ping", {}, timeout=5)
|
||||
@@ -2706,11 +2772,22 @@ class TestCBAutoReconnectRefresh:
|
||||
patch.object(mgr, "_refresh_server", side_effect=_refresh),
|
||||
):
|
||||
session = mgr._cb_auto_reconnect("srv")
|
||||
# Wait for the scheduled refresh task to actually run on the loop.
|
||||
# Wait for the scheduled refresh task to actually run on the loop,
|
||||
# then for the tracked task to DRAIN — exiting the patch context
|
||||
# while the task is still in flight would hand the un-patched
|
||||
# method to its tail.
|
||||
assert refresh_event.wait(timeout=5), "refresh task was not scheduled"
|
||||
deadline = time.time() + 5
|
||||
while mgr._background_tasks and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
assert not mgr._background_tasks, "background refresh task never drained"
|
||||
assert session is new_session
|
||||
|
||||
def test_auto_reconnect_swallows_refresh_failure(self, running_loop_mgr):
|
||||
def test_auto_reconnect_retrieves_and_logs_refresh_failure(self, running_loop_mgr):
|
||||
"""A refresh failure must be RETRIEVED and logged by the task's
|
||||
done-callback — not abandoned for asyncio to report as "Task exception
|
||||
was never retrieved" at GC time (which lands on whatever stream pytest
|
||||
has attached by then: the closed-file CI spew)."""
|
||||
import threading as _threading
|
||||
|
||||
mgr, _loop, _thread = running_loop_mgr
|
||||
@@ -2728,10 +2805,32 @@ class TestCBAutoReconnectRefresh:
|
||||
with (
|
||||
patch.object(mgr, "_connect_one", side_effect=_connect_one),
|
||||
patch.object(mgr, "_refresh_server", side_effect=_refresh_failing),
|
||||
patch("turnstone.core.mcp_client.log") as mock_log,
|
||||
):
|
||||
# Must not raise — refresh failures are non-fatal.
|
||||
# Must not raise — refresh failures are non-fatal to the caller.
|
||||
session = mgr._cb_auto_reconnect("srv")
|
||||
# Background refresh actually started and exception was swallowed
|
||||
# by the task without affecting the synchronous caller.
|
||||
assert refresh_started.wait(timeout=5), "refresh task was not scheduled"
|
||||
# Poll for the WARNING while the patch is still active — gating on
|
||||
# set-emptiness alone would race the un-patch (review-caught: the
|
||||
# warning could land on the restored real logger).
|
||||
deadline = time.time() + 5
|
||||
warn_calls = []
|
||||
while not warn_calls and time.time() < deadline:
|
||||
warn_calls = [
|
||||
c for c in mock_log.warning.call_args_list if "MCP background" in str(c.args[0])
|
||||
]
|
||||
time.sleep(0.02)
|
||||
# The tracked task must also fully drain (emptiness now implies
|
||||
# "done AND reported" — discard is the callback's LAST step).
|
||||
deadline = time.time() + 5
|
||||
while mgr._background_tasks and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
assert not mgr._background_tasks, "background refresh task never drained"
|
||||
assert session is new_session
|
||||
assert warn_calls, (
|
||||
"the refresh failure must be logged by the done-callback, not left "
|
||||
"for GC-time reporting"
|
||||
)
|
||||
exc = warn_calls[0].kwargs.get("exc_info")
|
||||
assert isinstance(exc, RuntimeError)
|
||||
assert "catalog fetch broke" in str(exc)
|
||||
|
||||
@@ -164,6 +164,25 @@ class TestProbeModelEndpoint:
|
||||
assert result["server_type"] == "anthropic"
|
||||
assert result["context_window"] == 1000000
|
||||
|
||||
@patch("turnstone.core.providers.create_client")
|
||||
def test_anthropic_compatible_server_type(self, mock_cc: MagicMock) -> None:
|
||||
m = _mock_model("deepseek-ai/DeepSeek-V4-Flash")
|
||||
mock_cc.return_value = _mock_client(m)
|
||||
|
||||
result = probe_model_endpoint("anthropic-compatible", "http://localhost:8000", "dummy")
|
||||
assert result["reachable"] is True
|
||||
assert result["server_type"] == "anthropic-compatible"
|
||||
assert result["context_window"] is None
|
||||
|
||||
@patch("turnstone.core.providers.create_client")
|
||||
def test_anthropic_compatible_max_model_len(self, mock_cc: MagicMock) -> None:
|
||||
m = _mock_model("deepseek-ai/DeepSeek-V4-Flash", max_model_len=131072)
|
||||
mock_cc.return_value = _mock_client(m)
|
||||
|
||||
result = probe_model_endpoint("anthropic-compatible", "http://localhost:8000", "dummy")
|
||||
assert result["context_window"] == 131072
|
||||
assert result["server_type"] == "anthropic-compatible"
|
||||
|
||||
@patch("turnstone.core.providers.create_client")
|
||||
def test_connection_failure(self, mock_cc: MagicMock) -> None:
|
||||
mock_cc.side_effect = OSError("Connection refused")
|
||||
|
||||
@@ -142,6 +142,24 @@ def test_create_emits_models_changed(storage: SQLiteBackend) -> None:
|
||||
assert collector.emit_models_changed.call_count == 1
|
||||
|
||||
|
||||
def test_create_accepts_anthropic_compatible_provider(storage: SQLiteBackend) -> None:
|
||||
"""anthropic-compatible passes the _MODEL_PROVIDERS enum check."""
|
||||
client, collector = _make_client(storage)
|
||||
resp = client.post(
|
||||
"/v1/api/admin/model-definitions",
|
||||
json={
|
||||
"alias": "vllm-messages",
|
||||
"model": "deepseek-ai/DeepSeek-V4-Flash",
|
||||
"provider": "anthropic-compatible",
|
||||
"base_url": "http://localhost:8000",
|
||||
"api_key": "dummy",
|
||||
"context_window": 131072,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert collector.emit_models_changed.call_count == 1
|
||||
|
||||
|
||||
def test_update_emits_models_changed(storage: SQLiteBackend) -> None:
|
||||
_seed(storage, definition_id="m1", alias="local")
|
||||
client, collector = _make_client(storage)
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Orphan-conversation scan + purge (the ``turnstone-admin orphan-conversations`` verb).
|
||||
|
||||
Orphans are conversation rows whose ``workstreams`` row is gone — written by
|
||||
historical unregistered paths or by the delete-during-inflight race (a late
|
||||
tool-result save re-creating rows after ``delete_workstream``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.admin import _cmd_orphan_conversations
|
||||
|
||||
|
||||
def _orphan(backend, ws_id: str, n: int = 2) -> None:
|
||||
"""Persist *n* conversation rows for *ws_id* WITHOUT registering it."""
|
||||
for i in range(n):
|
||||
backend.save_message(ws_id, "user" if i % 2 == 0 else "assistant", f"m{i}")
|
||||
|
||||
|
||||
def _blob(backend, payload: bytes, origin: str = "upload") -> str:
|
||||
"""Save a content-addressed attachment; each save bumps the refcount."""
|
||||
aid = hashlib.sha256(payload).hexdigest()
|
||||
backend.save_attachment(aid, "f.txt", "text/plain", len(payload), "text", payload, origin)
|
||||
return aid
|
||||
|
||||
|
||||
class TestOrphanScan:
|
||||
def test_clean_db_has_no_orphans(self, backend):
|
||||
backend.register_workstream("live1")
|
||||
backend.save_message("live1", "user", "hello")
|
||||
assert backend.list_orphan_conversations() == []
|
||||
|
||||
def test_orphan_reported_with_stats(self, backend):
|
||||
_orphan(backend, "ghost1", n=3)
|
||||
scan = backend.list_orphan_conversations()
|
||||
assert len(scan) == 1
|
||||
entry = scan[0]
|
||||
assert entry["ws_id"] == "ghost1"
|
||||
assert entry["rows"] == 3
|
||||
assert entry["first"] <= entry["last"]
|
||||
assert entry["attachment_refs"] == 0
|
||||
|
||||
def test_scan_counts_attachment_refs(self, backend):
|
||||
_orphan(backend, "ghost2", n=1)
|
||||
msg_id = backend.save_message("ghost2", "user", "with attachment")
|
||||
aid = _blob(backend, b"orphan-bytes")
|
||||
backend.set_message_attachments("ghost2", msg_id, [aid])
|
||||
scan = backend.list_orphan_conversations()
|
||||
assert scan[0]["attachment_refs"] == 1
|
||||
|
||||
def test_scan_is_oldest_first(self, backend):
|
||||
_orphan(backend, "newer")
|
||||
_orphan(backend, "older")
|
||||
# Timestamps are insertion-ordered ISO text; rewrite to force ordering.
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import conversations
|
||||
|
||||
with backend._conn() as conn:
|
||||
conn.execute(
|
||||
sa.update(conversations)
|
||||
.where(conversations.c.ws_id == "older")
|
||||
.values(timestamp="2020-01-01T00:00:00")
|
||||
)
|
||||
conn.commit()
|
||||
scan = backend.list_orphan_conversations()
|
||||
assert [o["ws_id"] for o in scan] == ["older", "newer"]
|
||||
|
||||
|
||||
class TestOrphanPurge:
|
||||
def test_purge_deletes_only_orphans(self, backend):
|
||||
backend.register_workstream("live1")
|
||||
backend.save_message("live1", "user", "keep me")
|
||||
_orphan(backend, "ghost1", n=4)
|
||||
result = backend.delete_orphan_conversations(["ghost1"])
|
||||
assert result == {"workstreams": 1, "rows": 4, "released_refs": 0, "skipped": 0}
|
||||
assert backend.list_orphan_conversations() == []
|
||||
assert len(backend.load_messages("live1")) == 1
|
||||
|
||||
def test_purge_skips_reregistered_ws(self, backend):
|
||||
"""A ws_id that gained a workstreams row between scan and purge survives."""
|
||||
_orphan(backend, "ghost1", n=2)
|
||||
scan = [o["ws_id"] for o in backend.list_orphan_conversations()]
|
||||
backend.register_workstream("ghost1")
|
||||
result = backend.delete_orphan_conversations(scan)
|
||||
assert result["skipped"] == 1
|
||||
assert result["workstreams"] == 0
|
||||
assert result["rows"] == 0
|
||||
assert len(backend.load_messages("ghost1")) == 2
|
||||
|
||||
def test_purge_releases_refcounts_and_prunes_at_zero(self, backend):
|
||||
_orphan(backend, "ghost1", n=1)
|
||||
msg_id = backend.save_message("ghost1", "user", "img")
|
||||
aid = _blob(backend, b"only-orphan-referenced")
|
||||
backend.set_message_attachments("ghost1", msg_id, [aid])
|
||||
result = backend.delete_orphan_conversations(["ghost1"])
|
||||
assert result["released_refs"] == 1
|
||||
assert backend.get_attachment(aid) is None
|
||||
|
||||
def test_purge_keeps_blob_shared_with_live_ws(self, backend):
|
||||
payload = b"shared-bytes"
|
||||
backend.register_workstream("live1")
|
||||
live_msg = backend.save_message("live1", "user", "live ref")
|
||||
aid_live = _blob(backend, payload)
|
||||
backend.set_message_attachments("live1", live_msg, [aid_live])
|
||||
|
||||
_orphan(backend, "ghost1", n=1)
|
||||
ghost_msg = backend.save_message("ghost1", "user", "ghost ref")
|
||||
aid_ghost = _blob(backend, payload) # same content hash; refcount -> 2
|
||||
backend.set_message_attachments("ghost1", ghost_msg, [aid_ghost])
|
||||
assert aid_live == aid_ghost
|
||||
|
||||
result = backend.delete_orphan_conversations(["ghost1"])
|
||||
assert result["released_refs"] == 1
|
||||
row = backend.get_attachment(aid_live)
|
||||
assert row is not None
|
||||
assert row["refcount"] == 1
|
||||
|
||||
def test_purge_sweeps_config_rows(self, backend):
|
||||
_orphan(backend, "ghost1", n=1)
|
||||
backend.save_workstream_config("ghost1", {"model": "x"})
|
||||
backend.delete_orphan_conversations(["ghost1"])
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage._schema import workstream_config
|
||||
|
||||
with backend._conn() as conn:
|
||||
left = conn.execute(
|
||||
sa.select(sa.func.count()).where(workstream_config.c.ws_id == "ghost1")
|
||||
).scalar()
|
||||
assert left == 0
|
||||
|
||||
def test_purge_dedupes_input(self, backend):
|
||||
"""Duplicate ws_ids must not inflate counts or bind params."""
|
||||
_orphan(backend, "ghost1", n=2)
|
||||
result = backend.delete_orphan_conversations(["ghost1", "ghost1", "ghost1"])
|
||||
assert result["workstreams"] == 1
|
||||
assert result["rows"] == 2
|
||||
assert result["skipped"] == 0
|
||||
|
||||
def test_purge_unknown_ws_counts_skipped(self, backend):
|
||||
"""An input with no rows and no workstream is reported, not purged."""
|
||||
result = backend.delete_orphan_conversations(["nope-never-existed"])
|
||||
assert result == {"workstreams": 0, "rows": 0, "released_refs": 0, "skipped": 1}
|
||||
|
||||
def test_purge_chunks_large_input(self, backend, monkeypatch):
|
||||
"""IN-lists are chunked (SQLite bind-parameter limits) without losing rows."""
|
||||
import turnstone.core.storage._utils as storage_utils
|
||||
|
||||
monkeypatch.setattr(storage_utils, "_PURGE_CHUNK", 2)
|
||||
for i in range(5):
|
||||
_orphan(backend, f"ghost{i}", n=1)
|
||||
result = backend.delete_orphan_conversations([f"ghost{i}" for i in range(5)])
|
||||
assert result["workstreams"] == 5
|
||||
assert result["rows"] == 5
|
||||
assert backend.list_orphan_conversations() == []
|
||||
|
||||
def test_purge_empty_list_is_noop(self, backend):
|
||||
assert backend.delete_orphan_conversations([]) == {
|
||||
"workstreams": 0,
|
||||
"rows": 0,
|
||||
"released_refs": 0,
|
||||
"skipped": 0,
|
||||
}
|
||||
|
||||
|
||||
class TestAdminVerb:
|
||||
"""The CLI handler over a real (ephemeral) backend."""
|
||||
|
||||
def _args(self, **kw) -> argparse.Namespace:
|
||||
return argparse.Namespace(delete=False, yes=False, **kw)
|
||||
|
||||
def test_scan_reports_and_does_not_delete(self, backend, monkeypatch, capsys):
|
||||
_orphan(backend, "ghost1", n=2)
|
||||
monkeypatch.setattr("turnstone.admin._get_storage", lambda args: backend)
|
||||
_cmd_orphan_conversations(self._args())
|
||||
out = capsys.readouterr().out
|
||||
assert "ghost1" in out
|
||||
assert "--delete" in out
|
||||
assert len(backend.load_messages("ghost1")) == 2
|
||||
|
||||
def test_delete_yes_purges(self, backend, monkeypatch, capsys):
|
||||
_orphan(backend, "ghost1", n=2)
|
||||
monkeypatch.setattr("turnstone.admin._get_storage", lambda args: backend)
|
||||
ns = self._args()
|
||||
ns.delete = True
|
||||
ns.yes = True
|
||||
_cmd_orphan_conversations(ns)
|
||||
out = capsys.readouterr().out
|
||||
assert "Purged 2" in out
|
||||
assert backend.list_orphan_conversations() == []
|
||||
|
||||
def test_delete_confirmation_abort(self, backend, monkeypatch, capsys):
|
||||
_orphan(backend, "ghost1", n=1)
|
||||
monkeypatch.setattr("turnstone.admin._get_storage", lambda args: backend)
|
||||
monkeypatch.setattr("builtins.input", lambda prompt: "n")
|
||||
ns = self._args()
|
||||
ns.delete = True
|
||||
with pytest.raises(SystemExit):
|
||||
_cmd_orphan_conversations(ns)
|
||||
assert len(backend.load_messages("ghost1")) == 1
|
||||
|
||||
def test_delete_summary_reports_partial_skip(self, backend, monkeypatch, capsys):
|
||||
"""Mixed batch: summary shows ACTUAL purge counts plus the skipped clause."""
|
||||
_orphan(backend, "ghost1", n=2)
|
||||
_orphan(backend, "ghost2", n=3)
|
||||
real_list = backend.list_orphan_conversations
|
||||
|
||||
def list_then_register():
|
||||
scan = real_list()
|
||||
backend.register_workstream("ghost2") # wins the scan-to-purge race
|
||||
return scan
|
||||
|
||||
monkeypatch.setattr(backend, "list_orphan_conversations", list_then_register)
|
||||
monkeypatch.setattr("turnstone.admin._get_storage", lambda args: backend)
|
||||
ns = self._args()
|
||||
ns.delete = True
|
||||
ns.yes = True
|
||||
_cmd_orphan_conversations(ns)
|
||||
out = capsys.readouterr().out
|
||||
assert "Purged 2 row(s) across 1 workstream(s)" in out
|
||||
assert "skipped 1 re-registered" in out
|
||||
assert len(backend.load_messages("ghost2")) == 3
|
||||
|
||||
def test_clean_db_message(self, backend, monkeypatch, capsys):
|
||||
monkeypatch.setattr("turnstone.admin._get_storage", lambda args: backend)
|
||||
_cmd_orphan_conversations(self._args())
|
||||
assert "No orphan conversation rows." in capsys.readouterr().out
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Tests for the ``anthropic-compatible`` provider lane.
|
||||
|
||||
Local servers (vLLM) expose Anthropic's ``/v1/messages`` wire surface for
|
||||
arbitrary checkpoints. The lane reuses ``AnthropicProvider`` with
|
||||
``compat=True``: identical message translation, but capabilities come from
|
||||
``_ANTHROPIC_COMPAT_DEFAULT`` for every model (the static Claude table
|
||||
never applies), native server-side tools are not injected, and operator
|
||||
``server_compat["extra_body"]`` overrides ride the Anthropic SDK's
|
||||
``extra_body`` — the channel for vLLM's ``chat_template_kwargs`` reasoning
|
||||
toggle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests._session_helpers import make_session as _make_session
|
||||
from turnstone.core.providers._anthropic import AnthropicProvider
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _capture_client() -> MagicMock:
|
||||
"""Build a fake Anthropic client whose ``messages.stream`` records kwargs."""
|
||||
stream_ctx = MagicMock()
|
||||
stream_ctx.__enter__ = MagicMock(return_value=iter([]))
|
||||
stream_ctx.__exit__ = MagicMock(return_value=False)
|
||||
client = MagicMock()
|
||||
client.messages.stream.return_value = stream_ctx
|
||||
return client
|
||||
|
||||
|
||||
_WEB_SEARCH_FUNCTION_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"description": "Search the web",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestCompatCapabilities
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestCompatCapabilities:
|
||||
"""Capability resolution on the compat lane."""
|
||||
|
||||
def test_compat_capability_defaults(self) -> None:
|
||||
provider = AnthropicProvider(compat=True)
|
||||
caps = provider.get_capabilities("deepseek-ai/DeepSeek-V4-Flash")
|
||||
assert caps.token_param == "max_tokens"
|
||||
assert caps.thinking_mode == "none"
|
||||
assert caps.supports_web_search is False
|
||||
assert caps.supports_tool_search is False
|
||||
assert caps.supports_vision is False
|
||||
assert caps.supports_reasoning_replay is True
|
||||
assert caps.supports_temperature is True
|
||||
|
||||
def test_claude_id_does_not_pick_up_static_table(self) -> None:
|
||||
"""A Claude-named local checkpoint must not inherit Claude API caps."""
|
||||
provider = AnthropicProvider(compat=True)
|
||||
caps = provider.get_capabilities("claude-opus-4-6")
|
||||
assert caps.context_window == 200000
|
||||
assert caps.thinking_mode == "none"
|
||||
assert caps.supports_web_search is False
|
||||
# The real lane still resolves the static entry.
|
||||
real_caps = AnthropicProvider().get_capabilities("claude-opus-4-6")
|
||||
assert real_caps.context_window == 1000000
|
||||
assert real_caps.thinking_mode == "adaptive"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestCompatWireShape
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestCompatWireShape:
|
||||
"""Body-inspecting tests on the kwargs handed to ``messages.stream``."""
|
||||
|
||||
def setup_method(self) -> None:
|
||||
self.provider = AnthropicProvider(compat=True)
|
||||
|
||||
@patch("turnstone.core.providers._anthropic._ensure_anthropic")
|
||||
def test_compat_no_web_search_swap_no_temp_force(self, mock_ensure: MagicMock) -> None:
|
||||
"""No native web_search swap, no temperature=1 forcing, max_tokens param."""
|
||||
client = _capture_client()
|
||||
list(
|
||||
self.provider.create_streaming(
|
||||
client=client,
|
||||
model="deepseek-ai/DeepSeek-V4-Flash",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tools=[_WEB_SEARCH_FUNCTION_TOOL],
|
||||
temperature=0.6,
|
||||
)
|
||||
)
|
||||
kwargs = client.messages.stream.call_args[1]
|
||||
sent_tools = kwargs["tools"]
|
||||
assert sent_tools == [
|
||||
{
|
||||
"name": "web_search",
|
||||
"description": "Search the web",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
}
|
||||
]
|
||||
assert all(t.get("type") != "web_search_20250305" for t in sent_tools)
|
||||
assert kwargs["temperature"] == 0.6
|
||||
assert "thinking" not in kwargs
|
||||
assert "max_tokens" in kwargs
|
||||
assert "max_completion_tokens" not in kwargs
|
||||
|
||||
@patch("turnstone.core.providers._anthropic._ensure_anthropic")
|
||||
def test_extra_params_passthrough_to_extra_body(self, mock_ensure: MagicMock) -> None:
|
||||
"""server_compat extra_body (chat_template_kwargs) reaches the SDK."""
|
||||
client = _capture_client()
|
||||
list(
|
||||
self.provider.create_streaming(
|
||||
client=client,
|
||||
model="deepseek-ai/DeepSeek-V4-Flash",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
extra_params={"chat_template_kwargs": {"thinking": False}},
|
||||
)
|
||||
)
|
||||
kwargs = client.messages.stream.call_args[1]
|
||||
assert kwargs["extra_body"] == {"chat_template_kwargs": {"thinking": False}}
|
||||
|
||||
@patch("turnstone.core.providers._anthropic._ensure_anthropic")
|
||||
def test_internal_keys_not_leaked(self, mock_ensure: MagicMock) -> None:
|
||||
"""Real-lane request bodies stay byte-identical with thinking overrides.
|
||||
|
||||
``thinking_budget_tokens`` is consumed by ``_reasoning_params`` and
|
||||
must never surface as wire ``extra_body`` — a leaked key would change
|
||||
every real-Anthropic request that threads a thinking override.
|
||||
Negative-tested: fails when the ``_INTERNAL_EXTRA_PARAMS`` exclusion
|
||||
is removed from ``_build_thinking_and_kwargs``.
|
||||
"""
|
||||
provider = AnthropicProvider()
|
||||
client = _capture_client()
|
||||
list(
|
||||
provider.create_streaming(
|
||||
client=client,
|
||||
model="claude-sonnet-4-5",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
extra_params={"thinking_budget_tokens": 2048},
|
||||
)
|
||||
)
|
||||
kwargs = client.messages.stream.call_args[1]
|
||||
assert "extra_body" not in kwargs
|
||||
assert kwargs["thinking"] == {"type": "enabled", "budget_tokens": 2048}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestCompatFactory
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestCompatFactory:
|
||||
"""create_provider / create_client routing for the compat lane."""
|
||||
|
||||
def test_create_provider_anthropic_compatible(self) -> None:
|
||||
from turnstone.core.providers import create_provider
|
||||
|
||||
provider = create_provider("anthropic-compatible")
|
||||
assert provider.provider_name == "anthropic-compatible"
|
||||
assert provider is not create_provider("anthropic")
|
||||
assert create_provider("anthropic-compatible") is provider
|
||||
|
||||
@patch("turnstone.core.providers._anthropic._ensure_anthropic")
|
||||
def test_create_client_anthropic_compatible(self, mock_ensure: MagicMock) -> None:
|
||||
"""base_url forwards verbatim; empty api_key is omitted entirely."""
|
||||
from turnstone.core.providers import create_client
|
||||
|
||||
mock_anthropic_cls = MagicMock()
|
||||
mock_mod = MagicMock()
|
||||
mock_mod.Anthropic = mock_anthropic_cls
|
||||
mock_ensure.return_value = mock_mod
|
||||
|
||||
create_client("anthropic-compatible", base_url="http://vllm-host:8000", api_key="")
|
||||
mock_anthropic_cls.assert_called_once_with(base_url="http://vllm-host:8000")
|
||||
|
||||
@patch("turnstone.core.providers._anthropic._ensure_anthropic")
|
||||
def test_create_client_strips_v1_suffix(self, mock_ensure: MagicMock) -> None:
|
||||
"""A /v1-suffixed base_url (openai-compatible muscle memory) is
|
||||
normalized for the compat lane — the SDK appends /v1/... itself,
|
||||
so the verbatim URL would request /v1/v1/messages and 404."""
|
||||
from turnstone.core.providers import create_client
|
||||
|
||||
mock_anthropic_cls = MagicMock()
|
||||
mock_mod = MagicMock()
|
||||
mock_mod.Anthropic = mock_anthropic_cls
|
||||
mock_ensure.return_value = mock_mod
|
||||
|
||||
for suffixed in ("http://vllm-host:8000/v1", "http://vllm-host:8000/v1/"):
|
||||
mock_anthropic_cls.reset_mock()
|
||||
create_client("anthropic-compatible", base_url=suffixed, api_key="")
|
||||
mock_anthropic_cls.assert_called_once_with(base_url="http://vllm-host:8000")
|
||||
|
||||
# A base_url that strips to nothing stays verbatim so the typo
|
||||
# fails loudly in httpx instead of silently targeting the SDK's
|
||||
# prod default.
|
||||
mock_anthropic_cls.reset_mock()
|
||||
create_client("anthropic-compatible", base_url="/v1", api_key="")
|
||||
mock_anthropic_cls.assert_called_once_with(base_url="/v1")
|
||||
|
||||
@patch("turnstone.core.providers._anthropic._ensure_anthropic")
|
||||
def test_create_client_requires_base_url(self, mock_ensure: MagicMock) -> None:
|
||||
"""Empty base_url fails at construction — the local-only lane must
|
||||
never fall back to the SDK's https://api.anthropic.com default."""
|
||||
from turnstone.core.providers import create_client
|
||||
|
||||
with pytest.raises(ValueError, match="anthropic-compatible requires base_url"):
|
||||
create_client("anthropic-compatible", base_url="", api_key="dummy")
|
||||
mock_ensure.return_value.Anthropic.assert_not_called()
|
||||
|
||||
@patch("turnstone.core.providers._anthropic._ensure_anthropic")
|
||||
def test_create_client_real_lane_base_url_untouched(self, mock_ensure: MagicMock) -> None:
|
||||
"""The real anthropic lane forwards base_url verbatim — the /v1
|
||||
normalization is compat-lane-only."""
|
||||
from turnstone.core.providers import create_client
|
||||
|
||||
mock_anthropic_cls = MagicMock()
|
||||
mock_mod = MagicMock()
|
||||
mock_mod.Anthropic = mock_anthropic_cls
|
||||
mock_ensure.return_value = mock_mod
|
||||
|
||||
create_client("anthropic", base_url="http://proxy:9000/v1", api_key="k")
|
||||
mock_anthropic_cls.assert_called_once_with(api_key="k", base_url="http://proxy:9000/v1")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestCliScope
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestCliScope:
|
||||
def test_cli_rejects_compat_provider_id(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The lane is registry-only — the CLI --provider flag does not grow."""
|
||||
from turnstone import cli
|
||||
|
||||
monkeypatch.setattr(sys, "argv", ["turnstone", "--provider", "anthropic-compatible"])
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
cli.main()
|
||||
assert excinfo.value.code == 2
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestCompatSessionPlumbing
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestCompatSessionPlumbing:
|
||||
"""ChatSession capability merge + extra_params gate for the lane."""
|
||||
|
||||
def test_per_model_capability_override_merge(self, tmp_db: Any) -> None:
|
||||
"""Per-model capabilities win over _ANTHROPIC_COMPAT_DEFAULT fields."""
|
||||
from turnstone.core.model_registry import ModelConfig, ModelRegistry
|
||||
from turnstone.core.providers import create_provider
|
||||
|
||||
cfg = ModelConfig(
|
||||
alias="vllm-messages",
|
||||
base_url="http://localhost:8000",
|
||||
api_key="dummy",
|
||||
model="deepseek-ai/DeepSeek-V4-Flash",
|
||||
provider="anthropic-compatible",
|
||||
capabilities={"supports_mid_conversation_system": True, "context_window": 131072},
|
||||
)
|
||||
registry = ModelRegistry(models={"vllm-messages": cfg}, default="vllm-messages")
|
||||
session = _make_session(registry=registry, model_alias="vllm-messages")
|
||||
provider = create_provider("anthropic-compatible")
|
||||
caps = session._resolve_capabilities(
|
||||
provider, "deepseek-ai/DeepSeek-V4-Flash", "vllm-messages"
|
||||
)
|
||||
assert caps.supports_mid_conversation_system is True
|
||||
assert caps.context_window == 131072
|
||||
# Untouched fields keep the compat-lane defaults.
|
||||
assert caps.token_param == "max_tokens"
|
||||
assert caps.thinking_mode == "none"
|
||||
assert caps.supports_web_search is False
|
||||
assert caps.supports_vision is False
|
||||
|
||||
def test_session_extra_params_gate(self, tmp_db: Any) -> None:
|
||||
"""server_compat extra_body forwards for the compat lane, not real Anthropic."""
|
||||
from turnstone.core.model_registry import ModelConfig, ModelRegistry
|
||||
from turnstone.core.providers import create_provider
|
||||
|
||||
session = _make_session(reasoning_effort="medium")
|
||||
cfg = ModelConfig(
|
||||
alias="vllm-messages",
|
||||
base_url="http://localhost:8000",
|
||||
api_key="dummy",
|
||||
model="deepseek-ai/DeepSeek-V4-Flash",
|
||||
provider="anthropic-compatible",
|
||||
server_compat={"extra_body": {"chat_template_kwargs": {"thinking": False}}},
|
||||
)
|
||||
session._registry = ModelRegistry(models={"vllm-messages": cfg}, default="vllm-messages")
|
||||
session._model_alias = "vllm-messages"
|
||||
|
||||
session._provider = create_provider("anthropic-compatible")
|
||||
assert session._provider_extra_params() == {"chat_template_kwargs": {"thinking": False}}
|
||||
|
||||
session._provider = create_provider("anthropic")
|
||||
assert session._provider_extra_params() is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestLiveCompatStream
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.live
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get("TURNSTONE_LIVE_ANTHROPIC_COMPAT_URL"),
|
||||
reason="TURNSTONE_LIVE_ANTHROPIC_COMPAT_URL not set",
|
||||
)
|
||||
class TestLiveCompatStream:
|
||||
"""One real streamed turn against a vLLM /v1/messages endpoint."""
|
||||
|
||||
def test_live_compat_streamed_turn(self) -> None:
|
||||
from turnstone.core.providers import create_client, create_provider
|
||||
|
||||
base_url = os.environ["TURNSTONE_LIVE_ANTHROPIC_COMPAT_URL"]
|
||||
model = os.environ.get(
|
||||
"TURNSTONE_LIVE_ANTHROPIC_COMPAT_MODEL", "deepseek-ai/DeepSeek-V4-Flash"
|
||||
)
|
||||
client = create_client("anthropic-compatible", base_url=base_url, api_key="dummy")
|
||||
provider = create_provider("anthropic-compatible")
|
||||
chunks = list(
|
||||
provider.create_streaming(
|
||||
client=client,
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": "Reply with the single word: pong"}],
|
||||
max_tokens=64,
|
||||
extra_params={"chat_template_kwargs": {"thinking": False}},
|
||||
)
|
||||
)
|
||||
content = "".join(c.content_delta or "" for c in chunks)
|
||||
assert content.strip()
|
||||
assert any(c.finish_reason for c in chunks)
|
||||
assert any(c.usage is not None for c in chunks)
|
||||
assert not any(c.reasoning_delta for c in chunks)
|
||||
@@ -2913,6 +2913,181 @@ class TestMemoryCompositionDeferral:
|
||||
assert session._system_composed_with_context is False
|
||||
|
||||
|
||||
class TestMemoryAccessTouch:
|
||||
"""Access metadata (``access_count`` / ``last_accessed``) moves only when
|
||||
the model actually sees a memory: the injected top-k during composition,
|
||||
and explicit search/get reads via the memory tool. Save/list and the
|
||||
wider candidate pool must NOT bump the counter.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _access_count(name: str, scope: str = "global", scope_id: str = "") -> int:
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
mem = get_storage().get_structured_memory_by_name(name, scope, scope_id)
|
||||
assert mem is not None, f"memory {name!r} not found"
|
||||
return int(mem["access_count"])
|
||||
|
||||
@staticmethod
|
||||
def _save(name: str, content: str) -> None:
|
||||
from turnstone.core.memory import save_structured_memory
|
||||
|
||||
save_structured_memory(name, content, scope="global")
|
||||
|
||||
@staticmethod
|
||||
def _empty_session() -> ChatSession:
|
||||
"""A session whose __init__ composed before any memory existed.
|
||||
|
||||
The constructor composes the system prefix once; building it before
|
||||
the memories are saved keeps that first (empty) compose from touching
|
||||
rows, so the tests observe only the turn-driven recompose below.
|
||||
"""
|
||||
return _make_session(ws_id="ws-1", user_id="user-1")
|
||||
|
||||
@staticmethod
|
||||
def _compose_turn(session: ChatSession, query: str) -> None:
|
||||
"""Drive one user turn's worth of composition.
|
||||
|
||||
Mirrors ``send``: a fresh user turn invalidates the per-turn memory
|
||||
caches, then the prefix recomposes against the new query.
|
||||
"""
|
||||
session._invalidate_memory_cache()
|
||||
session.messages.append(turn_from_dict({"role": "user", "content": query}))
|
||||
session._init_system_messages()
|
||||
|
||||
def test_composition_touches_injected_memories(self, tmp_db):
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
self._save("kafka_alerts", "kafka consumer lag alert thresholds")
|
||||
self._compose_turn(session, "how do I restart kafka")
|
||||
# Both query-matching memories were injected, so both got touched once.
|
||||
assert self._access_count("kafka_runbook") == 1
|
||||
assert self._access_count("kafka_alerts") == 1
|
||||
|
||||
def test_composition_skips_unmatched_candidates(self, tmp_db):
|
||||
"""The candidate pool is a superset of the injected set — a memory
|
||||
that loses BM25 ranking (no query overlap) must NOT be touched."""
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
self._save("garden_notes", "tomato watering schedule midsummer")
|
||||
self._compose_turn(session, "restart kafka broker pods status")
|
||||
# The matching memory was injected and touched.
|
||||
assert self._access_count("kafka_runbook") == 1
|
||||
# The non-matching one was a candidate but never injected.
|
||||
assert self._access_count("garden_notes") == 0
|
||||
# Sanity: it really was in the visible candidate pool.
|
||||
visible = {m["name"] for m in session._list_visible_memories()}
|
||||
assert "garden_notes" in visible
|
||||
|
||||
def test_composition_touches_each_memory_once_per_turn(self, tmp_db):
|
||||
"""``_init_system_messages`` runs many times within a turn (tool
|
||||
results, MCP refresh); the injected set must be touched at most once
|
||||
per memory between user turns, not once per recompose."""
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
self._compose_turn(session, "how do I restart kafka")
|
||||
# Several mid-turn recomposes (no new user turn between them).
|
||||
session._init_system_messages()
|
||||
session._init_system_messages()
|
||||
assert self._access_count("kafka_runbook") == 1
|
||||
# A genuinely new turn lets the same memory be counted again.
|
||||
self._compose_turn(session, "kafka again please")
|
||||
assert self._access_count("kafka_runbook") == 2
|
||||
|
||||
def test_composition_touches_exactly_the_injected_keys(self, tmp_db):
|
||||
"""Spy the touch boundary and assert the keys match the names the
|
||||
composer rendered into the ``<memories>`` block — exactly, not the
|
||||
candidate pool."""
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
self._save("garden_notes", "tomato watering schedule midsummer")
|
||||
session._invalidate_memory_cache()
|
||||
session.messages.append(
|
||||
turn_from_dict({"role": "user", "content": "restart kafka broker pods status"})
|
||||
)
|
||||
touched: list[tuple[str, str, str]] = []
|
||||
with patch(
|
||||
"turnstone.core.session.touch_structured_memories",
|
||||
side_effect=lambda keys: touched.extend(keys),
|
||||
):
|
||||
session._init_system_messages()
|
||||
joined = "\n".join(m["content"] for m in session.system_messages if m["role"] == "system")
|
||||
touched_names = {name for name, _, _ in touched}
|
||||
assert touched_names == {"kafka_runbook"}
|
||||
assert '<memory name="kafka_runbook"' in joined
|
||||
assert '<memory name="garden_notes"' not in joined
|
||||
|
||||
def test_composition_survives_touch_storage_error(self, tmp_db):
|
||||
"""A storage blow-up inside the touch must not break composition —
|
||||
the facade swallows it and the memory block still lands."""
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
session._invalidate_memory_cache()
|
||||
session.messages.append(
|
||||
turn_from_dict({"role": "user", "content": "how do I restart kafka"})
|
||||
)
|
||||
with patch.object(
|
||||
get_storage(),
|
||||
"touch_structured_memories",
|
||||
side_effect=RuntimeError("storage exploded"),
|
||||
):
|
||||
session._init_system_messages()
|
||||
joined = "\n".join(m["content"] for m in session.system_messages if m["role"] == "system")
|
||||
assert '<memory name="kafka_runbook"' in joined
|
||||
|
||||
def test_search_action_touches_returned_hits(self, tmp_db):
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
item = session._prepare_memory("call_1", {"action": "search", "query": "kafka"})
|
||||
assert "error" not in item
|
||||
session._exec_memory(item)
|
||||
assert self._access_count("kafka_runbook") == 1
|
||||
|
||||
def test_get_action_touches_fetched_memory(self, tmp_db):
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
item = session._prepare_memory(
|
||||
"call_1", {"action": "get", "name": "kafka_runbook", "scope": "global"}
|
||||
)
|
||||
assert "error" not in item
|
||||
session._exec_memory(item)
|
||||
assert self._access_count("kafka_runbook") == 1
|
||||
|
||||
def test_get_miss_touches_nothing(self, tmp_db):
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
item = session._prepare_memory(
|
||||
"call_1", {"action": "get", "name": "no_such_mem", "scope": "global"}
|
||||
)
|
||||
_, msg = session._exec_memory(item)
|
||||
assert "not found" in msg
|
||||
# The existing row must not be collaterally touched by a miss.
|
||||
assert self._access_count("kafka_runbook") == 0
|
||||
|
||||
def test_list_action_does_not_touch(self, tmp_db):
|
||||
session = self._empty_session()
|
||||
self._save("kafka_runbook", "restart the kafka broker pods")
|
||||
item = session._prepare_memory("call_1", {"action": "list"})
|
||||
session._exec_memory(item)
|
||||
assert self._access_count("kafka_runbook") == 0
|
||||
|
||||
def test_save_action_does_not_touch_access_count(self, tmp_db):
|
||||
"""The save action handler itself must not bump ``access_count`` —
|
||||
that counter is read traffic only. (The recompose a save triggers
|
||||
may surface the row via the composition path; that is exercised by
|
||||
the composition tests. Suppressed here to isolate the handler.)"""
|
||||
session = self._empty_session()
|
||||
item = session._prepare_memory(
|
||||
"call_1",
|
||||
{"action": "save", "name": "kafka_runbook", "content": "x", "scope": "global"},
|
||||
)
|
||||
with patch.object(session, "_init_system_messages"):
|
||||
session._exec_memory(item)
|
||||
assert self._access_count("kafka_runbook") == 0
|
||||
|
||||
|
||||
class TestMetacognitiveBuffers:
|
||||
"""Nudges drain through advisory channels, not the system message."""
|
||||
|
||||
|
||||
+166
-16
@@ -330,6 +330,63 @@ def test_step3_rail_manage_builds_from_admin_seam() -> None:
|
||||
assert "aria-expanded" in body, "collapsible group heads must expose aria-expanded"
|
||||
|
||||
|
||||
def test_rail_manage_row_badge_hook() -> None:
|
||||
"""rail.js owns a GENERIC Manage-row count badge — `setRowBadge(tabKey, count,
|
||||
label?)` stamps a glyph+count chip on a tab row (DS warn `.rail-badge`), and so
|
||||
a COLLAPSED group never hides the signal, mirrors the group's running total onto
|
||||
its head. rail.js stays agnostic about what the count means (no consent
|
||||
specifics here); a subsystem drives it. `mountManage` registers the row/head
|
||||
refs and re-applies live counts across a (re)mount. This is the re-homing
|
||||
target for the MCP consent badge after its settings-gear host was deleted."""
|
||||
body = _RAIL_JS.read_text(encoding="utf-8")
|
||||
assert "export function setRowBadge(tabKey, count, label)" in body, (
|
||||
"rail must export the generic setRowBadge hook (mechanism, not meaning)"
|
||||
)
|
||||
# The chip pairs colour with a glyph (never colour alone — chip-contrast rule).
|
||||
assert "rail-badge" in body and '"⚠"' in body, (
|
||||
"the badge must carry a ⚠ glyph alongside the count (not colour alone)"
|
||||
)
|
||||
# The collapsed-group head must carry the group total so a hidden row's signal
|
||||
# still surfaces — pin the head propagation + the per-group sum.
|
||||
assert "function _groupCount(" in body, "the head badge must sum the group's tab counts"
|
||||
assert "_groupEls" in body and "_rowEls" in body, (
|
||||
"mountManage must register row + owning-group-head refs for the badge hook"
|
||||
)
|
||||
assert "_reapplyBadges()" in body, (
|
||||
"a (re)mount must re-apply any live badge state (the refs are rebuilt)"
|
||||
)
|
||||
# rail.js stays agnostic — the hook takes a generic tabKey/count, with no
|
||||
# consent-specific endpoint, fetch, or branch (an explanatory comment naming a
|
||||
# sample caller is fine; logic is not). `setRowBadge` itself never fetches.
|
||||
badge_fn = body[body.index("export function setRowBadge") :]
|
||||
badge_fn = badge_fn[: badge_fn.index("\n}\n") + 3]
|
||||
assert "fetch" not in badge_fn and "/v1/" not in badge_fn, (
|
||||
"the generic badge hook must not reach into a subsystem (no fetch / endpoint)"
|
||||
)
|
||||
# No colour-only treatment: the chip uses DS warn tokens AND a glyph.
|
||||
css = _SHELL_CSS.read_text(encoding="utf-8")
|
||||
assert ".rail-badge" in css, "shell.css must carry the .rail-badge chip rule"
|
||||
badge_block = css[css.index(".rail-badge") :]
|
||||
badge_block = badge_block[: badge_block.index("\n.grp") if "\n.grp" in badge_block else 800]
|
||||
assert "var(--warn" in badge_block, (
|
||||
"the badge chip must use the DS --warn family (theme-flips by construction)"
|
||||
)
|
||||
assert "#" not in badge_block, "the badge chip must be token-only (no hex) so themes flip"
|
||||
|
||||
|
||||
def test_shell_bridges_setrowbadge_for_classic_subsystems() -> None:
|
||||
"""shell.js is the ESM module bridge: a classic-script subsystem (the
|
||||
standalone consent badge in ui/static/app.js) can't import rail.js, so the
|
||||
shell re-exports `setRowBadge` on the `window.TS_SHELL` seam. Pin the import
|
||||
and the seam so the bridge can't be silently dropped (which would re-break the
|
||||
badge the same way the gear deletion did)."""
|
||||
body = _SHELL_JS.read_text(encoding="utf-8")
|
||||
assert 'setRowBadge } from "./rail.js"' in body, "shell must import setRowBadge from rail.js"
|
||||
assert "notifySessionClosed, setRowBadge }" in body, (
|
||||
"TS_SHELL must expose setRowBadge for classic subsystems (the consent-badge bridge)"
|
||||
)
|
||||
|
||||
|
||||
def test_step3_admin_seam_and_thin_show_admin() -> None:
|
||||
"""admin.js exposes the TS_ADMIN seam (IA + shared perm gate + active-tab +
|
||||
openTab) and showAdmin is now a thin delegator that opens the singleton
|
||||
@@ -615,7 +672,7 @@ def test_step7_live_tab_state_glyphs() -> None:
|
||||
)
|
||||
assert "this.stateful" in pane, "ShellPane must carry the stateful flag"
|
||||
shell = _SHELL_JS.read_text(encoding="utf-8")
|
||||
assert 'import { mountRail, mountManage, glyph } from "./rail.js"' in shell, (
|
||||
assert 'import { mountRail, mountManage, glyph, setRowBadge } from "./rail.js"' in shell, (
|
||||
"the shell must import the rail's glyph builder (one source for tab + rail)"
|
||||
)
|
||||
assert "function stateForWs(" in shell and "function paintConvTabs(" in shell
|
||||
@@ -629,17 +686,107 @@ def test_step7_live_tab_state_glyphs() -> None:
|
||||
assert ".tab .tab-glyph" in css, "the tab-glyph spacing rule must apply to static + live glyphs"
|
||||
|
||||
|
||||
def test_step7_new_tab_launcher_button() -> None:
|
||||
"""Step 7 #3: the tab bar's right tail carries a [+] new-session button that
|
||||
focuses the persona launcher (the Dashboard pane hosts it; a new session needs
|
||||
a task prompt so it composes there). Cross-deployment via showHome with an
|
||||
openPane fallback; reuses the scaffold's .tab-add styling."""
|
||||
def test_split_view_controls() -> None:
|
||||
"""The revived split-view's affordance surface: Split right / Split down /
|
||||
Unsplit buttons in the tab-bar tail (they REPLACED the redundant [+] — the
|
||||
permanent Dashboard tab is the launcher). Deliberately NO contextmenu
|
||||
override (the pre-L-shell split UI hijacked right-click); denials surface
|
||||
as a toast with the manager's reason."""
|
||||
shell = _SHELL_JS.read_text(encoding="utf-8")
|
||||
assert 'make("button", "tab-add")' in shell, "the [+] new-tab button must exist"
|
||||
assert "shell.tail.append(addTab)" in shell, "the [+] lives in the right-floated tail slot"
|
||||
assert "window.showHome()" in shell, "[+] must focus the persona launcher (showHome)"
|
||||
assert 'tbBtn("tb-split", "◫", "Split right")' in shell
|
||||
assert 'tbBtn("tb-split tb-split--down", "◫", "Split down")' in shell
|
||||
assert 'pm.splitFocused("right")' in shell and 'pm.splitFocused("down")' in shell
|
||||
assert "pm.unsplit()" in shell, "the Unsplit button must call pm.unsplit"
|
||||
assert "unsplitBtn.hidden = !pm.isSplit()" in shell, (
|
||||
"Unsplit only shows while split (synced via onActiveChange)"
|
||||
)
|
||||
assert "shell.tail.append(splitRightBtn, splitDownBtn, unsplitBtn)" in shell
|
||||
# The [+] is gone with its showHome/focusLauncher plumbing kept out.
|
||||
assert "tab-add" not in shell, "the [+] new-tab button was replaced by the split controls"
|
||||
css = _SHELL_CSS.read_text(encoding="utf-8")
|
||||
assert ".tab-add" in css, "the .tab-add button style must exist (from the scaffold)"
|
||||
assert ".tb-split" in css and ".tb-split--down .tb-glyph" in css, (
|
||||
"split buttons styled; the down variant rotates the GLYPH (not the button)"
|
||||
)
|
||||
assert "tab-add" not in css, "the dead .tab-add style must not survive"
|
||||
|
||||
|
||||
def test_pane_manager_split_engine() -> None:
|
||||
"""The split-view engine in PaneManager: an optional binary layout tree
|
||||
(null = the pre-feature single-pane behaviour, bit-for-bit). Visible panes
|
||||
are positioned by inline % insets — NEVER reparented, so live stream DOM,
|
||||
scroll state and media survive every layout change. Tabs stay global:
|
||||
active = the focused cell, a backgrounded tab swaps into it, a click inside
|
||||
a visible pane focuses its cell. Separators resize by drag AND keyboard
|
||||
(role=separator + aria-value*); the tree persists in the working-set blob
|
||||
and rehydrate prunes leaves whose pane did not restore."""
|
||||
pane = _PANE_JS.read_text(encoding="utf-8")
|
||||
# public surface (splitFocused takes an optional explicit fill — openPaneBeside)
|
||||
assert "splitFocused(dir, fillId)" in pane
|
||||
assert "unsplit()" in pane and "isSplit()" in pane
|
||||
# no reparenting: layout is applied as % insets on the pane elements
|
||||
assert 'p.el.style.left = r.x * 100 + "%"' in pane
|
||||
# the focused-cell swap + pure focus move both live in activate()
|
||||
assert "this._leafFor(paneId)" in pane and "target.paneId = paneId" in pane
|
||||
# close() collapses the cell and prefers the absorbing sibling as fallback
|
||||
assert "_collapseLeaf(leaf)" in pane and "preferFallback" in pane
|
||||
# auto-fill source: most-recently-focused backgrounded pane
|
||||
assert "_nextBackgroundPane()" in pane and "this._mru" in pane
|
||||
# separators: ARIA + keyboard + pointer-capture drag, ratio bounds from the
|
||||
# split node's OWN px region (nested splits clamp against their own space)
|
||||
assert 'setAttribute("role", "separator")' in pane
|
||||
assert "setPointerCapture" in pane and "_ratioBounds(node)" in pane
|
||||
assert '"aria-valuenow"' in pane and "ArrowRight" in pane
|
||||
# the ARIA range mirrors the REAL clamp (_ratioBounds per handle in the
|
||||
# _applyLayout loop) — never a hard-coded constant
|
||||
assert "this._ratioBounds(h.node)" in pane
|
||||
assert '"aria-valuemin"' in pane and '"aria-valuemax"' in pane
|
||||
assert 'setAttribute("aria-valuemin", "10")' not in pane
|
||||
# limits: cell minimums + cap (the old ui/static ceiling, kept)
|
||||
assert "SPLIT_MAX_CELLS = 6" in pane
|
||||
assert "SPLIT_MIN_W = 200" in pane and "SPLIT_MIN_H = 150" in pane
|
||||
# persistence: layout rides the working-set blob; restore prunes dead leaves
|
||||
assert "state.layout = this._serializeLayout(this._layout)" in pane
|
||||
assert "_restoreLayout(data)" in pane and "seen.has(d.paneId)" in pane
|
||||
# the visible-but-unfocused tab marker
|
||||
assert 'classList.toggle("shown"' in pane
|
||||
# per-pane ✕: split mode hides ONE cell keeping the tab (closeCell);
|
||||
# single-pane it closes the pane (withheld from non-closable) — the click
|
||||
# decides at click time, the label tracks the mode. Manager-injected into
|
||||
# the pane SECTION (content untouched), removed via _clearCellStyle.
|
||||
assert "closeCell(paneId)" in pane and "_refreshCellChips()" in pane
|
||||
assert 'b.className = "cell-unsplit"' in pane
|
||||
assert '"Close pane"' in pane, "the single-pane chip mode"
|
||||
# mode-DISTINCT glyphs (designer P1: identical signifier + locus with a
|
||||
# reversible/destructive divergence is a mode-error trap)
|
||||
assert 'b.textContent = multi ? "−" : "✕"' in pane
|
||||
assert '"cell-unsplit--close"' in pane
|
||||
assert "this._removeCellChip(pane)" in pane
|
||||
# open-beside: the coordinator child-link placement (split right of the
|
||||
# focused cell, degrade to the plain swap on deny)
|
||||
assert "openPaneBeside(type, id, extra)" in pane
|
||||
assert 'this.splitFocused("right", paneId)' in pane
|
||||
css = _SHELL_CSS.read_text(encoding="utf-8")
|
||||
assert ".panes--split > section.pane" in css, (
|
||||
"split cells must target section.pane ONLY — the interactive pane's inner "
|
||||
"div also carries .pane (the step-5b lesson)"
|
||||
)
|
||||
assert ".split-handle" in css and "col-resize" in css and "row-resize" in css
|
||||
assert ".tab.shown:not(.active)" in css, "the visible-but-unfocused tab marker"
|
||||
# the focused ring rides an ::after OVERLAY — an inset shadow on the
|
||||
# section itself paints UNDER edge-touching children (the status-bar
|
||||
# occlusion bug); the ::before bar sits above the ring line
|
||||
assert ".panes--split > section.pane.split-focused::after" in css
|
||||
assert ".cell-unsplit" in css, "the per-cell hide-from-split chip style"
|
||||
assert ".cell-unsplit--close:hover" in css, "destructive mode telegraphs on hover"
|
||||
# the pane is the chip's containing block in BOTH modes — unpositioned,
|
||||
# the single-pane chip anchored to the VIEWPORT (offsetParent <body>)
|
||||
assert ".panes > section.pane" in css
|
||||
# pane-hosted coordinator sidebar drops below the chip's corner lane —
|
||||
# the chip sat exactly on the Children refresh button (user report)
|
||||
coord_chrome = (_ROOT / "turnstone/console/static/coordinator/coord-chrome.css").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert ".pane-body.coord-chrome-root #coord-sidebar.sidebar" in coord_chrome
|
||||
|
||||
|
||||
def test_step7_auth_gated_open_pane() -> None:
|
||||
@@ -741,15 +888,18 @@ def test_interactive_pane_dead_session_revive() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_shell_marks_pane_dead_on_ws_closed() -> None:
|
||||
"""Tier-1 ws_closed → the open pane stops reconnect-polling a session that
|
||||
is GONE and shows the reconnect affordance immediately (the console keeps
|
||||
the tab — unlike the standalone, which closes the pane outright)."""
|
||||
def test_shell_closes_pane_on_ws_closed() -> None:
|
||||
"""Tier-1 ws_closed → the open pane CLOSES outright (tab gone; a split
|
||||
cell collapses onto its sibling) — the coordinator-closes-its-child flow,
|
||||
matching the standalone's pane-auto-close. The dead-BANNER lane survives
|
||||
for streams that die WITHOUT a ws_closed (node crash / network), where the
|
||||
session may still be revivable."""
|
||||
shell = _SHELL_JS.read_text(encoding="utf-8")
|
||||
assert "const notifySessionClosed = (wsId)" in shell
|
||||
assert 'pm.getPane("interactive", wsId)' in shell
|
||||
assert "p._ctl.markDead()" in shell
|
||||
assert "window.TS_SHELL = { panes: pm, caps, notifySessionClosed }" in shell, (
|
||||
assert "if (p) pm.close(p.id)" in shell, "ws_closed closes the pane, not mark-dead"
|
||||
assert "showDeadBanner" in shell, "the banner lane must survive for non-closed deaths"
|
||||
assert "window.TS_SHELL = { panes: pm, caps, notifySessionClosed, setRowBadge }" in shell, (
|
||||
"the seam must be exported on TS_SHELL for the console's Tier-1 handler"
|
||||
)
|
||||
app = _CONSOLE_APP.read_text(encoding="utf-8")
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "1.6.0"
|
||||
__version__ = "1.6.3"
|
||||
|
||||
@@ -439,6 +439,44 @@ def _discover_console_url() -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _cmd_orphan_conversations(args: argparse.Namespace) -> None:
|
||||
"""Scan for (and with --delete purge) conversation rows whose workstream is gone."""
|
||||
storage = _get_storage(args)
|
||||
orphans = storage.list_orphan_conversations()
|
||||
if not orphans:
|
||||
print("No orphan conversation rows.")
|
||||
return
|
||||
width = max(len(o["ws_id"]) for o in orphans)
|
||||
print(f"{'ws_id':<{width}} {'rows':>5} {'refs':>4} first last")
|
||||
for o in orphans:
|
||||
print(
|
||||
f"{o['ws_id']:<{width}} {o['rows']:>5} {o['attachment_refs']:>4} "
|
||||
f"{(o['first'] or '')[:10]} {(o['last'] or '')[:10]}"
|
||||
)
|
||||
total_rows = sum(o["rows"] for o in orphans)
|
||||
total_refs = sum(o["attachment_refs"] for o in orphans)
|
||||
print(
|
||||
f"\n{len(orphans)} orphan workstream(s), {total_rows} conversation row(s), "
|
||||
f"{total_refs} attachment ref(s)."
|
||||
)
|
||||
if not args.delete:
|
||||
print("Re-run with --delete to purge them.")
|
||||
return
|
||||
if not args.yes:
|
||||
reply = input(f"Delete {total_rows} row(s) across {len(orphans)} workstream(s)? [y/N] ")
|
||||
if reply.strip().lower() not in ("y", "yes"):
|
||||
print("Aborted.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
result = storage.delete_orphan_conversations([o["ws_id"] for o in orphans])
|
||||
summary = (
|
||||
f"Purged {result['rows']} row(s) across {result['workstreams']} workstream(s); "
|
||||
f"released {result['released_refs']} attachment ref(s)"
|
||||
)
|
||||
if result["skipped"]:
|
||||
summary += f"; skipped {result['skipped']} re-registered workstream(s)"
|
||||
print(summary + ".")
|
||||
|
||||
|
||||
def _cmd_rerank_calibrate(args: argparse.Namespace) -> None:
|
||||
from turnstone.core.config import get_rerank_instruction
|
||||
from turnstone.core.config_store import ConfigStore
|
||||
@@ -619,6 +657,15 @@ def main() -> None:
|
||||
help="Write the calibration onto the model's capabilities",
|
||||
)
|
||||
|
||||
p_orph = sub.add_parser(
|
||||
"orphan-conversations",
|
||||
help="Scan (and with --delete purge) conversation rows whose workstream row is gone",
|
||||
)
|
||||
p_orph.add_argument(
|
||||
"--delete", action="store_true", help="Purge the orphans after the scan report"
|
||||
)
|
||||
p_orph.add_argument("--yes", action="store_true", help="Skip the interactive confirmation")
|
||||
|
||||
args = parser.parse_args()
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
@@ -638,6 +685,7 @@ def main() -> None:
|
||||
"set-node-metadata": _cmd_set_node_metadata,
|
||||
"delete-node-metadata": _cmd_delete_node_metadata,
|
||||
"export": _cmd_export,
|
||||
"orphan-conversations": _cmd_orphan_conversations,
|
||||
"rerank-calibrate": _cmd_rerank_calibrate,
|
||||
}
|
||||
dispatch[args.command](args)
|
||||
|
||||
@@ -10210,7 +10210,9 @@ async def admin_import_mcp_config(request: Request) -> JSONResponse:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MODEL_ALIAS_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
|
||||
_MODEL_PROVIDERS = frozenset({"openai", "anthropic", "openai-compatible", "google", "xai"})
|
||||
_MODEL_PROVIDERS = frozenset(
|
||||
{"openai", "anthropic", "openai-compatible", "anthropic-compatible", "google", "xai"}
|
||||
)
|
||||
_REASONING_EFFORT_CHOICES = frozenset(
|
||||
{"", "none", "minimal", "low", "medium", "high", "xhigh", "max"}
|
||||
)
|
||||
|
||||
@@ -5596,7 +5596,8 @@ function _renderModels(items) {
|
||||
? "model-provider-anthropic"
|
||||
: m.provider === "google"
|
||||
? "model-provider-google"
|
||||
: m.provider === "openai-compatible"
|
||||
: m.provider === "openai-compatible" ||
|
||||
m.provider === "anthropic-compatible"
|
||||
? "model-provider-compat"
|
||||
: "model-provider-openai";
|
||||
|
||||
@@ -5896,12 +5897,16 @@ function showEditModelModal(definitionId) {
|
||||
? capsObj.server_compat
|
||||
: {};
|
||||
// Only extract thinking_mode into the dropdown when the UI can
|
||||
// represent it ("manual" or ""). Values like "adaptive" (Anthropic-
|
||||
// only) stay in the raw capabilities JSON so they aren't silently
|
||||
// lost on save.
|
||||
// represent it ("manual" or "") AND the provider round-trips the
|
||||
// dropdown on save (every provider except anthropic-compatible —
|
||||
// see submitCreateModel). Unrepresentable values like "adaptive"
|
||||
// and anthropic-compatible rows keep thinking_mode in the raw
|
||||
// capabilities JSON so it isn't silently lost on save.
|
||||
const tmVal = capsObj.thinking_mode || "";
|
||||
const tmRepresentable = tmVal === "" || tmVal === "manual";
|
||||
if (tmRepresentable) {
|
||||
const tmCaptured =
|
||||
tmRepresentable && (m.provider || "openai") !== "anthropic-compatible";
|
||||
if (tmCaptured) {
|
||||
document.getElementById("model-thinking-mode").value = tmVal;
|
||||
document.getElementById("model-thinking-param").value =
|
||||
capsObj.thinking_param || "";
|
||||
@@ -5945,7 +5950,7 @@ function showEditModelModal(definitionId) {
|
||||
// Remove structured fields from capabilities display — only delete
|
||||
// thinking_mode/thinking_param when the UI successfully captured them.
|
||||
delete capsObj.server_compat;
|
||||
if (tmRepresentable) {
|
||||
if (tmCaptured) {
|
||||
delete capsObj.thinking_mode;
|
||||
delete capsObj.thinking_param;
|
||||
}
|
||||
@@ -6017,10 +6022,16 @@ function submitCreateModel() {
|
||||
}
|
||||
}
|
||||
|
||||
// Thinking mode → capabilities (provider uses this to inject
|
||||
// the correct chat_template_kwargs param automatically).
|
||||
const providerVal = document.getElementById("model-provider").value;
|
||||
|
||||
// Thinking mode → capabilities. thinking_mode round-trips through the
|
||||
// dropdown for every provider EXCEPT anthropic-compatible, where it
|
||||
// stays in the raw capabilities JSON (mirroring the edit-load lift):
|
||||
// that lane hides the dropdown row and drives reasoning via extra-body
|
||||
// chat_template_kwargs, so a lingering dropdown value must never be
|
||||
// persisted.
|
||||
const thinkingMode = document.getElementById("model-thinking-mode").value;
|
||||
if (thinkingMode) {
|
||||
if (providerVal !== "anthropic-compatible" && thinkingMode) {
|
||||
caps.thinking_mode = thinkingMode;
|
||||
// Preserve thinking_param so Granite/DeepSeek "thinking" key
|
||||
// isn't silently reverted to the default "enable_thinking".
|
||||
@@ -6028,20 +6039,25 @@ function submitCreateModel() {
|
||||
if (savedParam) caps.thinking_param = savedParam;
|
||||
}
|
||||
|
||||
// Build server_compat from structured fields. Only meaningful for
|
||||
// openai-compatible aliases — for other providers the section is hidden
|
||||
// Build server_compat from structured fields. Only meaningful for the
|
||||
// compat lanes (openai-compatible: all fields; anthropic-compatible: the
|
||||
// extra-body JSON only) — for other providers the section is hidden
|
||||
// but the form values can linger after a provider switch, so gate the
|
||||
// whole block on the active provider to keep persisted state honest.
|
||||
const serverCompat = {};
|
||||
const providerVal = document.getElementById("model-provider").value;
|
||||
const ebEl = document.getElementById("model-extra-body");
|
||||
ebEl.removeAttribute("aria-invalid");
|
||||
ebEl.style.borderColor = "";
|
||||
if (providerVal === "openai-compatible") {
|
||||
const serverType = document.getElementById("model-server-type").value;
|
||||
if (serverType) serverCompat.server_type = serverType;
|
||||
const apiSurface = document.getElementById("model-api-surface").value;
|
||||
if (apiSurface) serverCompat.api_surface = apiSurface;
|
||||
if (
|
||||
providerVal === "openai-compatible" ||
|
||||
providerVal === "anthropic-compatible"
|
||||
) {
|
||||
if (providerVal === "openai-compatible") {
|
||||
const serverType = document.getElementById("model-server-type").value;
|
||||
if (serverType) serverCompat.server_type = serverType;
|
||||
const apiSurface = document.getElementById("model-api-surface").value;
|
||||
if (apiSurface) serverCompat.api_surface = apiSurface;
|
||||
}
|
||||
const ebText = ebEl.value.trim();
|
||||
if (ebText) {
|
||||
try {
|
||||
@@ -6516,7 +6532,11 @@ function _modelCapsRefreshBaseline() {
|
||||
const provider = document.getElementById("model-provider").value;
|
||||
const modelName = document.getElementById("model-name").value.trim();
|
||||
const banner = document.getElementById("model-autofill");
|
||||
if (!modelName || provider === "openai-compatible") {
|
||||
if (
|
||||
!modelName ||
|
||||
provider === "openai-compatible" ||
|
||||
provider === "anthropic-compatible"
|
||||
) {
|
||||
_modelCapsBaseline = {};
|
||||
banner.hidden = true;
|
||||
_modelRenderTiles();
|
||||
@@ -6578,6 +6598,10 @@ const _providerDefaults = {
|
||||
urlPlaceholder: "e.g. https://your-provider.com/v1",
|
||||
modelPlaceholder: "GLM5",
|
||||
},
|
||||
"anthropic-compatible": {
|
||||
urlPlaceholder: "e.g. http://your-vllm-host:8000",
|
||||
modelPlaceholder: "deepseek-ai/DeepSeek-V4-Flash",
|
||||
},
|
||||
};
|
||||
|
||||
/* Update placeholders when provider changes. */
|
||||
@@ -6592,8 +6616,18 @@ function _applyProviderDefaults() {
|
||||
if (scSection) {
|
||||
// hidden attr, not style.display — `.hatch [hidden]` is !important and
|
||||
// an inline display can never un-hide it.
|
||||
scSection.hidden = provider !== "openai-compatible";
|
||||
scSection.hidden =
|
||||
provider !== "openai-compatible" && provider !== "anthropic-compatible";
|
||||
}
|
||||
// Within the section, server type / API surface / thinking mode are
|
||||
// openai-compatible knobs — the anthropic-compatible lane is configured
|
||||
// through the extra-body JSON alone, so collapse the section to just
|
||||
// that field.
|
||||
const hideOpenaiOnlyRows = provider === "anthropic-compatible";
|
||||
["model-server-fields-row", "model-thinking-mode-row"].forEach(function (id) {
|
||||
const row = document.getElementById(id);
|
||||
if (row) row.hidden = hideOpenaiOnlyRows;
|
||||
});
|
||||
}
|
||||
|
||||
/* Populate the model name datalist with known model prefixes for the
|
||||
|
||||
@@ -146,9 +146,10 @@ function patchClusterState(data) {
|
||||
if (typeof loadSavedCoordinators === "function") {
|
||||
loadSavedCoordinators();
|
||||
}
|
||||
// An open pane on this session must stop reconnect-polling a stream that
|
||||
// is now gone and show its reconnect affordance instead (the shell owns
|
||||
// the pane lifecycle; this is the Tier-1 → pane seam).
|
||||
// An open pane on this session closes with it — the coordinator just
|
||||
// closed its child (or the session ended elsewhere); a lingering tab
|
||||
// would only reconnect-poll a stream that is gone (the shell owns the
|
||||
// pane lifecycle; this is the Tier-1 → pane seam).
|
||||
if (
|
||||
window.TS_SHELL &&
|
||||
typeof window.TS_SHELL.notifySessionClosed === "function"
|
||||
@@ -1962,14 +1963,6 @@ window.TS_APP.bucketByParent = function (list) {
|
||||
window.TS_APP.buildNodeInfo = function (node) {
|
||||
return buildNodeInfoFromSnapshot(node);
|
||||
};
|
||||
// Focus the persona launcher's composer — the [+] new-session button calls this
|
||||
// after showHome so "new session" lands you ready to type (showHome alone, on the
|
||||
// already-active Dashboard, is a no-op). _ensureHomeComposerInit (run by
|
||||
// showHome) has set _homeCoordComposer by the time this fires.
|
||||
window.TS_APP.focusLauncher = function () {
|
||||
if (_homeCoordComposer && typeof _homeCoordComposer.focus === "function")
|
||||
_homeCoordComposer.focus();
|
||||
};
|
||||
// Resolve the cluster node that should host an interactive pane's session AND
|
||||
// ensure the session is loaded there before the pane streams — the node /events
|
||||
// stream 404s on a ws not loaded on that node, and /history alone won't load it.
|
||||
|
||||
@@ -62,6 +62,16 @@
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
/* Pane-hosted: the shell's per-pane ✕/− chip floats in the pane's top-right
|
||||
corner (top 5px + 28px tall) — exactly where this sidebar's toggle row and
|
||||
the Children refresh sit. Drop the sidebar content below the chip's lane;
|
||||
padding (not margin) keeps the column's left border running the full pane
|
||||
height. (In practice this is EVERY coordinator — the /coordinator/{ws_id}
|
||||
standalone page is a chip-less direct-URL fallback no UI path navigates to;
|
||||
the .pane-body scope just keeps that lane honest.) */
|
||||
.pane-body.coord-chrome-root #coord-sidebar.sidebar {
|
||||
padding-top: 44px;
|
||||
}
|
||||
#coord-children-wrap {
|
||||
min-height: 40%;
|
||||
max-height: 60%;
|
||||
|
||||
@@ -310,9 +310,15 @@ function createCoordinatorPane(root, wsId, opts) {
|
||||
const childNode = link.dataset.nodeId;
|
||||
if (!childWs || !childNode) return;
|
||||
const pm = window.TS_SHELL && window.TS_SHELL.panes;
|
||||
if (pm && pm.openPane) {
|
||||
if (pm && pm.openPaneBeside) {
|
||||
e.preventDefault();
|
||||
pm.openPane("interactive", childWs, { nodeId: childNode });
|
||||
// Beside, not instead: the child lands in a cell to the RIGHT of the
|
||||
// coordinator (you are usually cross-checking the child against the
|
||||
// tree that spawned it, so the parent stays on screen). The click's
|
||||
// pointerdown already focused this coordinator's cell, so "beside the
|
||||
// focused cell" is beside THIS pane; a denied split (cell cap / narrow
|
||||
// viewport) degrades to the old focused-cell swap.
|
||||
pm.openPaneBeside("interactive", childWs, { nodeId: childNode });
|
||||
}
|
||||
});
|
||||
// Approval keyboard shortcuts (designer P2 — the console twin of the
|
||||
|
||||
@@ -1577,6 +1577,7 @@
|
||||
<option value="anthropic">anthropic</option>
|
||||
<option value="google">google</option>
|
||||
<option value="openai-compatible">openai-compatible</option>
|
||||
<option value="anthropic-compatible">anthropic-compatible</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -1673,7 +1674,7 @@
|
||||
|
||||
<div id="model-server-compat-section" hidden>
|
||||
<div class="sh-section">Server compatibility</div>
|
||||
<div class="field-pair">
|
||||
<div class="field-pair" id="model-server-fields-row">
|
||||
<div>
|
||||
<label for="model-server-type"
|
||||
>Server type
|
||||
@@ -1702,28 +1703,32 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<label for="model-thinking-mode"
|
||||
>Thinking mode
|
||||
<span class="label-hint">chat-template reasoning</span></label
|
||||
>
|
||||
<select id="model-thinking-mode">
|
||||
<option value="">None</option>
|
||||
<option value="manual">Enabled</option>
|
||||
</select>
|
||||
<div id="model-thinking-param-row" hidden>
|
||||
<label for="model-thinking-param"
|
||||
>Template param name
|
||||
<div id="model-thinking-mode-row">
|
||||
<label for="model-thinking-mode"
|
||||
>Thinking mode
|
||||
<span class="label-hint"
|
||||
>Granite/DeepSeek use "thinking"</span
|
||||
>chat-template reasoning</span
|
||||
></label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="model-thinking-param"
|
||||
class="sh-mono"
|
||||
value="enable_thinking"
|
||||
placeholder="enable_thinking"
|
||||
/>
|
||||
<select id="model-thinking-mode">
|
||||
<option value="">None</option>
|
||||
<option value="manual">Enabled</option>
|
||||
</select>
|
||||
<div id="model-thinking-param-row" hidden>
|
||||
<label for="model-thinking-param"
|
||||
>Template param name
|
||||
<span class="label-hint"
|
||||
>Granite/DeepSeek use "thinking"</span
|
||||
></label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="model-thinking-param"
|
||||
class="sh-mono"
|
||||
value="enable_thinking"
|
||||
placeholder="enable_thinking"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label for="model-extra-body"
|
||||
>Extra body params
|
||||
|
||||
@@ -57,7 +57,7 @@ from turnstone.core.mcp_oauth import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
|
||||
log = get_logger("turnstone.mcp")
|
||||
|
||||
@@ -607,6 +607,13 @@ class MCPClientManager:
|
||||
# static-only deployments).
|
||||
self._user_pool_eviction_task: asyncio.Task[None] | None = None
|
||||
|
||||
# Strong references to fire-and-forget background tasks (catalog
|
||||
# refreshes etc.). ``create_task`` alone keeps only a weak ref — an
|
||||
# untracked task can be GC'd mid-flight, and its exception surfaces
|
||||
# as "Task exception was never retrieved" at GC time instead of
|
||||
# being logged where it happened. See ``_spawn_background``.
|
||||
self._background_tasks: set[asyncio.Task[Any]] = set()
|
||||
|
||||
def _ensure_static_state(self, name: str) -> StaticServerState:
|
||||
"""Get or create the StaticServerState for ``name``.
|
||||
|
||||
@@ -2712,6 +2719,30 @@ class MCPClientManager:
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Close all MCP sessions and stop the background loop."""
|
||||
# Cancel tracked background tasks (catalog refreshes etc.) FIRST —
|
||||
# they are pure auxiliaries, and draining them up front means the
|
||||
# stack teardown below can't race an in-flight refresh. Submitted
|
||||
# whenever a loop exists — NOT gated on a main-thread truthiness
|
||||
# check of ``_background_tasks``: a spawn queued via
|
||||
# call_soon_threadsafe may not have reached the set yet, but ready
|
||||
# callbacks run in FIFO order, so by the time the drain coroutine
|
||||
# snapshots the set ON the loop, every earlier-queued spawn has
|
||||
# landed. ``is_running()`` guard: on a stopped loop nothing can
|
||||
# execute the drain — submitting would just stall on the future.
|
||||
if self._loop is not None and self._loop.is_running():
|
||||
|
||||
async def _cancel_background() -> None:
|
||||
tasks = list(self._background_tasks)
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
future = asyncio.run_coroutine_threadsafe(_cancel_background(), self._loop)
|
||||
try:
|
||||
future.result(timeout=10)
|
||||
except Exception:
|
||||
log.debug("Error cancelling MCP background tasks", exc_info=True)
|
||||
|
||||
# Cancel the pool eviction task, then close all pool entries
|
||||
# before tearing down static-path state. Both run on the
|
||||
# mcp-loop so dispatcher coroutines can't race them.
|
||||
@@ -2767,8 +2798,21 @@ class MCPClientManager:
|
||||
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5)
|
||||
if self._thread.is_alive():
|
||||
# Closing a still-running loop raises; the daemon thread dies
|
||||
# with the process, so leaving the loop open is the lesser
|
||||
# evil. Loud because a stuck loop thread is itself a bug.
|
||||
log.warning("MCP loop thread did not stop within 5s; loop left open")
|
||||
else:
|
||||
if self._loop is not None:
|
||||
self._loop.close()
|
||||
self._loop = None
|
||||
self._thread = None
|
||||
# When no thread was started (tests wire ``_loop`` directly) the loop
|
||||
# is not ours to close — the stop above is all the owner needs.
|
||||
|
||||
# Clear all state
|
||||
self._background_tasks.clear()
|
||||
self._static_servers.clear()
|
||||
self._db_managed.clear()
|
||||
self._tools = []
|
||||
@@ -3327,6 +3371,33 @@ class MCPClientManager:
|
||||
# broken the first failure re-trips the circuit immediately.
|
||||
self._circuit_open_until.pop(server_name, None)
|
||||
|
||||
def _spawn_background(self, coro: Coroutine[Any, Any, Any], label: str) -> asyncio.Task[Any]:
|
||||
"""Schedule *coro* as a tracked background task (loop thread only).
|
||||
|
||||
Holds a strong reference until completion and retrieves the task's
|
||||
outcome in a done-callback: failures are logged once, here, at
|
||||
warning — never deferred to garbage collection, where they surface
|
||||
as "Task exception was never retrieved" on whatever stream happens
|
||||
to be attached at the time. ``shutdown()`` cancels anything still
|
||||
tracked before stopping the loop.
|
||||
"""
|
||||
task = asyncio.create_task(coro)
|
||||
self._background_tasks.add(task)
|
||||
|
||||
def _done(t: asyncio.Task[Any]) -> None:
|
||||
try:
|
||||
if not t.cancelled():
|
||||
exc = t.exception()
|
||||
if exc is not None:
|
||||
log.warning("MCP background %s failed", label, exc_info=exc)
|
||||
finally:
|
||||
# Discard LAST so set-emptiness means "done AND reported" —
|
||||
# a watcher keying on emptiness must never race the warning.
|
||||
self._background_tasks.discard(t)
|
||||
|
||||
task.add_done_callback(_done)
|
||||
return task
|
||||
|
||||
def _cb_auto_reconnect(self, server_name: str) -> Any:
|
||||
"""Attempt reconnection for a disconnected server during half-open probe.
|
||||
|
||||
@@ -3355,13 +3426,18 @@ class MCPClientManager:
|
||||
|
||||
# Schedule catalog refresh on the loop without blocking the caller.
|
||||
# The reconnected session is valid for the imminent dispatch; catalog
|
||||
# drift will be reconciled on the loop in the background.
|
||||
# drift will be reconciled on the loop in the background. The task is
|
||||
# tracked: a refresh FAILURE is logged by the done-callback — this
|
||||
# except only covers the scheduling itself.
|
||||
def _schedule_refresh() -> None:
|
||||
try:
|
||||
asyncio.create_task(self._refresh_server(server_name))
|
||||
self._spawn_background(
|
||||
self._refresh_server(server_name),
|
||||
f"catalog refresh after reconnect for '{server_name}'",
|
||||
)
|
||||
except Exception:
|
||||
log.warning(
|
||||
"Catalog refresh after reconnect failed for '%s'",
|
||||
"Scheduling catalog refresh after reconnect failed for '%s'",
|
||||
server_name,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@@ -817,6 +817,14 @@ def probe_model_endpoint(
|
||||
if known is not None:
|
||||
result["context_window"] = known["context_window"]
|
||||
result["server_type"] = "xai"
|
||||
elif provider == "anthropic-compatible":
|
||||
# No static table for local models; vLLM exposes max_model_len
|
||||
# as an extra field the Anthropic SDK preserves (extra="allow").
|
||||
if inspect_obj is not None:
|
||||
max_len = inspect_obj.model_dump().get("max_model_len")
|
||||
if isinstance(max_len, int) and max_len > 0:
|
||||
result["context_window"] = max_len
|
||||
result["server_type"] = "anthropic-compatible"
|
||||
else:
|
||||
# OpenAI-compatible path
|
||||
_detect_openai_compat(result, inspect_obj, inspect_id, base_url)
|
||||
|
||||
@@ -46,6 +46,7 @@ _openai_provider = OpenAIResponsesProvider()
|
||||
_openai_compat_provider = OpenAIChatCompletionsProvider()
|
||||
_xai_provider = XAIProvider()
|
||||
_anthropic_provider: LLMProvider | None = None
|
||||
_anthropic_compat_provider: LLMProvider | None = None
|
||||
_google_provider: LLMProvider | None = None
|
||||
|
||||
|
||||
@@ -67,8 +68,13 @@ def create_provider(
|
||||
endpoints like Mistral cloud, or local servers that expose the
|
||||
Responses surface).
|
||||
|
||||
Ignored for non-OpenAI providers. ``provider_name="openai"`` always
|
||||
uses the Responses API regardless of *api_surface*.
|
||||
Ignored for non-OpenAI providers — both Anthropic lanes
|
||||
(``"anthropic"`` and ``"anthropic-compatible"``) talk to the
|
||||
Messages API regardless of *api_surface*.
|
||||
``provider_name="anthropic-compatible"`` returns the Anthropic
|
||||
adapter in compat mode (local servers exposing ``/v1/messages``,
|
||||
e.g. vLLM). ``provider_name="openai"`` always uses the Responses
|
||||
API regardless of *api_surface*.
|
||||
|
||||
Note: the ``OpenAIResponsesProvider`` singleton is reused for both
|
||||
cloud OpenAI and ``openai-compatible`` + responses, so its
|
||||
@@ -77,7 +83,7 @@ def create_provider(
|
||||
must read ``ModelConfig.provider`` and ``server_compat["api_surface"]``
|
||||
rather than ``provider.provider_name``.
|
||||
"""
|
||||
global _anthropic_provider, _google_provider # noqa: PLW0603
|
||||
global _anthropic_provider, _anthropic_compat_provider, _google_provider # noqa: PLW0603
|
||||
if provider_name == "openai":
|
||||
return _openai_provider
|
||||
if provider_name == "openai-compatible":
|
||||
@@ -98,6 +104,13 @@ def create_provider(
|
||||
|
||||
_anthropic_provider = AnthropicProvider()
|
||||
return _anthropic_provider
|
||||
if provider_name == "anthropic-compatible":
|
||||
with _provider_lock:
|
||||
if _anthropic_compat_provider is None:
|
||||
from turnstone.core.providers._anthropic import AnthropicProvider
|
||||
|
||||
_anthropic_compat_provider = AnthropicProvider(compat=True)
|
||||
return _anthropic_compat_provider
|
||||
if provider_name == "google":
|
||||
with _provider_lock:
|
||||
if _google_provider is None:
|
||||
@@ -107,7 +120,7 @@ def create_provider(
|
||||
return _google_provider
|
||||
raise ValueError(
|
||||
f"Unknown provider: {provider_name!r}. "
|
||||
"Supported: openai, anthropic, google, openai-compatible, xai"
|
||||
"Supported: openai, anthropic, google, openai-compatible, anthropic-compatible, xai"
|
||||
)
|
||||
|
||||
|
||||
@@ -133,19 +146,36 @@ def create_client(provider_name: str, *, base_url: str, api_key: str) -> Any:
|
||||
if base_url:
|
||||
return OpenAI(base_url=base_url, api_key=resolved_key)
|
||||
return OpenAI(api_key=resolved_key)
|
||||
if provider_name == "anthropic":
|
||||
if provider_name in ("anthropic", "anthropic-compatible"):
|
||||
from turnstone.core.providers._anthropic import _ensure_anthropic
|
||||
|
||||
anthropic = _ensure_anthropic()
|
||||
kwargs: dict[str, str] = {}
|
||||
if resolved_key is not None:
|
||||
kwargs["api_key"] = resolved_key
|
||||
if provider_name == "anthropic-compatible":
|
||||
# The lane targets local /v1/messages servers; without a
|
||||
# base_url the SDK would default to https://api.anthropic.com
|
||||
# and send compat-shaped requests to the commercial API.
|
||||
if not base_url:
|
||||
raise ValueError(
|
||||
"anthropic-compatible requires base_url (the server root, "
|
||||
"e.g. http://your-vllm-host:8000)"
|
||||
)
|
||||
# The Anthropic SDK appends /v1/... to base_url, so a
|
||||
# /v1-suffixed URL (the openai-compatible convention) would
|
||||
# request /v1/v1/messages and 404. Tolerate the suffix.
|
||||
# Keep the verbatim value when stripping would empty it
|
||||
# (base_url of exactly "/v1") so the typo still fails loudly
|
||||
# instead of silently retargeting the SDK's prod default.
|
||||
stripped = base_url.rstrip("/").removesuffix("/v1")
|
||||
base_url = stripped or base_url
|
||||
if base_url and base_url != "https://api.anthropic.com":
|
||||
kwargs["base_url"] = base_url
|
||||
return anthropic.Anthropic(**kwargs)
|
||||
raise ValueError(
|
||||
f"Unknown provider: {provider_name!r}. "
|
||||
"Supported: openai, anthropic, google, openai-compatible, xai"
|
||||
"Supported: openai, anthropic, google, openai-compatible, anthropic-compatible, xai"
|
||||
)
|
||||
|
||||
|
||||
@@ -153,11 +183,12 @@ def lookup_model_capabilities(provider: str, model: str) -> dict[str, Any] | Non
|
||||
"""Return static capabilities for a known model, or ``None`` if unknown.
|
||||
|
||||
The returned dict has JSON-friendly values (tuples converted to lists).
|
||||
Returns ``None`` for ``openai-compatible`` (no static table for local models).
|
||||
Returns ``None`` for ``openai-compatible`` and ``anthropic-compatible``
|
||||
(no static table for local models).
|
||||
"""
|
||||
import dataclasses
|
||||
|
||||
if provider == "openai-compatible":
|
||||
if provider in ("openai-compatible", "anthropic-compatible"):
|
||||
return None
|
||||
prov = create_provider(provider)
|
||||
caps = prov.get_capabilities(model)
|
||||
|
||||
@@ -71,6 +71,11 @@ _WEB_SEARCH_TOOL_TYPE = "web_search_20250305"
|
||||
# Tool search: server-side BM25 tool discovery for deferred tools
|
||||
_TOOL_SEARCH_TOOL_TYPE = "tool_search_tool_bm25"
|
||||
|
||||
# extra_params keys consumed internally (``_reasoning_params``) — never
|
||||
# forwarded to the wire ``extra_body``. Keeps real-Anthropic request
|
||||
# bodies byte-identical when a caller threads thinking overrides.
|
||||
_INTERNAL_EXTRA_PARAMS = frozenset({"thinking_budget_tokens"})
|
||||
|
||||
# -- model capabilities -------------------------------------------------------
|
||||
|
||||
_ANTHROPIC_DEFAULT = ModelCapabilities(
|
||||
@@ -83,6 +88,27 @@ _ANTHROPIC_DEFAULT = ModelCapabilities(
|
||||
supports_reasoning_replay=True,
|
||||
)
|
||||
|
||||
# Anthropic-compatible local servers (vLLM's /v1/messages endpoint):
|
||||
# token_param must be "max_tokens" (the only token param the endpoint
|
||||
# accepts); the "thinking" request param is not consumed by vLLM — the
|
||||
# reasoning toggle rides chat_template_kwargs via extra_body, so
|
||||
# thinking_mode stays "none"; supports_reasoning_replay stays True even
|
||||
# so, because the endpoint emits and round-trips thinking blocks whenever
|
||||
# the chat template enables reasoning (the request param is simply not
|
||||
# the switch); native web_search / tool_search server-tool types 400 on
|
||||
# vLLM (tools require input_schema); vision is opt-in per model.
|
||||
# supports_temperature stays True via the dataclass default.
|
||||
_ANTHROPIC_COMPAT_DEFAULT = ModelCapabilities(
|
||||
context_window=200000,
|
||||
max_output_tokens=64000,
|
||||
token_param="max_tokens",
|
||||
thinking_mode="none",
|
||||
supports_web_search=False,
|
||||
supports_tool_search=False,
|
||||
supports_vision=False,
|
||||
supports_reasoning_replay=True,
|
||||
)
|
||||
|
||||
_ANTHROPIC_CAPABILITIES: dict[str, ModelCapabilities] = {
|
||||
# Fable 5: same wire surface as opus-4-8 (adaptive-only thinking, no
|
||||
# sampling params, prefill rejected) with one extra constraint — an
|
||||
@@ -239,13 +265,24 @@ ANTHROPIC_REASONING_BLOCK_TYPES = frozenset({"thinking", "redacted_thinking"})
|
||||
|
||||
|
||||
class AnthropicProvider:
|
||||
"""Provider for Anthropic's Messages API with native streaming."""
|
||||
"""Provider for Anthropic's Messages API with native streaming.
|
||||
|
||||
``compat=True`` serves Anthropic-compatible local servers (vLLM's
|
||||
``/v1/messages``): same wire translation, but capabilities come from
|
||||
``_ANTHROPIC_COMPAT_DEFAULT`` for every model — the static Claude
|
||||
table never applies to local checkpoints.
|
||||
"""
|
||||
|
||||
def __init__(self, *, compat: bool = False) -> None:
|
||||
self._compat = compat
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "anthropic"
|
||||
return "anthropic-compatible" if self._compat else "anthropic"
|
||||
|
||||
def get_capabilities(self, model: str) -> ModelCapabilities:
|
||||
if self._compat:
|
||||
return _ANTHROPIC_COMPAT_DEFAULT
|
||||
return _lookup_capabilities(model, _ANTHROPIC_CAPABILITIES, _ANTHROPIC_DEFAULT)
|
||||
|
||||
# -- web search tool injection -------------------------------------------
|
||||
@@ -352,6 +389,13 @@ class AnthropicProvider:
|
||||
if effort:
|
||||
kwargs["output_config"] = {"effort": effort}
|
||||
|
||||
# Operator server_compat extra_body overrides (e.g. chat_template_kwargs
|
||||
# for anthropic-compatible local servers) ride the SDK's extra_body.
|
||||
if extra_params:
|
||||
wire_extra = {k: v for k, v in extra_params.items() if k not in _INTERNAL_EXTRA_PARAMS}
|
||||
if wire_extra:
|
||||
kwargs["extra_body"] = wire_extra
|
||||
|
||||
return kwargs
|
||||
|
||||
# -- message conversion --------------------------------------------------
|
||||
|
||||
@@ -86,6 +86,7 @@ from turnstone.core.memory import (
|
||||
search_visible_structured_memories,
|
||||
set_message_attachments,
|
||||
set_workstream_alias,
|
||||
touch_structured_memories,
|
||||
update_workstream_title,
|
||||
)
|
||||
from turnstone.core.memory_relevance import (
|
||||
@@ -1031,6 +1032,10 @@ class ChatSession:
|
||||
# tool results) and the recent-context string is identical across
|
||||
# them. Invalidated on user-turn append and on memory write/delete.
|
||||
self._mem_search_cache: dict[tuple[str, str, int], list[dict[str, str]]] = {}
|
||||
# Per-turn dedup for composition touches: ``_init_system_messages`` runs
|
||||
# many times within a turn, so the injected set is touched at most once
|
||||
# per memory per turn. Cleared alongside the search cache.
|
||||
self._touched_memory_keys: set[tuple[str, str, str]] = set()
|
||||
self._ws_id = ws_id or uuid.uuid4().hex
|
||||
self._title_generated = False
|
||||
self._read_files: set[str] = set()
|
||||
@@ -2873,6 +2878,9 @@ class ChatSession:
|
||||
candidates=len(visible_mems),
|
||||
injected=len(relevant),
|
||||
)
|
||||
# Access metadata tracks what the model actually saw — touch the
|
||||
# injected top-k, not the candidate pool.
|
||||
self._touch_injected_memories(relevant)
|
||||
if relevant:
|
||||
dev_parts.append("")
|
||||
dev_parts.append(build_memory_context(relevant))
|
||||
@@ -3127,7 +3135,10 @@ class ChatSession:
|
||||
|
||||
Forwards operator-supplied ``server_compat["extra_body"]`` overrides
|
||||
(``skip_special_tokens``, ``reasoning_format``, or explicit
|
||||
``chat_template_kwargs``) to the OpenAI SDK ``extra_body``. Operators
|
||||
``chat_template_kwargs``) to the OpenAI SDK ``extra_body`` on the
|
||||
OpenAI-shaped lanes, and to the Anthropic SDK ``extra_body`` on the
|
||||
anthropic-compatible lane (the channel for vLLM's
|
||||
``chat_template_kwargs`` reasoning toggle). Operators
|
||||
running gpt-oss-style local templates that consume ``reasoning_effort``
|
||||
from ``chat_template_kwargs`` should set it explicitly under
|
||||
``server_compat["extra_body"]["chat_template_kwargs"]``.
|
||||
@@ -3143,9 +3154,11 @@ class ChatSession:
|
||||
from turnstone.core.server_compat import merge_server_compat
|
||||
|
||||
prov = provider or self._provider
|
||||
# Only OpenAI-shaped providers consume extra_body. Anthropic/Google
|
||||
# have their own param paths handled inside their providers.
|
||||
if prov.provider_name not in ("openai", "openai-compatible"):
|
||||
# extra_body consumers: the OpenAI-shaped providers, plus the
|
||||
# anthropic-compatible lane (server_compat extra_body rides the
|
||||
# Anthropic SDK's extra_body). Real Anthropic and Google keep
|
||||
# their own param paths handled inside their providers.
|
||||
if prov.provider_name not in ("openai", "openai-compatible", "anthropic-compatible"):
|
||||
return None
|
||||
extra = merge_server_compat(None, self._get_server_compat(model_alias))
|
||||
return extra or None
|
||||
@@ -7439,6 +7452,7 @@ class ChatSession:
|
||||
def _invalidate_memory_cache(self) -> None:
|
||||
"""Drop the per-turn search cache; call on user-turn append + memory writes."""
|
||||
self._mem_search_cache.clear()
|
||||
self._touched_memory_keys.clear()
|
||||
|
||||
def _select_memory_candidates(self, context: str) -> tuple[list[dict[str, str]], str]:
|
||||
"""Pick the candidate set fed into BM25 ranking.
|
||||
@@ -7476,6 +7490,38 @@ class ChatSession:
|
||||
return extra, "recency"
|
||||
return search_hits + extra, ("union" if extra else "search")
|
||||
|
||||
@staticmethod
|
||||
def _memory_keys(rows: list[dict[str, str]]) -> list[tuple[str, str, str]]:
|
||||
"""Build ``(name, scope, scope_id)`` touch keys from memory rows.
|
||||
|
||||
The storage read helpers return ``SELECT *`` rows, so all three
|
||||
columns are present.
|
||||
"""
|
||||
return [(r.get("name", ""), r.get("scope", ""), r.get("scope_id", "")) for r in rows]
|
||||
|
||||
def _touch_injected_memories(self, rows: list[dict[str, str]]) -> None:
|
||||
"""Touch the memories injected into the system prefix this turn.
|
||||
|
||||
``_init_system_messages`` recomposes many times per turn; gate on the
|
||||
per-turn touched-key set so each surfaced memory is counted at most
|
||||
once between user turns. Best-effort: the facade swallows storage
|
||||
errors, so a failed touch never breaks composition.
|
||||
"""
|
||||
fresh = [k for k in self._memory_keys(rows) if k not in self._touched_memory_keys]
|
||||
if not fresh:
|
||||
return
|
||||
self._touched_memory_keys.update(fresh)
|
||||
touch_structured_memories(fresh)
|
||||
|
||||
def _touch_read_memories(self, rows: list[dict[str, str]]) -> None:
|
||||
"""Touch memories returned by an explicit memory-tool read.
|
||||
|
||||
A search/get is a distinct user-driven access each time it runs, so
|
||||
these are counted unconditionally (not subject to the composition
|
||||
per-turn dedup). Best-effort via the facade.
|
||||
"""
|
||||
touch_structured_memories(self._memory_keys(rows))
|
||||
|
||||
def _check_metacognitive_nudge(self, user_message: str) -> tuple[str, str] | None:
|
||||
"""Check if a metacognitive nudge should fire for *user_message*.
|
||||
|
||||
@@ -11489,6 +11535,7 @@ class ChatSession:
|
||||
found_scope = scope
|
||||
break
|
||||
if mem:
|
||||
self._touch_read_memories([mem])
|
||||
content = mem.get("content", "")
|
||||
desc = mem.get("description", "")
|
||||
mem_type = mem.get("type", "")
|
||||
@@ -11566,6 +11613,7 @@ class ChatSession:
|
||||
result_count=len(rows),
|
||||
query=item["query"][:120],
|
||||
)
|
||||
self._touch_read_memories(rows)
|
||||
if rows:
|
||||
lines = []
|
||||
for m in rows:
|
||||
|
||||
@@ -114,17 +114,19 @@ from turnstone.core.storage._utils import (
|
||||
from turnstone.core.storage._utils import (
|
||||
escape_like as _escape_like,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
find_orphan_conversations,
|
||||
prepare_provider_data_for_save,
|
||||
purge_orphan_conversations,
|
||||
release_attachment_refs,
|
||||
sanitize_text,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
normalize_search_terms as _normalize_search_terms,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
parse_attachment_refs as _parse_attachment_refs,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
prepare_provider_data_for_save,
|
||||
release_attachment_refs,
|
||||
sanitize_text,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
reconstruct_messages as _reconstruct_messages,
|
||||
)
|
||||
@@ -909,6 +911,16 @@ class PostgreSQLBackend:
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def list_orphan_conversations(self) -> list[dict[str, Any]]:
|
||||
with self._conn() as conn:
|
||||
return find_orphan_conversations(conn)
|
||||
|
||||
def delete_orphan_conversations(self, ws_ids: list[str]) -> dict[str, int]:
|
||||
with self._conn() as conn:
|
||||
result = purge_orphan_conversations(conn, ws_ids)
|
||||
conn.commit()
|
||||
return result
|
||||
|
||||
# -- Workstream attachments (content-addressed, refcounted) ----------------
|
||||
|
||||
def save_attachment(
|
||||
|
||||
@@ -633,6 +633,29 @@ class StorageBackend(Protocol):
|
||||
"""Delete a workstream and all its conversations + config."""
|
||||
...
|
||||
|
||||
def list_orphan_conversations(self) -> list[dict[str, Any]]:
|
||||
"""Conversation ws_ids with no ``workstreams`` row.
|
||||
|
||||
One dict per orphan workstream — keys ``ws_id``, ``rows``, ``first``,
|
||||
``last`` (ISO text timestamps), ``attachment_refs`` — ordered
|
||||
oldest-first. Read-only; feeds the ``turnstone-admin
|
||||
orphan-conversations`` maintenance verb.
|
||||
"""
|
||||
...
|
||||
|
||||
def delete_orphan_conversations(self, ws_ids: list[str]) -> dict[str, int]:
|
||||
"""Purge conversation rows for the *ws_ids* that are STILL orphaned.
|
||||
|
||||
Orphan-ness is enforced inside the DELETE itself (correlated
|
||||
``NOT EXISTS`` against ``workstreams``) and refcounts are released
|
||||
from its ``RETURNING`` — a ws_id registered before or during the
|
||||
purge keeps both its rows and its refcounts. Sweeps the purged
|
||||
ws_ids' ``workstream_config`` / ``workstream_overrides`` rows.
|
||||
Returns counts keyed ``workstreams``, ``rows``, ``released_refs``,
|
||||
``skipped`` (distinct inputs not purged).
|
||||
"""
|
||||
...
|
||||
|
||||
def list_workstreams(
|
||||
self,
|
||||
node_id: str | None = None,
|
||||
|
||||
@@ -114,17 +114,19 @@ from turnstone.core.storage._utils import (
|
||||
from turnstone.core.storage._utils import (
|
||||
escape_like as _escape_like,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
find_orphan_conversations,
|
||||
prepare_provider_data_for_save,
|
||||
purge_orphan_conversations,
|
||||
release_attachment_refs,
|
||||
sanitize_text,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
normalize_search_terms as _normalize_search_terms,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
parse_attachment_refs as _parse_attachment_refs,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
prepare_provider_data_for_save,
|
||||
release_attachment_refs,
|
||||
sanitize_text,
|
||||
)
|
||||
from turnstone.core.storage._utils import (
|
||||
reconstruct_messages as _reconstruct_messages,
|
||||
)
|
||||
@@ -1055,6 +1057,16 @@ class SQLiteBackend:
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def list_orphan_conversations(self) -> list[dict[str, Any]]:
|
||||
with self._conn() as conn:
|
||||
return find_orphan_conversations(conn)
|
||||
|
||||
def delete_orphan_conversations(self, ws_ids: list[str]) -> dict[str, int]:
|
||||
with self._conn() as conn:
|
||||
result = purge_orphan_conversations(conn, ws_ids)
|
||||
conn.commit()
|
||||
return result
|
||||
|
||||
# -- Workstream attachments (content-addressed, refcounted) ----------------
|
||||
|
||||
def save_attachment(
|
||||
|
||||
@@ -12,7 +12,13 @@ import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.attachments import unreadable_placeholder
|
||||
from turnstone.core.log import get_logger
|
||||
from turnstone.core.storage._schema import workstream_attachments
|
||||
from turnstone.core.storage._schema import (
|
||||
conversations,
|
||||
workstream_attachments,
|
||||
workstream_config,
|
||||
workstream_overrides,
|
||||
workstreams,
|
||||
)
|
||||
from turnstone.core.trajectory import (
|
||||
AttachmentRef,
|
||||
ContentBlock,
|
||||
@@ -173,6 +179,119 @@ def release_attachment_refs(conn: Any, attachment_ids: list[str]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def find_orphan_conversations(conn: Any) -> list[dict[str, Any]]:
|
||||
"""Conversation ws_ids that have no ``workstreams`` row, with row stats.
|
||||
|
||||
Orphans come from writers that persisted without a registered workstream:
|
||||
historically the pre-unification CLI/server paths, and the
|
||||
delete-during-inflight race (a late tool-result save re-creating rows
|
||||
after ``delete_workstream``). Read-only; ordered oldest-first. Each
|
||||
entry carries the attachment-ref count so a purge's refcount release is
|
||||
visible before it happens.
|
||||
"""
|
||||
anti_join = conversations.outerjoin(workstreams, conversations.c.ws_id == workstreams.c.ws_id)
|
||||
rows = conn.execute(
|
||||
sa.select(
|
||||
conversations.c.ws_id,
|
||||
sa.func.count().label("row_count"),
|
||||
sa.func.min(conversations.c.timestamp).label("first"),
|
||||
sa.func.max(conversations.c.timestamp).label("last"),
|
||||
)
|
||||
.select_from(anti_join)
|
||||
.where(workstreams.c.ws_id.is_(None))
|
||||
.group_by(conversations.c.ws_id)
|
||||
.order_by(sa.func.min(conversations.c.timestamp))
|
||||
).fetchall()
|
||||
# Ref counts in ONE pass over the orphan rows that carry attachments —
|
||||
# not a query per orphan workstream, so the scan stays proportional to
|
||||
# orphan ROW count.
|
||||
ref_counts: dict[str, int] = {}
|
||||
ref_rows = conn.execute(
|
||||
sa.select(conversations.c.ws_id, conversations.c.attachments)
|
||||
.select_from(anti_join)
|
||||
.where(
|
||||
sa.and_(
|
||||
workstreams.c.ws_id.is_(None),
|
||||
conversations.c.attachments.is_not(None),
|
||||
)
|
||||
)
|
||||
).fetchall()
|
||||
for ws_id, refs in ref_rows:
|
||||
ref_counts[ws_id] = ref_counts.get(ws_id, 0) + len(parse_attachment_refs(refs))
|
||||
return [
|
||||
{
|
||||
"ws_id": ws_id,
|
||||
"rows": int(row_count),
|
||||
"first": first,
|
||||
"last": last,
|
||||
"attachment_refs": ref_counts.get(ws_id, 0),
|
||||
}
|
||||
for ws_id, row_count, first, last in rows
|
||||
]
|
||||
|
||||
|
||||
# IN-list chunk size for the purge statements — mirrors the storage layer's
|
||||
# existing bulk chunking (SQLite bind-parameter limits).
|
||||
_PURGE_CHUNK = 500
|
||||
|
||||
|
||||
def purge_orphan_conversations(conn: Any, ws_ids: list[str]) -> dict[str, int]:
|
||||
"""Delete conversation rows for the *ws_ids* that are STILL orphans.
|
||||
|
||||
Orphan-ness is enforced INSIDE the DELETE itself (a correlated
|
||||
``NOT EXISTS`` against ``workstreams``), and the refcounts to release
|
||||
come from the DELETE's ``RETURNING`` — so refs are released for exactly
|
||||
the rows that were deleted. A ws_id registered at any point before the
|
||||
DELETE statement keeps both its rows AND its refcounts; there is no
|
||||
pre-count/delete window to underflow. (Needs ``DELETE .. RETURNING``:
|
||||
PostgreSQL, or SQLite ≥ 3.35.)
|
||||
|
||||
Input is de-duplicated and all IN-lists are chunked. ``skipped`` =
|
||||
distinct input ws_ids not purged (registered before/during the purge, or
|
||||
no rows). The purged ws_ids' ``workstream_config`` /
|
||||
``workstream_overrides`` rows are swept. Caller owns commit.
|
||||
"""
|
||||
distinct = list(dict.fromkeys(ws_ids))
|
||||
if not distinct:
|
||||
return {"workstreams": 0, "rows": 0, "released_refs": 0, "skipped": 0}
|
||||
ref_ids: list[str] = []
|
||||
purged_ws: set[str] = set()
|
||||
rows_deleted = 0
|
||||
for i in range(0, len(distinct), _PURGE_CHUNK):
|
||||
chunk = distinct[i : i + _PURGE_CHUNK]
|
||||
returned = conn.execute(
|
||||
sa.delete(conversations)
|
||||
.where(
|
||||
sa.and_(
|
||||
conversations.c.ws_id.in_(chunk),
|
||||
~sa.exists(
|
||||
sa.select(workstreams.c.ws_id).where(
|
||||
workstreams.c.ws_id == conversations.c.ws_id
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
.returning(conversations.c.ws_id, conversations.c.attachments)
|
||||
).fetchall()
|
||||
for ws_id, refs in returned:
|
||||
purged_ws.add(ws_id)
|
||||
rows_deleted += 1
|
||||
if refs:
|
||||
ref_ids.extend(parse_attachment_refs(refs))
|
||||
release_attachment_refs(conn, ref_ids)
|
||||
swept = sorted(purged_ws)
|
||||
for i in range(0, len(swept), _PURGE_CHUNK):
|
||||
chunk = swept[i : i + _PURGE_CHUNK]
|
||||
conn.execute(sa.delete(workstream_config).where(workstream_config.c.ws_id.in_(chunk)))
|
||||
conn.execute(sa.delete(workstream_overrides).where(workstream_overrides.c.ws_id.in_(chunk)))
|
||||
return {
|
||||
"workstreams": len(purged_ws),
|
||||
"rows": rows_deleted,
|
||||
"released_refs": len(ref_ids),
|
||||
"skipped": len(distinct) - len(purged_ws),
|
||||
}
|
||||
|
||||
|
||||
def attachment_to_content_part(att: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Convert a stored attachment row into an OpenAI-style content part.
|
||||
|
||||
|
||||
@@ -630,6 +630,28 @@ class Pane {
|
||||
}
|
||||
});
|
||||
|
||||
// Click-to-play for media embeds. Pane-owned (on this.el) and root-scoped
|
||||
// via closest(".media-play-btn") so every embedded L-shell pane activates
|
||||
// its own players — the old standalone wired this via a document-level
|
||||
// delegated listener in app.js, which the console host never loaded (so the
|
||||
// Play button was dead in console-hosted panes). Enter on a focused button
|
||||
// routes through the same path, mirroring the approval keydown above.
|
||||
this.el.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest(".media-play-btn");
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
activateMediaPlayButton(btn);
|
||||
});
|
||||
this.el.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
const btn = e.target.closest(".media-play-btn");
|
||||
if (!btn || btn.disabled) return;
|
||||
// Single-path activation: preventDefault stops the browser's native
|
||||
// Enter-to-click from dispatching a second activation behind ours.
|
||||
e.preventDefault();
|
||||
activateMediaPlayButton(btn);
|
||||
});
|
||||
|
||||
// No pane header: the workstream name, persona, and state are shown by the
|
||||
// tab and the rail (Workspaces); the --skip-permissions banner lands in
|
||||
// messagesEl (see the host warningTarget). The standalone split-pane
|
||||
@@ -2693,6 +2715,143 @@ function _tryPrettyJson(text) {
|
||||
return _redactApiKeys(JSON.stringify(obj, null, 2));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HLS lazy-loader + click-to-play (lifted from the standalone app.js so
|
||||
// console-hosted panes activate media too). Follows the mermaid.js
|
||||
// lazy-load pattern in /shared/renderer.js: the vendor is fetched by absolute
|
||||
// /shared/ URL on first use, so it resolves in BOTH the standalone server and
|
||||
// the console (where /shared is mounted at the root and node-proxied panes
|
||||
// also reach it via /node/{id}/shared/).
|
||||
// ---------------------------------------------------------------------------
|
||||
let _hlsState = "idle";
|
||||
let _hlsQueue = [];
|
||||
|
||||
function _loadHls(callback) {
|
||||
if (_hlsState === "ready") {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
_hlsQueue.push(callback);
|
||||
if (_hlsState === "loading") return;
|
||||
_hlsState = "loading";
|
||||
const script = document.createElement("script");
|
||||
script.src = "/shared/hls-1.6.16/hls.min.js";
|
||||
script.onload = function () {
|
||||
_hlsState = "ready";
|
||||
const q = _hlsQueue;
|
||||
_hlsQueue = [];
|
||||
for (let i = 0; i < q.length; i++) q[i]();
|
||||
};
|
||||
script.onerror = function () {
|
||||
_hlsState = "idle";
|
||||
const q = _hlsQueue;
|
||||
_hlsQueue = [];
|
||||
// Fall through — _activatePlayer will use stream_url since Hls is undefined
|
||||
for (let i = 0; i < q.length; i++) q[i]();
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
function _isHlsUrl(url) {
|
||||
return typeof url === "string" && /\.m3u8(\?|$)/i.test(url);
|
||||
}
|
||||
|
||||
function _activatePlayer(btn) {
|
||||
const url = btn.dataset.streamUrl;
|
||||
const hlsUrl = btn.dataset.hlsUrl;
|
||||
const isAudio = btn.dataset.audioOnly === "true";
|
||||
const directStream = btn.dataset.directStream === "true";
|
||||
|
||||
const player = document.createElement(isAudio ? "audio" : "video");
|
||||
player.controls = true;
|
||||
player.autoplay = true;
|
||||
player.className = "media-player";
|
||||
|
||||
// Held so the error handler can tear the instance down before the player
|
||||
// node is replaced — otherwise its listeners/loader timers run detached.
|
||||
let hls = null;
|
||||
|
||||
// Prefer direct stream when the source supports it; fall back to HLS
|
||||
// only when transcoding is needed.
|
||||
if (directStream && url) {
|
||||
player.src = url;
|
||||
} else if (
|
||||
hlsUrl &&
|
||||
!isAudio &&
|
||||
typeof Hls !== "undefined" &&
|
||||
Hls.isSupported()
|
||||
) {
|
||||
hls = new Hls();
|
||||
hls.loadSource(hlsUrl);
|
||||
hls.attachMedia(player);
|
||||
} else if (
|
||||
hlsUrl &&
|
||||
!isAudio &&
|
||||
player.canPlayType("application/vnd.apple.mpegurl")
|
||||
) {
|
||||
player.src = hlsUrl;
|
||||
} else {
|
||||
player.src = url;
|
||||
}
|
||||
|
||||
player.addEventListener("error", function () {
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
hls = null; // media error events can repeat — never double-destroy
|
||||
}
|
||||
const card = player.closest(".media-embed");
|
||||
const titleEl = card ? card.querySelector(".media-card-title") : null;
|
||||
const label = titleEl ? ": " + titleEl.textContent : "";
|
||||
|
||||
const err = document.createElement("div");
|
||||
err.className = "media-player-error";
|
||||
err.setAttribute("role", "alert");
|
||||
err.textContent = "Failed to load stream" + label;
|
||||
|
||||
const retry = document.createElement("button");
|
||||
retry.className = "media-play-btn";
|
||||
retry.type = "button";
|
||||
retry.dataset.streamUrl = url;
|
||||
retry.dataset.hlsUrl = hlsUrl || "";
|
||||
retry.dataset.audioOnly = String(isAudio);
|
||||
retry.dataset.directStream = String(directStream);
|
||||
retry.setAttribute("aria-label", "Retry" + label);
|
||||
retry.appendChild(document.createTextNode("▶ Retry"));
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.appendChild(err);
|
||||
container.appendChild(retry);
|
||||
player.replaceWith(container);
|
||||
});
|
||||
|
||||
btn.replaceWith(player);
|
||||
}
|
||||
|
||||
// Activate a clicked/Enter-pressed play button: show the loading affordance,
|
||||
// then ensure hls.js is loaded before swapping in the player when the source
|
||||
// needs it. The pane wires this from a root-scoped this.el listener.
|
||||
function activateMediaPlayButton(btn) {
|
||||
btn.disabled = true;
|
||||
const labelEl = btn.querySelector("span:last-child");
|
||||
if (labelEl) {
|
||||
labelEl.textContent = "Loading…";
|
||||
} else {
|
||||
btn.textContent = "▶ Loading…";
|
||||
}
|
||||
|
||||
const hlsUrl = btn.dataset.hlsUrl;
|
||||
const isAudio = btn.dataset.audioOnly === "true";
|
||||
|
||||
// If HLS URL present and not audio, ensure hls.js is loaded first
|
||||
if (hlsUrl && !isAudio && _isHlsUrl(hlsUrl)) {
|
||||
_loadHls(function () {
|
||||
_activatePlayer(btn);
|
||||
});
|
||||
} else {
|
||||
_activatePlayer(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function buildMediaCard(item) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "media-card";
|
||||
@@ -3244,10 +3403,18 @@ function createInteractivePane(root, wsId, opts) {
|
||||
warningTarget(pane) {
|
||||
return pane.messagesEl;
|
||||
},
|
||||
// MCP re-consent surfaces inline in the pane card; the console has no
|
||||
// settings-gear badge to drive (a future console consent surface can hook
|
||||
// here).
|
||||
onConsentDetected() {},
|
||||
// MCP re-consent surfaces inline in the pane card; the STANDALONE additionally
|
||||
// drives a Manage-row attention badge — bridged through the TS_APP seam so the
|
||||
// shared factory stays deployment-agnostic (the console doesn't define the
|
||||
// hook, so this stays a no-op there).
|
||||
onConsentDetected(server) {
|
||||
if (
|
||||
window.TS_APP &&
|
||||
typeof window.TS_APP.onConsentDetected === "function"
|
||||
) {
|
||||
window.TS_APP.onConsentDetected(server);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const pane = new Pane(wsId, {
|
||||
|
||||
+658
-14
@@ -6,7 +6,9 @@
|
||||
host is generic and every surface is a registered factory. This is the spine
|
||||
the rest of the renovation hangs off — step 1 exercises it with a single
|
||||
`dashboard` pane that adopts the legacy `#main`; richer pane types arrive in
|
||||
steps 2-5.
|
||||
steps 2-5. The split-view section lets the host show several open panes at
|
||||
once (the revived split-pane feature — see "Split view" below); without an
|
||||
active split tree the manager is strictly one-pane-per-tab.
|
||||
|
||||
House style: ES module, programmatic DOM (createElement / textContent /
|
||||
append), NO innerHTML. Panes scope all queries to their own `bodyEl` so they
|
||||
@@ -19,6 +21,15 @@ function cssId(s) {
|
||||
return String(s).replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
}
|
||||
|
||||
/* ----- Split view limits (the revived split-pane feature) -----
|
||||
A split cell below ~200×150 can't render a usable conversation (the composer
|
||||
alone needs ~150px of width headroom); 6 cells is the old ui/static ceiling,
|
||||
kept — past it the cells fall under the minimums on any sane viewport. */
|
||||
const SPLIT_MAX_CELLS = 6;
|
||||
const SPLIT_MIN_W = 200;
|
||||
const SPLIT_MIN_H = 150;
|
||||
const SPLIT_HANDLE_PX = 7; // keep in sync with shell.css .split-handle--row/--col
|
||||
|
||||
/**
|
||||
* A pane: a typed window with its own scoped root. Subtypes (or callers that
|
||||
* patch the lifecycle hooks) build content into `bodyEl` on first mount and
|
||||
@@ -221,6 +232,23 @@ export class PaneManager {
|
||||
this._activeId = null;
|
||||
this._activeSubs = []; // active-pane-change listeners (e.g. the rail marker)
|
||||
this._openMenu = null; // the currently-open tab-action dropdown, if any
|
||||
// Split view: a binary layout tree ({type:"leaf",paneId} | {type:"split",
|
||||
// dir:"row"|"col", ratio, children:[2]}), or null — null is single-pane
|
||||
// mode, where every code path below behaves exactly as before the feature.
|
||||
// Visible panes are positioned by inline % insets (no reparenting: a pane's
|
||||
// live SSE DOM and media elements are never detached).
|
||||
this._layout = null;
|
||||
this._handleEls = []; // live .split-handle separators (rebuilt per layout change)
|
||||
this._mru = []; // paneId[], most-recently-focused first (split auto-fill order)
|
||||
// Clicking anywhere inside a visible-but-unfocused pane focuses its cell
|
||||
// (capture phase — pane content may stopPropagation on bubbled events).
|
||||
if (this.panesEl) {
|
||||
this.panesEl.addEventListener(
|
||||
"pointerdown",
|
||||
(e) => this._onPanesPointerdown(e),
|
||||
true,
|
||||
);
|
||||
}
|
||||
// The tab bar is a WAI-ARIA tablist; arrow keys rove focus across the open
|
||||
// tabs (delegated, so it survives tab reconciliation).
|
||||
if (this.tabbarEl) {
|
||||
@@ -330,7 +358,7 @@ export class PaneManager {
|
||||
* via `setPaneMeta`) to have it persisted and handed back as `extra` on
|
||||
* rehydrate — the interactive pane does this with its resolved nodeId so a
|
||||
* reload restores the pane onto the SAME node (no re-route / duplicate load). */
|
||||
openPane(type, id, extra) {
|
||||
openPane(type, id, extra, _beside) {
|
||||
if (!this._types.has(type)) {
|
||||
console.warn("PaneManager: unknown pane type", type);
|
||||
return null;
|
||||
@@ -355,7 +383,14 @@ export class PaneManager {
|
||||
this._order.push(paneId);
|
||||
this._mount(pane);
|
||||
}
|
||||
this.activate(paneId);
|
||||
if (_beside && this._activeId !== paneId && !this._leafFor(paneId)) {
|
||||
// Open BESIDE the focused cell (see openPaneBeside) — a denied split
|
||||
// (cap / space) degrades to the plain focused-cell placement below.
|
||||
const r = this.splitFocused("right", paneId);
|
||||
if (!r.ok) this.activate(paneId);
|
||||
} else {
|
||||
this.activate(paneId);
|
||||
}
|
||||
// Explicit-reopen signal: openPane on an existing pane is a user saying
|
||||
// "open this AGAIN" (saved-list resume, rail row, child link) — activate()
|
||||
// alone can't carry that (it no-ops hooks on the already-active pane, and
|
||||
@@ -371,6 +406,17 @@ export class PaneManager {
|
||||
return pane;
|
||||
}
|
||||
|
||||
/** openPane, but a pane that was not already on screen lands in a fresh
|
||||
* cell to the RIGHT of the focused one instead of replacing it — the
|
||||
* coordinator child-link gesture (the parent stays visible; you are
|
||||
* usually cross-checking the child against the tree that spawned it).
|
||||
* An already-visible pane is just focused; a denied split (cell cap /
|
||||
* not enough space) degrades to the plain focused-cell swap. Everything
|
||||
* else (auth gate, factory hint, onReopen) is openPane's. */
|
||||
openPaneBeside(type, id, extra) {
|
||||
return this.openPane(type, id, extra, true);
|
||||
}
|
||||
|
||||
_mount(pane) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "pane";
|
||||
@@ -394,31 +440,55 @@ export class PaneManager {
|
||||
this._renderTabs();
|
||||
}
|
||||
|
||||
/** Show one pane, hide the rest, fire deactivate/activate hooks. */
|
||||
/** Show one pane (single-pane mode: hide the rest) or focus it (split mode),
|
||||
* firing deactivate/activate hooks. In split mode "active" means FOCUSED:
|
||||
* a pane already in a cell keeps every cell as-is (pure focus move); a
|
||||
* backgrounded pane swaps into the focused cell, parking that cell's
|
||||
* current pane. onDeactivate therefore means "lost focus", not necessarily
|
||||
* "hidden" — which matches the panes' contract (interactive panes only stop
|
||||
* focus-stealing and keep streaming: exactly what a visible-but-unfocused
|
||||
* cell wants). */
|
||||
activate(paneId) {
|
||||
// Re-activating the already-active pane is a cheap no-op (it still re-renders
|
||||
// tabs / re-persists / re-notifies below, just no onDeactivate/onActivate).
|
||||
if (!this._panes.has(paneId)) return;
|
||||
const prev = this._activeId ? this._panes.get(this._activeId) : null;
|
||||
if (prev && prev.id !== paneId) {
|
||||
const next = this._panes.get(paneId);
|
||||
const changed = this._activeId !== paneId;
|
||||
if (this._layout) {
|
||||
if (changed && !this._leafFor(paneId)) {
|
||||
// Swap the backgrounded pane into the focused cell.
|
||||
const target = this._leafFor(this._activeId) || this._firstLeaf();
|
||||
const old = target ? this._panes.get(target.paneId) : null;
|
||||
if (target) target.paneId = paneId;
|
||||
if (old && old !== next) {
|
||||
old.el.hidden = true;
|
||||
this._clearCellStyle(old);
|
||||
}
|
||||
}
|
||||
} else if (prev && prev.id !== paneId) {
|
||||
prev.el.hidden = true;
|
||||
}
|
||||
if (changed && prev) {
|
||||
try {
|
||||
prev.onDeactivate();
|
||||
} catch (e) {
|
||||
console.error("PaneManager: onDeactivate failed", prev.id, e);
|
||||
}
|
||||
}
|
||||
const next = this._panes.get(paneId);
|
||||
next.el.hidden = false;
|
||||
const changed = this._activeId !== paneId;
|
||||
this._activeId = paneId;
|
||||
if (changed) {
|
||||
// Most-recently-focused order — the split auto-fill source.
|
||||
this._mru = [paneId].concat(this._mru.filter((p) => p !== paneId));
|
||||
try {
|
||||
next.onActivate();
|
||||
} catch (e) {
|
||||
console.error("PaneManager: onActivate failed", paneId, e);
|
||||
}
|
||||
}
|
||||
if (this._layout) this._applyLayout();
|
||||
this._refreshCellChips();
|
||||
this._renderTabs();
|
||||
this._persist();
|
||||
this._notifyActive();
|
||||
@@ -434,11 +504,19 @@ export class PaneManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop a pane (tab + content), release it, focus a neighbour. */
|
||||
/** Drop a pane (tab + content), release it, focus a neighbour. In split
|
||||
* mode a visible pane's cell collapses first (its sibling takes the space),
|
||||
* and the sibling is preferred as the fallback focus target so closing a
|
||||
* cell lands you on the pane that absorbed it. */
|
||||
close(paneId) {
|
||||
const pane = this._panes.get(paneId);
|
||||
if (!pane || pane.closable === false) return;
|
||||
this._closeTabMenu(); // a dropdown anchored on the closing tab must not strand
|
||||
let preferFallback = null;
|
||||
if (this._layout) {
|
||||
const leaf = this._leafFor(paneId);
|
||||
if (leaf) preferFallback = this._collapseLeaf(leaf);
|
||||
}
|
||||
try {
|
||||
pane.onClose();
|
||||
} catch (e) {
|
||||
@@ -447,17 +525,576 @@ export class PaneManager {
|
||||
if (pane.el && pane.el.parentNode) pane.el.parentNode.removeChild(pane.el);
|
||||
this._panes.delete(paneId);
|
||||
this._order = this._order.filter((p) => p !== paneId);
|
||||
this._mru = this._mru.filter((p) => p !== paneId);
|
||||
if (this._activeId === paneId) {
|
||||
this._activeId = null;
|
||||
const fallback = this._order[this._order.length - 1];
|
||||
const fallback = preferFallback || this._order[this._order.length - 1];
|
||||
if (fallback)
|
||||
this.activate(fallback); // fires _notifyActive itself
|
||||
else this._notifyActive(); // last pane closed — clear the marker
|
||||
} else {
|
||||
this._notifyActive(); // split state may have changed (a cell collapsed)
|
||||
}
|
||||
this._renderTabs();
|
||||
this._persist();
|
||||
}
|
||||
|
||||
/* ===== Split view ============================================================
|
||||
The layout tree shows MORE than one open pane at once. Tabs stay global:
|
||||
the active tab is the FOCUSED cell, clicking a backgrounded tab swaps that
|
||||
pane into the focused cell, clicking inside a visible pane focuses its
|
||||
cell. Cells are rendered as inline % insets on the pane elements
|
||||
themselves — a pane is NEVER reparented or detached, so its live stream
|
||||
DOM, scroll positions and media elements are untouched by layout changes.
|
||||
With `_layout === null` (the default) every path above behaves exactly as
|
||||
it did before this feature existed.
|
||||
========================================================================== */
|
||||
|
||||
/** Is the pane host currently showing more than one cell? */
|
||||
isSplit() {
|
||||
return !!this._layout;
|
||||
}
|
||||
|
||||
/** Split the focused pane's cell — `dir` "right" puts the new cell beside
|
||||
* it, "down" below it. The new cell shows `fillId` when given (the
|
||||
* openPaneBeside path), else the most-recently-focused backgrounded pane.
|
||||
* Splitting never duplicates a pane (panes are keyed singletons — two
|
||||
* mounts of one session would race its stream). Returns `{ok:true}` or
|
||||
* `{ok:false, reason}`; the CALLER owns user feedback (the shell toasts
|
||||
* the reason — PaneManager stays chrome-free). */
|
||||
splitFocused(dir, fillId) {
|
||||
const activeId = this._activeId;
|
||||
if (!activeId || !this._panes.has(activeId))
|
||||
return { ok: false, reason: "Nothing to split" };
|
||||
if (this._leafCount() >= SPLIT_MAX_CELLS)
|
||||
return {
|
||||
ok: false,
|
||||
reason: "Pane limit reached (" + SPLIT_MAX_CELLS + ")",
|
||||
};
|
||||
if (fillId != null) {
|
||||
// An explicit fill must be an open, backgrounded, non-focused pane —
|
||||
// anything else is the caller's bug; deny so it can degrade cleanly.
|
||||
if (
|
||||
!this._panes.has(fillId) ||
|
||||
fillId === activeId ||
|
||||
this._leafFor(fillId)
|
||||
)
|
||||
return { ok: false, reason: "Not splittable" };
|
||||
} else {
|
||||
fillId = this._nextBackgroundPane();
|
||||
if (!fillId)
|
||||
return {
|
||||
ok: false,
|
||||
reason: "Every open tab is already visible — open another one first",
|
||||
};
|
||||
}
|
||||
// Space guard: the focused cell must fit two cells plus the divider.
|
||||
const rect = this._cellRect(activeId);
|
||||
const need =
|
||||
(dir === "down" ? SPLIT_MIN_H : SPLIT_MIN_W) * 2 + SPLIT_HANDLE_PX;
|
||||
if ((dir === "down" ? rect.h : rect.w) < need)
|
||||
return { ok: false, reason: "Not enough space to split" };
|
||||
const leaf = this._layout ? this._leafFor(activeId) : null;
|
||||
const node = {
|
||||
type: "split",
|
||||
dir: dir === "down" ? "col" : "row",
|
||||
ratio: 0.5,
|
||||
children: [
|
||||
leaf || { type: "leaf", paneId: activeId },
|
||||
{ type: "leaf", paneId: fillId },
|
||||
],
|
||||
};
|
||||
if (!leaf) {
|
||||
this._layout = node;
|
||||
} else {
|
||||
const found = this._findParent(this._layout, leaf);
|
||||
if (found) found.parent.children[found.index] = node;
|
||||
else this._layout = node; // the leaf was the root (defensive — see _collapseLeaf)
|
||||
}
|
||||
this.panesEl.classList.add("panes--split");
|
||||
this._applyLayout(true);
|
||||
this.activate(fillId); // focus the new cell (already a leaf → pure focus move)
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Collapse back to a single pane — the focused one. The other panes stay
|
||||
* open as tabs (they just stop being visible); nothing is closed. */
|
||||
unsplit() {
|
||||
if (!this._layout) return;
|
||||
this._exitLayout(this._activeId);
|
||||
this._renderTabs();
|
||||
this._persist();
|
||||
this._notifyActive();
|
||||
}
|
||||
|
||||
/** Remove ONE cell from the split — the pane stays open as a (now hidden)
|
||||
* tab and its sibling absorbs the space. The per-cell ✕ chip calls this;
|
||||
* distinct from close() (destroys the pane) and unsplit() (collapses every
|
||||
* cell but the focused one). */
|
||||
closeCell(paneId) {
|
||||
if (!this._layout) return;
|
||||
const leaf = this._leafFor(paneId);
|
||||
if (!leaf) return;
|
||||
const sibling = this._collapseLeaf(leaf); // hides the pane + may exit split mode
|
||||
if (this._activeId === paneId && sibling) {
|
||||
this.activate(sibling); // fires the focus hooks + renders + persists + notifies
|
||||
} else {
|
||||
this._renderTabs();
|
||||
this._persist();
|
||||
this._notifyActive();
|
||||
}
|
||||
}
|
||||
|
||||
// ----- tree helpers -----
|
||||
|
||||
_leaves(node, out) {
|
||||
out = out || [];
|
||||
node = node || this._layout;
|
||||
if (!node) return out;
|
||||
if (node.type === "leaf") out.push(node);
|
||||
else {
|
||||
this._leaves(node.children[0], out);
|
||||
this._leaves(node.children[1], out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
_leafCount() {
|
||||
return this._layout ? this._leaves().length : 1;
|
||||
}
|
||||
|
||||
_leafFor(paneId) {
|
||||
if (paneId == null || !this._layout) return null;
|
||||
return this._leaves().find((l) => l.paneId === paneId) || null;
|
||||
}
|
||||
|
||||
_firstLeaf(node) {
|
||||
node = node || this._layout;
|
||||
if (!node) return null;
|
||||
return node.type === "leaf" ? node : this._firstLeaf(node.children[0]);
|
||||
}
|
||||
|
||||
_findParent(node, target) {
|
||||
if (!node || node.type === "leaf") return null;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
if (node.children[i] === target) return { parent: node, index: i };
|
||||
const found = this._findParent(node.children[i], target);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Remove a leaf: its sibling subtree takes the parent's place. Exits split
|
||||
* mode when one cell remains. Returns the sibling's first pane id — the
|
||||
* natural focus target for a close() that emptied the focused cell. */
|
||||
_collapseLeaf(leaf) {
|
||||
const found = this._findParent(this._layout, leaf);
|
||||
if (!found) {
|
||||
// The leaf IS the root — a tree this small should already have exited
|
||||
// split mode; recover rather than strand a stale layout.
|
||||
this._exitLayout(null);
|
||||
return null;
|
||||
}
|
||||
const sibling = found.parent.children[found.index === 0 ? 1 : 0];
|
||||
const grand = this._findParent(this._layout, found.parent);
|
||||
if (grand) grand.parent.children[grand.index] = sibling;
|
||||
else this._layout = sibling;
|
||||
const first = this._firstLeaf(sibling);
|
||||
if (this._layout.type === "leaf") this._exitLayout(this._layout.paneId);
|
||||
else this._applyLayout(true);
|
||||
return first ? first.paneId : null;
|
||||
}
|
||||
|
||||
/** The most-recently-focused open pane that is not currently visible —
|
||||
* what a fresh split cell shows. Null when every open pane is visible. */
|
||||
_nextBackgroundPane() {
|
||||
const visible = new Set(
|
||||
this._layout
|
||||
? this._leaves().map((l) => l.paneId)
|
||||
: [this._activeId].filter(Boolean),
|
||||
);
|
||||
for (const pid of this._mru) {
|
||||
if (this._panes.has(pid) && !visible.has(pid)) return pid;
|
||||
}
|
||||
for (const pid of this._order) {
|
||||
if (!visible.has(pid)) return pid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ----- geometry + rendering -----
|
||||
|
||||
/** The px rect of a pane's current cell (the whole host when unsplit). */
|
||||
_cellRect(paneId) {
|
||||
const W = this.panesEl.clientWidth;
|
||||
const H = this.panesEl.clientHeight;
|
||||
if (!this._layout) return { x: 0, y: 0, w: W, h: H };
|
||||
let hit = null;
|
||||
const walk = (node, x, y, w, h) => {
|
||||
if (hit) return;
|
||||
if (node.type === "leaf") {
|
||||
if (node.paneId === paneId) hit = { x, y, w, h };
|
||||
return;
|
||||
}
|
||||
const r = node.ratio;
|
||||
if (node.dir === "row") {
|
||||
walk(node.children[0], x, y, w * r, h);
|
||||
walk(node.children[1], x + w * r, y, w * (1 - r), h);
|
||||
} else {
|
||||
walk(node.children[0], x, y, w, h * r);
|
||||
walk(node.children[1], x, y + h * r, w, h * (1 - r));
|
||||
}
|
||||
};
|
||||
walk(this._layout, 0, 0, W, H);
|
||||
return hit || { x: 0, y: 0, w: W, h: H };
|
||||
}
|
||||
|
||||
/** Lay the visible panes out as % insets and place the separators.
|
||||
* `rebuild` re-creates the handle ELEMENTS (tree structure changed);
|
||||
* without it only styles update, so a mid-drag handle keeps its pointer
|
||||
* capture. % insets make window resizes free — no JS resize listener. */
|
||||
_applyLayout(rebuild) {
|
||||
if (!this._layout) return;
|
||||
const rects = new Map();
|
||||
const handles = [];
|
||||
const walk = (node, x, y, w, h) => {
|
||||
if (node.type === "leaf") {
|
||||
rects.set(node.paneId, { x, y, w, h });
|
||||
return;
|
||||
}
|
||||
const r = node.ratio;
|
||||
if (node.dir === "row") {
|
||||
walk(node.children[0], x, y, w * r, h);
|
||||
handles.push({ node, x: x + w * r, y, span: h });
|
||||
walk(node.children[1], x + w * r, y, w * (1 - r), h);
|
||||
} else {
|
||||
walk(node.children[0], x, y, w, h * r);
|
||||
handles.push({ node, x, y: y + h * r, span: w });
|
||||
walk(node.children[1], x, y + h * r, w, h * (1 - r));
|
||||
}
|
||||
};
|
||||
walk(this._layout, 0, 0, 1, 1);
|
||||
const multi = rects.size > 1;
|
||||
for (const p of this._panes.values()) {
|
||||
const r = rects.get(p.id);
|
||||
if (r) {
|
||||
p.el.hidden = false;
|
||||
p.el.style.left = r.x * 100 + "%";
|
||||
p.el.style.top = r.y * 100 + "%";
|
||||
p.el.style.width = r.w * 100 + "%";
|
||||
p.el.style.height = r.h * 100 + "%";
|
||||
// The focus ring only means something with 2+ cells on screen.
|
||||
p.el.classList.toggle(
|
||||
"split-focused",
|
||||
multi && p.id === this._activeId,
|
||||
);
|
||||
} else {
|
||||
p.el.hidden = true;
|
||||
this._clearCellStyle(p);
|
||||
}
|
||||
}
|
||||
if (rebuild) {
|
||||
for (const h of this._handleEls) h.remove();
|
||||
this._handleEls = handles.map((h) => this._buildHandle(h.node));
|
||||
}
|
||||
// Position fresh AND surviving handles from the same walk (identical
|
||||
// traversal order, so index pairing is stable while the tree shape is).
|
||||
for (let i = 0; i < handles.length && i < this._handleEls.length; i++) {
|
||||
const h = handles[i];
|
||||
const el = this._handleEls[i];
|
||||
el.style.left = h.x * 100 + "%";
|
||||
el.style.top = h.y * 100 + "%";
|
||||
if (h.node.dir === "row") el.style.height = h.span * 100 + "%";
|
||||
else el.style.width = h.span * 100 + "%";
|
||||
// The ARIA range is the REAL clamp (_ratioBounds: the cell minimums
|
||||
// against this split's OWN px region — nested splits sit tighter than
|
||||
// any constant; the old hard-coded 10–90 misreported it to AT). It
|
||||
// refreshes with every drag/keyboard/structure pass through here; a
|
||||
// bare window resize can stale it until the next interaction (no
|
||||
// resize listener by design — % insets make resizes free), which is
|
||||
// still strictly truer than a constant. The max>=min guard covers a
|
||||
// host shrunk below two minimums, where the bounds legitimately cross.
|
||||
const b = this._ratioBounds(h.node);
|
||||
const lo = Math.round(b.min * 100);
|
||||
el.setAttribute("aria-valuemin", String(lo));
|
||||
el.setAttribute(
|
||||
"aria-valuemax",
|
||||
String(Math.max(lo, Math.round(b.max * 100))),
|
||||
);
|
||||
el.setAttribute("aria-valuenow", String(Math.round(h.node.ratio * 100)));
|
||||
}
|
||||
}
|
||||
|
||||
/** Leave split mode. `keepId` (usually the focused pane) stays visible and
|
||||
* the other panes hide; null leaves visibility to the caller (the close()
|
||||
* fallback re-activates). No extra deactivate hooks fire: a pane hidden
|
||||
* here already lost focus — and with it its onDeactivate — earlier. */
|
||||
_exitLayout(keepId) {
|
||||
this._layout = null;
|
||||
this.panesEl.classList.remove("panes--split");
|
||||
for (const h of this._handleEls) h.remove();
|
||||
this._handleEls = [];
|
||||
for (const p of this._panes.values()) {
|
||||
this._clearCellStyle(p);
|
||||
if (keepId) p.el.hidden = p.id !== keepId;
|
||||
}
|
||||
this._refreshCellChips(); // the survivor's ✕ flips to close-pane mode
|
||||
}
|
||||
|
||||
_clearCellStyle(pane) {
|
||||
pane.el.style.left = "";
|
||||
pane.el.style.top = "";
|
||||
pane.el.style.width = "";
|
||||
pane.el.style.height = "";
|
||||
pane.el.classList.remove("split-focused");
|
||||
this._removeCellChip(pane);
|
||||
}
|
||||
|
||||
/** The per-pane ✕ chip, top-right of every VISIBLE pane. Mode-dependent:
|
||||
* in a multi-cell split it hides that cell (closeCell — the tab stays);
|
||||
* single-pane it closes the pane outright (tab and all), so it is withheld
|
||||
* from non-closable panes (the Dashboard) there. The click decides at
|
||||
* CLICK time, the label tracks the mode. Injected by the MANAGER into the
|
||||
* pane's section (not bodyEl — pane content is never touched). */
|
||||
_refreshCellChips() {
|
||||
const multi = !!this._layout && this._leaves().length > 1;
|
||||
for (const p of this._panes.values()) {
|
||||
const want =
|
||||
!p.el.hidden && (multi ? !!this._leafFor(p.id) : p.closable !== false);
|
||||
if (want) this._ensureCellChip(p, multi);
|
||||
else this._removeCellChip(p);
|
||||
}
|
||||
}
|
||||
|
||||
_ensureCellChip(pane, multi) {
|
||||
let b = pane._cellChip;
|
||||
if (!b || !b.isConnected) {
|
||||
b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "cell-unsplit";
|
||||
b.addEventListener("click", () => {
|
||||
if (this._layout && this._leafFor(pane.id)) this.closeCell(pane.id);
|
||||
else this.close(pane.id);
|
||||
});
|
||||
pane.el.append(b);
|
||||
pane._cellChip = b;
|
||||
}
|
||||
// Mode-DISTINCT glyphs — an identical signifier at an identical locus with
|
||||
// divergent outcomes is a mode-error trap (split-mode muscle memory would
|
||||
// fire the destructive close): − hides the cell (reversible — the tab
|
||||
// stays), ✕ closes the pane. Close mode also wears a danger hover
|
||||
// (shell.css .cell-unsplit--close).
|
||||
b.textContent = multi ? "−" : "✕";
|
||||
b.classList.toggle("cell-unsplit--close", !multi);
|
||||
const label = multi ? "Hide from split — the tab stays open" : "Close pane";
|
||||
b.title = label;
|
||||
b.setAttribute("aria-label", label);
|
||||
}
|
||||
|
||||
_removeCellChip(pane) {
|
||||
if (pane._cellChip) {
|
||||
pane._cellChip.remove();
|
||||
pane._cellChip = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Focus follows the pointer between cells: a click anywhere inside a
|
||||
* visible-but-unfocused pane focuses it (capture phase — pane content may
|
||||
* stop propagation of bubbled events). */
|
||||
_onPanesPointerdown(e) {
|
||||
if (!this._layout) return;
|
||||
// The ✕ chip collapses its cell — focusing that cell first would fire a
|
||||
// spurious onActivate on the very pane about to leave the screen.
|
||||
if (e.target.closest && e.target.closest(".cell-unsplit")) return;
|
||||
let el = e.target;
|
||||
// The ShellPane <section> is the DIRECT child of the host (the interactive
|
||||
// pane's inner <div> also carries .pane — walking to the direct child
|
||||
// disambiguates without knowing any pane-type internals).
|
||||
while (el && el.parentElement !== this.panesEl) el = el.parentElement;
|
||||
if (!el || !el.classList || !el.classList.contains("pane")) return;
|
||||
for (const p of this._panes.values()) {
|
||||
if (p.el === el) {
|
||||
if (p.id !== this._activeId && this._leafFor(p.id)) this.activate(p.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- separators (drag + keyboard resize) -----
|
||||
|
||||
_buildHandle(node) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "split-handle split-handle--" + node.dir;
|
||||
el.setAttribute("role", "separator");
|
||||
el.tabIndex = 0;
|
||||
el.setAttribute(
|
||||
"aria-orientation",
|
||||
node.dir === "row" ? "vertical" : "horizontal",
|
||||
);
|
||||
// aria-valuenow/min/max are written by _applyLayout's handle loop (the
|
||||
// single writer) — the range comes from _ratioBounds, not a constant.
|
||||
el.setAttribute(
|
||||
"aria-label",
|
||||
node.dir === "row"
|
||||
? "Resize panes horizontally"
|
||||
: "Resize panes vertically",
|
||||
);
|
||||
this._wireHandle(el, node);
|
||||
this.panesEl.append(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Ratio bounds that keep both children of a split above the cell minimums,
|
||||
* derived from the split node's CURRENT px region (so nested splits clamp
|
||||
* against their own space, not the whole host). */
|
||||
_ratioBounds(node) {
|
||||
const W = this.panesEl.clientWidth;
|
||||
const H = this.panesEl.clientHeight;
|
||||
let region = null;
|
||||
const walk = (n, x, y, w, h) => {
|
||||
if (region) return;
|
||||
if (n === node) {
|
||||
region = { w, h };
|
||||
return;
|
||||
}
|
||||
if (n.type === "leaf") return;
|
||||
const r = n.ratio;
|
||||
if (n.dir === "row") {
|
||||
walk(n.children[0], x, y, w * r, h);
|
||||
walk(n.children[1], x + w * r, y, w * (1 - r), h);
|
||||
} else {
|
||||
walk(n.children[0], x, y, w, h * r);
|
||||
walk(n.children[1], x, y + h * r, w, h * (1 - r));
|
||||
}
|
||||
};
|
||||
walk(this._layout, 0, 0, W, H);
|
||||
const px = region ? (node.dir === "row" ? region.w : region.h) : 0;
|
||||
const minPx = node.dir === "row" ? SPLIT_MIN_W : SPLIT_MIN_H;
|
||||
return {
|
||||
min: px > 0 ? Math.max(0.05, minPx / px) : 0.1,
|
||||
max: px > 0 ? Math.min(0.95, 1 - minPx / px) : 0.9,
|
||||
px: px || 1,
|
||||
};
|
||||
}
|
||||
|
||||
_wireHandle(el, node) {
|
||||
el.addEventListener("pointerdown", (e) => {
|
||||
// Touch/pen carry no primary-button semantics — only filter MOUSE
|
||||
// non-primary buttons (a bare `e.button !== 0` would break touch).
|
||||
if (e.button !== 0 && e.pointerType === "mouse") return;
|
||||
e.preventDefault();
|
||||
el.setPointerCapture(e.pointerId);
|
||||
el.classList.add("dragging");
|
||||
const bounds = this._ratioBounds(node);
|
||||
const startRatio = node.ratio;
|
||||
const horiz = node.dir === "row";
|
||||
const startPos = horiz ? e.clientX : e.clientY;
|
||||
document.body.style.cursor = horiz ? "col-resize" : "row-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
const onMove = (e2) => {
|
||||
const delta = (horiz ? e2.clientX : e2.clientY) - startPos;
|
||||
node.ratio = Math.max(
|
||||
bounds.min,
|
||||
Math.min(bounds.max, startRatio + delta / bounds.px),
|
||||
);
|
||||
this._applyLayout();
|
||||
};
|
||||
const onUp = () => {
|
||||
el.classList.remove("dragging");
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
document.removeEventListener("pointermove", onMove);
|
||||
document.removeEventListener("pointerup", onUp);
|
||||
document.removeEventListener("pointercancel", onUp);
|
||||
this._persist(); // the settled ratio is part of the working set
|
||||
};
|
||||
// The move/up listeners live on DOCUMENT, not the handle: a layout
|
||||
// rebuild can remove the handle MID-DRAG (e.g. a server-side ws_closed
|
||||
// collapses a cell), and handle-bound listeners would die with it,
|
||||
// stranding the body-wide cursor/user-select overrides. Capture loss on
|
||||
// removal just redirects the events to the hit-test chain — document
|
||||
// still hears them and onUp always runs.
|
||||
document.addEventListener("pointermove", onMove);
|
||||
document.addEventListener("pointerup", onUp);
|
||||
document.addEventListener("pointercancel", onUp);
|
||||
});
|
||||
// Keyboard resize: arrows nudge (Shift = coarse), Home/End to the bounds.
|
||||
el.addEventListener("keydown", (e) => {
|
||||
const bounds = this._ratioBounds(node);
|
||||
const step = e.shiftKey ? 0.1 : 0.02;
|
||||
let delta = 0;
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") delta = step;
|
||||
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") delta = -step;
|
||||
else if (e.key === "Home") delta = bounds.min - node.ratio;
|
||||
else if (e.key === "End") delta = bounds.max - node.ratio;
|
||||
else return;
|
||||
e.preventDefault();
|
||||
node.ratio = Math.max(
|
||||
bounds.min,
|
||||
Math.min(bounds.max, node.ratio + delta),
|
||||
);
|
||||
this._applyLayout();
|
||||
this._persist();
|
||||
});
|
||||
}
|
||||
|
||||
// ----- persistence -----
|
||||
|
||||
_serializeLayout(node) {
|
||||
if (node.type === "leaf") return { type: "leaf", paneId: node.paneId };
|
||||
return {
|
||||
type: "split",
|
||||
dir: node.dir,
|
||||
ratio: node.ratio,
|
||||
children: [
|
||||
this._serializeLayout(node.children[0]),
|
||||
this._serializeLayout(node.children[1]),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Re-apply a persisted layout after rehydrate re-opened the panes. Leaves
|
||||
* whose pane did not restore (skipped type, auth-denied, duplicate) prune
|
||||
* away and their sibling absorbs the space — the same degrade-don't-error
|
||||
* stance rehydrate takes on pane types. */
|
||||
_restoreLayout(data) {
|
||||
const seen = new Set();
|
||||
const prune = (d) => {
|
||||
if (!d || typeof d !== "object") return null;
|
||||
if (d.type === "leaf") {
|
||||
if (!this._panes.has(d.paneId) || seen.has(d.paneId)) return null;
|
||||
seen.add(d.paneId);
|
||||
return { type: "leaf", paneId: d.paneId };
|
||||
}
|
||||
if (d.type !== "split" || !Array.isArray(d.children)) return null;
|
||||
const a = prune(d.children[0]);
|
||||
const b = prune(d.children[1]);
|
||||
if (!a || !b) return a || b;
|
||||
return {
|
||||
type: "split",
|
||||
dir: d.dir === "col" ? "col" : "row",
|
||||
ratio:
|
||||
typeof d.ratio === "number" && d.ratio >= 0.05 && d.ratio <= 0.95
|
||||
? d.ratio
|
||||
: 0.5,
|
||||
children: [a, b],
|
||||
};
|
||||
};
|
||||
const tree = prune(data);
|
||||
if (!tree || tree.type === "leaf") return; // 0-1 cells — stay single-pane
|
||||
this._layout = tree;
|
||||
this.panesEl.classList.add("panes--split");
|
||||
this._applyLayout(true);
|
||||
// The persisted active pane may have failed to restore — focus the first
|
||||
// cell rather than leaving a hidden pane active.
|
||||
if (!this._leafFor(this._activeId)) {
|
||||
const first = this._firstLeaf(tree);
|
||||
if (first) this.activate(first.paneId);
|
||||
}
|
||||
this._refreshCellChips();
|
||||
this._renderTabs(); // pick up the .shown markers
|
||||
}
|
||||
|
||||
_renderTabs() {
|
||||
// Reconcile the managed tabs IN PLACE — never destroy + recreate. Keeps
|
||||
// keyboard focus and click/keydown listeners stable across activate / open /
|
||||
@@ -538,10 +1175,13 @@ export class PaneManager {
|
||||
}
|
||||
|
||||
/** Refresh a tab's selection state: roving tabindex (only the selected tab is
|
||||
* in the Tab order) + aria-selected + the `.active` style hook. */
|
||||
* in the Tab order) + aria-selected + the `.active` style hook. `.shown`
|
||||
* marks a pane that is VISIBLE in a split cell without being the focused
|
||||
* one (aria-selected stays single — selection means focus). */
|
||||
_refreshTab(tab, pane) {
|
||||
const active = pane.id === this._activeId;
|
||||
tab.classList.toggle("active", active);
|
||||
tab.classList.toggle("shown", !active && !!this._leafFor(pane.id));
|
||||
tab.setAttribute("aria-selected", active ? "true" : "false");
|
||||
tab.tabIndex = active ? 0 : -1;
|
||||
}
|
||||
@@ -625,10 +1265,11 @@ export class PaneManager {
|
||||
if (p.meta != null) entry.meta = p.meta;
|
||||
return entry;
|
||||
});
|
||||
sessionStorage.setItem(
|
||||
this.storageKey,
|
||||
JSON.stringify({ order, active: this._activeId }),
|
||||
);
|
||||
const state = { order, active: this._activeId };
|
||||
// The split tree rides along (leaves reference pane ids, which are
|
||||
// deterministic type[:id] strings — rehydrate recomputes the same ones).
|
||||
if (this._layout) state.layout = this._serializeLayout(this._layout);
|
||||
sessionStorage.setItem(this.storageKey, JSON.stringify(state));
|
||||
} catch (e) {
|
||||
/* sessionStorage may be unavailable (private mode / disabled) — non-fatal */
|
||||
}
|
||||
@@ -660,6 +1301,9 @@ export class PaneManager {
|
||||
if (state.active && this._panes.has(state.active)) {
|
||||
this.activate(state.active);
|
||||
}
|
||||
// Layout LAST: every pane it references is open (or pruned), and the
|
||||
// active pane is settled — the restore just re-applies the cells.
|
||||
if (restored && state.layout) this._restoreLayout(state.layout);
|
||||
return restored;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,24 @@ export function glyph(state) {
|
||||
return el;
|
||||
}
|
||||
|
||||
/** A small count chip for a Manage row/group head — glyph + count (never colour
|
||||
* alone), DS warn vocabulary (shell.css `.rail-badge`). The count rides the
|
||||
* text; the ⚠ glyph is aria-hidden because the supplied `label` already names
|
||||
* the condition for assistive tech. Returns a detached span the caller mounts. */
|
||||
function badge(count, label) {
|
||||
const el = document.createElement("span");
|
||||
el.className = "rail-badge";
|
||||
const g = document.createElement("span");
|
||||
g.className = "rail-badge-glyph";
|
||||
g.setAttribute("aria-hidden", "true");
|
||||
g.textContent = "⚠"; // ⚠ — pairs the colour with a glyph (chip-contrast rule)
|
||||
const n = document.createElement("b");
|
||||
n.textContent = String(count);
|
||||
el.append(g, n);
|
||||
if (label) el.setAttribute("aria-label", label);
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Derive a node's overall state glyph from its workstream mix + health. */
|
||||
function nodeState(info) {
|
||||
if (!info.reachable) return "error";
|
||||
@@ -325,6 +343,80 @@ export function mountRail(sections, caps) {
|
||||
|
||||
// ---- Manage section (admin IA → collapsible discovery groups) --------------
|
||||
|
||||
// Generic Manage-row badge state. A subsystem (e.g. the standalone consent
|
||||
// badge) drives a count onto a tab row by KEY via `setRowBadge`; rail.js stays
|
||||
// agnostic about what the count means. Kept module-level so a `mountManage`
|
||||
// rebuild (the IA is static, but the section re-mounts on shell init) re-applies
|
||||
// the live counts rather than dropping them. `null` label = clear.
|
||||
const _rowBadges = new Map(); // tabKey -> { count, label }
|
||||
// Rebuilt each mount: the row <button> + owning group key for every tab. The
|
||||
// group's head <button> (where a collapsed group surfaces its summed badge —
|
||||
// a collapsed group hides its rows, so the head must carry the signal) is
|
||||
// looked up via _groupEls, not duplicated here.
|
||||
let _rowEls = new Map(); // tabKey -> { row, group }
|
||||
let _groupEls = new Map(); // group key -> { head, tabKeys: [] }
|
||||
|
||||
/** Paint (or clear) the badge slot inside a host button, keyed by a stable
|
||||
* `.rail-badge` child so repeated calls replace rather than stack. */
|
||||
function _applyBadge(host, count, label) {
|
||||
if (!host) return;
|
||||
const existing = host.querySelector(":scope > .rail-badge");
|
||||
if (!count) {
|
||||
if (existing) existing.remove();
|
||||
return;
|
||||
}
|
||||
const fresh = badge(count, label);
|
||||
if (existing) host.replaceChild(fresh, existing);
|
||||
else host.append(fresh);
|
||||
}
|
||||
|
||||
/** Sum the live badge counts for a group's tabs — drives the head badge so a
|
||||
* COLLAPSED group still shows that something inside it needs attention. */
|
||||
function _groupCount(groupKey) {
|
||||
const grp = _groupEls.get(groupKey);
|
||||
if (!grp) return 0;
|
||||
let total = 0;
|
||||
for (const tabKey of grp.tabKeys) {
|
||||
const b = _rowBadges.get(tabKey);
|
||||
if (b) total += b.count;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic Manage-row badge hook. `setRowBadge(tabKey, count, label?)` stamps a
|
||||
* count chip on that tab's row and, so the signal survives a collapsed group,
|
||||
* mirrors the group's total onto its head. `count` 0 (or falsy) clears the row.
|
||||
* Drives off the refs `mountManage` registered; a no-op before the first mount
|
||||
* (the consent subsystem re-drives it after `_refreshConsentBadge`).
|
||||
*
|
||||
* rail.js owns the MECHANISM only — callers own the meaning (no consent specifics
|
||||
* here), matching the rail's seam-driven posture.
|
||||
*/
|
||||
export function setRowBadge(tabKey, count, label) {
|
||||
const n = Number(count) || 0;
|
||||
if (n > 0) _rowBadges.set(tabKey, { count: n, label: label || "" });
|
||||
else _rowBadges.delete(tabKey);
|
||||
const ref = _rowEls.get(tabKey);
|
||||
if (!ref) return; // not mounted yet (or gated away) — state is kept for remount
|
||||
_applyBadge(ref.row, n, label);
|
||||
// The collapsed-group head mirrors the group's running total + its own label.
|
||||
const total = _groupCount(ref.group);
|
||||
const grp = _groupEls.get(ref.group);
|
||||
_applyBadge(
|
||||
grp && grp.head,
|
||||
total,
|
||||
total ? total + " in " + ref.group + " awaiting attention" : "",
|
||||
);
|
||||
}
|
||||
|
||||
/** Re-stamp every stored badge after a (re)mount rebuilt the row/head refs.
|
||||
* `setRowBadge` self-guards a missing ref and recomputes each head total, so
|
||||
* replaying the stored rows lands both rows and heads at their correct sums. */
|
||||
function _reapplyBadges() {
|
||||
for (const [tabKey, b] of _rowBadges) setRowBadge(tabKey, b.count, b.label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the rail's Manage groups from the admin IA seam (admin.js exposes
|
||||
* `window.TS_ADMIN`). Each group is a collapsible `.grp` whose head toggles
|
||||
@@ -374,6 +466,9 @@ export function mountManage(root, paneManager) {
|
||||
const activeTab = adminOpen && TS.getActiveTab ? TS.getActiveTab() : null;
|
||||
|
||||
const rowByTab = new Map(); // tab -> its row <button>, for active-state sync
|
||||
// Rebuild the badge ref maps for this mount (the previous DOM is gone).
|
||||
_rowEls = new Map();
|
||||
_groupEls = new Map();
|
||||
|
||||
ia.forEach((group) => {
|
||||
const tabs = group.tabs.filter((t) => allowed(t.tab));
|
||||
@@ -404,6 +499,8 @@ export function mountManage(root, paneManager) {
|
||||
count.className = "gcount";
|
||||
count.textContent = String(tabs.length);
|
||||
head.append(chev, name, count);
|
||||
// Register the head so a collapsed group can carry its tabs' badge total.
|
||||
_groupEls.set(group.group, { head, tabKeys: tabs.map((t) => t.tab) });
|
||||
|
||||
const items = document.createElement("div");
|
||||
items.className = "grp-items";
|
||||
@@ -421,6 +518,9 @@ export function mountManage(root, paneManager) {
|
||||
if (TS.openTab) TS.openTab(t.tab);
|
||||
});
|
||||
rowByTab.set(t.tab, row);
|
||||
// Register the row + its owning group for the badge hook (the group's
|
||||
// head element is resolved through _groupEls when needed).
|
||||
_rowEls.set(t.tab, { row, group: group.group });
|
||||
items.append(row);
|
||||
}
|
||||
|
||||
@@ -434,6 +534,9 @@ export function mountManage(root, paneManager) {
|
||||
root.append(grp);
|
||||
});
|
||||
|
||||
// Re-apply any live row badges a subsystem set before/across this (re)mount.
|
||||
_reapplyBadges();
|
||||
|
||||
// Single writer for the Manage active-row: the row for the current admin tab
|
||||
// carries `.active`. admin.js notifies on every switchAdminTab; seed it here
|
||||
// for an already-open (restored) Admin pane.
|
||||
|
||||
@@ -559,29 +559,54 @@
|
||||
.tab-caret:hover {
|
||||
background: var(--hair-2);
|
||||
}
|
||||
.tab-add {
|
||||
/* Split controls in the tab-bar tail (they replaced the redundant [+] — the
|
||||
permanent Dashboard tab IS the launcher). One ◫ glyph for both directions;
|
||||
the down variant rotates the glyph span, not the button (a rotated button
|
||||
would rotate its focus ring too). */
|
||||
.tb-split {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ink-4);
|
||||
/* --ink-3, not the chrome-ghost --ink-4: these are available CONTROLS and
|
||||
the [+] convention they replaced is gone — they must read as present. */
|
||||
color: var(--ink-3);
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
.tab-add:hover {
|
||||
.tb-split:hover {
|
||||
background: var(--panel-2);
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.tb-split:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.tb-split .tb-glyph {
|
||||
display: inline-block;
|
||||
/* greyscale AA — subpixel RGB rendering fringes these box-drawing glyphs
|
||||
with blue/amber bleed against the panel */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.tb-split--down .tb-glyph {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.tabbar-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--ink-4);
|
||||
/* hairline fence so the split cluster reads as one "layout" control group */
|
||||
border-left: 1px solid var(--hair);
|
||||
padding-left: 10px;
|
||||
}
|
||||
/* Drawer toggle + backdrop — desktop keeps the rail in the grid, so both are
|
||||
dormant here; the mobile block at the end of this sheet brings them up. */
|
||||
@@ -729,8 +754,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* pane host — ONE pane visible per tab (no split; the mock's 2-up was a
|
||||
display device only, so this is a single column, not `1fr 1fr`). */
|
||||
/* pane host — ONE pane visible per tab by default; the split view below can
|
||||
show several at once (PaneManager toggles .panes--split). */
|
||||
.panes {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
@@ -746,6 +771,189 @@
|
||||
.pane[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ===== Split view — visible panes become absolutely-positioned cells (inline
|
||||
% insets from PaneManager._applyLayout; % keeps window resizes free). The
|
||||
panes stay DIRECT children of .panes and are never reparented, so a live
|
||||
stream's DOM, scroll state and media playback ride out every layout change.
|
||||
Only `section.pane` (the ShellPane host) is targeted — the interactive
|
||||
pane's INNER div also carries .pane (the 5b lesson) and must not match. ===== */
|
||||
.panes--split {
|
||||
position: relative;
|
||||
}
|
||||
/* The pane is the containing block in BOTH modes: unpositioned, the per-pane
|
||||
✕ chip's absolute would resolve to the VIEWPORT (offsetParent <body>) and
|
||||
only coincidentally land near the pane corner. The split rule below wins
|
||||
on specificity while split. */
|
||||
.panes > section.pane {
|
||||
position: relative;
|
||||
}
|
||||
.panes--split > section.pane {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* the focused cell — only meaningful with 2+ cells. A quiet inset ring PLUS
|
||||
a 2px top accent bar (the rail's .row.open::before vocabulary): the bar is
|
||||
the load-bearing cue — designer-measured, no thin tinted ring clears WCAG
|
||||
1.4.11's 3:1 on the light theme, and 38%-mix was ~invisible on dark.
|
||||
The ring lives on an ::after OVERLAY, not the section's own box-shadow: an
|
||||
inset shadow paints in the element's background layer, UNDER any opaque
|
||||
child touching the edge — the status bar / composer strip occluded it (the
|
||||
reported paint bug). The overlay sits above pane content (a coordinator
|
||||
overlay label uses z:10) and stays click-transparent. */
|
||||
.panes--split > section.pane.split-focused::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
box-shadow: inset 0 0 0 1px
|
||||
color-mix(in srgb, var(--accent) 55%, var(--hair-2));
|
||||
z-index: 12;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* light needs a hotter mix: 55% measured 2.60:1 there (sub-3:1); 75% clears.
|
||||
Dark stays 55% (3.75:1) — hotter reads as a heavy border on dark. */
|
||||
[data-theme="light"] .panes--split > section.pane.split-focused::after {
|
||||
box-shadow: inset 0 0 0 1px
|
||||
color-mix(in srgb, var(--accent) 75%, var(--hair-2));
|
||||
}
|
||||
.panes--split > section.pane.split-focused::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
/* 1px in from the cell sides so the bar never butts a separator line into
|
||||
one doubled-accent stripe at the T-junction */
|
||||
inset: 0 1px auto 1px;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
z-index: 13; /* above the ring overlay — a clean bar, not bar-plus-ring-line */
|
||||
pointer-events: none;
|
||||
}
|
||||
/* per-pane ✕ — on every visible pane. Split mode: "hide this cell" (the TAB
|
||||
stays — closeCell); single-pane: "close pane" (withheld from the unclosable
|
||||
Dashboard). Shell chrome floating over pane content: elevated panel +
|
||||
hairline so it reads as the shell's, not the conversation's; sits clear of
|
||||
the 2px focus bar and the cell corner. */
|
||||
.cell-unsplit {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 14px; /* clear of the message scroller's scrollbar gutter */
|
||||
z-index: 14;
|
||||
/* 28px: WCAG 2.5.8's 24px floor + a hair (22px under-shot it) */
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* --hair-2 measured ~1.3:1 against the chip bg — an invisible boundary;
|
||||
--ink-4 clears 3:1 in both themes */
|
||||
border: 1px solid var(--ink-4);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--panel-2);
|
||||
color: var(--ink-3);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.cell-unsplit:hover {
|
||||
opacity: 1;
|
||||
color: var(--ink);
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
/* the destructive mode (single-pane "Close pane" ✕) telegraphs BEFORE the
|
||||
click lands — split-mode muscle memory must not fire it blind */
|
||||
.cell-unsplit--close:hover {
|
||||
color: var(--err);
|
||||
border-color: var(--err);
|
||||
}
|
||||
.cell-unsplit:focus-visible {
|
||||
opacity: 1;
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.cell-unsplit--close:focus-visible {
|
||||
outline-color: var(--err);
|
||||
}
|
||||
/* light resting glyph at .85 sat right at the 3:1 floor with --ink-3 — one
|
||||
ink step restores headroom (~4.5:1) without making the chip shout */
|
||||
[data-theme="light"] .cell-unsplit {
|
||||
color: var(--ink-2);
|
||||
}
|
||||
/* separator — a 7px hit area straddling the cell boundary with a 1px visual
|
||||
line; drag (pointer capture) or arrow keys resize, see pane.js _wireHandle.
|
||||
Resting line is --ink-4, the lightest token clearing 3:1 in BOTH themes
|
||||
(--hair measured ~1.3:1 — an undiscoverable drag target); hover/drag goes
|
||||
SOLID accent and thickens (--accent-dim was a luminance DROP on hover). */
|
||||
.split-handle {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
}
|
||||
.split-handle--row {
|
||||
width: 7px;
|
||||
cursor: col-resize;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.split-handle--col {
|
||||
height: 7px;
|
||||
cursor: row-resize;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.split-handle::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background: var(--ink-4);
|
||||
}
|
||||
.split-handle--row::after {
|
||||
left: 3px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
}
|
||||
.split-handle--col::after {
|
||||
top: 3px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
}
|
||||
.split-handle:hover::after,
|
||||
.split-handle.dragging::after,
|
||||
.split-handle:focus-visible::after {
|
||||
background: var(--accent);
|
||||
}
|
||||
.split-handle:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.split-handle--row:hover::after,
|
||||
.split-handle--row.dragging::after,
|
||||
.split-handle--row:focus-visible::after {
|
||||
left: 2px;
|
||||
width: 3px;
|
||||
}
|
||||
.split-handle--col:hover::after,
|
||||
.split-handle--col.dragging::after,
|
||||
.split-handle--col:focus-visible::after {
|
||||
top: 2px;
|
||||
height: 3px;
|
||||
}
|
||||
/* a visible-but-unfocused pane's tab — the three tab states must each read:
|
||||
rest (bare) → shown (accent UNDERLINE — a shape, not a fill delta; rhymes
|
||||
with the focused cell's TOP bar) → active (panel fill). Designer-measured:
|
||||
a border/fill-only delta between shown and active was ≤1.1:1. */
|
||||
.tab.shown:not(.active) {
|
||||
border-color: var(--hair-2);
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.tab.shown:not(.active)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 2px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
/* 80%: 55% composited to 2.34:1 on the light --panel (sub-3:1 for a
|
||||
load-bearing state mark); 80% = ~3.7:1 light / ~5:1 dark */
|
||||
background: color-mix(in srgb, var(--accent) 80%, transparent);
|
||||
}
|
||||
.pane-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -997,6 +1205,38 @@
|
||||
box-shadow: inset 2px 0 0 var(--ink-4);
|
||||
}
|
||||
|
||||
/* Manage-row attention badge (rail.js `setRowBadge` / `badge`) — a small count
|
||||
chip pinned to the row tail (and to a COLLAPSED group's head, so a hidden row
|
||||
never hides the signal). DS warn vocabulary: the ⚠ glyph PLUS the tinted
|
||||
chip carry the meaning (never colour alone), so it reads at chip size and
|
||||
flips themes by construction (the --warn family is defined per theme). */
|
||||
.rail-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-left: auto; /* push to the row/head tail past the flex-1 label */
|
||||
padding: 0 5px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--warn-tint);
|
||||
border: 1px solid var(--warn-tint-border);
|
||||
color: var(--warn);
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex: none;
|
||||
}
|
||||
/* The group head already auto-spaces with its own gap, and its --warn count
|
||||
should not fight the dim --ink-4 tab count beside it. */
|
||||
.grp-head .rail-badge {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.rail-badge .rail-badge-glyph {
|
||||
font-weight: 700;
|
||||
}
|
||||
.rail-badge b {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ===== Admin pane — adopts #view-admin (the 18 tabpanels). The in-pane
|
||||
sidebar is retired (the rail's Manage groups navigate), so #view-admin fills
|
||||
the pane body and the content host renders full-width. ===== */
|
||||
@@ -1187,6 +1427,16 @@
|
||||
.tab {
|
||||
max-width: 48vw;
|
||||
}
|
||||
/* splits are a desktop affordance (a phone viewport is below the cell
|
||||
minimums before the first divider lands) — and with the buttons gone the
|
||||
tail's group fence has nothing to fence */
|
||||
.tb-split {
|
||||
display: none;
|
||||
}
|
||||
.tabbar-right {
|
||||
border-left: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rail {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
========================================================================== */
|
||||
|
||||
import { PaneManager, ShellPane, openPopupMenu } from "./pane.js";
|
||||
import { mountRail, mountManage, glyph } from "./rail.js";
|
||||
import { mountRail, mountManage, glyph, setRowBadge } from "./rail.js";
|
||||
import { authFetch } from "./auth.js";
|
||||
// The interactive pane is a real ES module beside us in /shared (step 5a) — the
|
||||
// shell imports it directly, and it exists in every deployment. The coordinator
|
||||
@@ -510,26 +510,45 @@ async function mountShell() {
|
||||
});
|
||||
pm.onActiveChange(() => setDrawer(false));
|
||||
|
||||
// [+] new-tab (step 7): a shortcut to the persona launcher. The Dashboard pane
|
||||
// hosts the unified coordinator/interactive launcher (a new session needs a task
|
||||
// prompt, so it composes there) — "new session" focuses it. showHome is exposed
|
||||
// by both deployments; openPane is the fallback. Lives in the right-floated tail
|
||||
// slot per the brief. (Auth is the launcher's own concern — it gates each
|
||||
// persona option; focusing it is always safe.)
|
||||
const addTab = make("button", "tab-add");
|
||||
addTab.type = "button";
|
||||
addTab.setAttribute("aria-label", "New session");
|
||||
addTab.title = "New session";
|
||||
addTab.textContent = "+";
|
||||
addTab.addEventListener("click", () => {
|
||||
if (typeof window.showHome === "function") window.showHome();
|
||||
else pm.openPane("dashboard");
|
||||
// Land in the launcher composer so "new session" is immediately typeable —
|
||||
// showHome on the already-active Dashboard is otherwise a no-op.
|
||||
if (window.TS_APP && typeof window.TS_APP.focusLauncher === "function")
|
||||
window.TS_APP.focusLauncher();
|
||||
});
|
||||
shell.tail.append(addTab);
|
||||
// Split controls (the revived split-view): they act on the FOCUSED pane.
|
||||
// Split right / split down open a second cell beside/below it, filled with
|
||||
// the most-recently-used backgrounded tab; Unsplit returns to one pane and
|
||||
// only shows while split. They replaced the old [+] new-session button —
|
||||
// the permanent Dashboard tab IS the launcher, so [+] duplicated one click.
|
||||
// Deliberately NO contextmenu override anywhere (the pre-L-shell split UI
|
||||
// hijacked right-click): these buttons are the whole affordance surface.
|
||||
const tbBtn = (cls, glyph, label) => {
|
||||
const b = make("button", cls);
|
||||
b.type = "button";
|
||||
b.setAttribute("aria-label", label);
|
||||
b.title = label;
|
||||
const g = make("span", "tb-glyph", glyph);
|
||||
g.setAttribute("aria-hidden", "true"); // the button's aria-label speaks
|
||||
b.append(g);
|
||||
return b;
|
||||
};
|
||||
// Denials surface as a toast (space / pane-limit / nothing to show) — the
|
||||
// manager stays chrome-free and just returns the reason.
|
||||
const splitFeedback = (r) => {
|
||||
if (r && !r.ok && r.reason && typeof window.showToast === "function")
|
||||
window.showToast(r.reason, "warning");
|
||||
};
|
||||
const splitRightBtn = tbBtn("tb-split", "◫", "Split right");
|
||||
splitRightBtn.addEventListener("click", () =>
|
||||
splitFeedback(pm.splitFocused("right")),
|
||||
);
|
||||
const splitDownBtn = tbBtn("tb-split tb-split--down", "◫", "Split down");
|
||||
splitDownBtn.addEventListener("click", () =>
|
||||
splitFeedback(pm.splitFocused("down")),
|
||||
);
|
||||
const unsplitBtn = tbBtn("tb-split", "□", "Unsplit — keep the focused pane");
|
||||
unsplitBtn.addEventListener("click", () => pm.unsplit());
|
||||
const syncSplitControls = () => {
|
||||
unsplitBtn.hidden = !pm.isSplit();
|
||||
};
|
||||
pm.onActiveChange(syncSplitControls);
|
||||
syncSplitControls();
|
||||
shell.tail.append(splitRightBtn, splitDownBtn, unsplitBtn);
|
||||
|
||||
// Dashboard pane (step 1): a singleton that ADOPTS the legacy #main so the
|
||||
// console renders unchanged inside the new shell. Real pane types (admin,
|
||||
@@ -873,14 +892,21 @@ async function mountShell() {
|
||||
}
|
||||
}
|
||||
|
||||
// Tier-1 lifecycle → pane signal. The console's ws_closed handler calls this
|
||||
// so an open pane on that session stops its reconnect loop NOW (instead of
|
||||
// 404-polling a session that is gone) and shows the reconnect affordance.
|
||||
// Tier-1 lifecycle → pane signal. The console's ws_closed handler calls
|
||||
// this so an open pane on a CLOSED session closes outright — tab gone, a
|
||||
// split cell collapses onto its sibling. This is the coordinator-closes-
|
||||
// its-child flow (and matches the standalone's pane-auto-close); the dead-
|
||||
// BANNER lane stays for streams that die WITHOUT a ws_closed (node crash,
|
||||
// network) where the session may still be revivable.
|
||||
const notifySessionClosed = (wsId) => {
|
||||
const p = pm.getPane("interactive", wsId);
|
||||
if (p && p._ctl && p._ctl.markDead) p._ctl.markDead();
|
||||
if (p) pm.close(p.id);
|
||||
};
|
||||
window.TS_SHELL = { panes: pm, caps, notifySessionClosed };
|
||||
// `setRowBadge` lets a classic-script subsystem (the standalone consent badge
|
||||
// in ui/static/app.js) stamp a count chip on a Manage row without importing the
|
||||
// ESM rail module — the shell is its module bridge. Generic: the rail owns the
|
||||
// chip mechanism, the caller owns what the count means.
|
||||
window.TS_SHELL = { panes: pm, caps, notifySessionClosed, setRowBadge };
|
||||
|
||||
// Login fan-out: app.js owns the single window.onLoginSuccess (the Tier-1
|
||||
// reconnect, set at load). Wrap it in a tiny registry so EVERY conversational
|
||||
|
||||
+29
-163
@@ -1473,17 +1473,19 @@ function connectGlobalSSE() {
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 12. MCP consent badge (standalone settings-gear pending-consent indicator)
|
||||
// 12. MCP consent badge (standalone pending-consent indicator)
|
||||
//
|
||||
// The tool-output / media / MCP-error / verdict renderers that used to live in
|
||||
// this section moved to shared_static/interactive.js with the Pane. What
|
||||
// stays here is the standalone consent-badge subsystem: the gear badge lives
|
||||
// in this shell's header, so the pane only notifies it (host.onConsentDetected
|
||||
// -> _onConsentDetected) and the dashboard hydrates it via loadPendingConsents.
|
||||
// stays here is the standalone consent-badge subsystem: it owns the pending set
|
||||
// and drives the rail's Manage > Connections row badge (via the TS_SHELL bridge
|
||||
// — `setRowBadge`). An interactive pane only NOTIFIES it (the shared host
|
||||
// bridges `onConsentDetected` to the TS_APP seam below); `loadPendingConsents`
|
||||
// hydrates it on boot. The settings-gear it used to hang on is retired.
|
||||
// ===========================================================================
|
||||
|
||||
// Module-level set of servers with an unresolved consent prompt; drives the
|
||||
// gear-icon badge so the user has a stable signal that re-consent is pending
|
||||
// Manage-row badge so the user has a stable signal that re-consent is pending
|
||||
// after the inline card scrolls out of view.
|
||||
const _pendingConsentServers = new Set();
|
||||
|
||||
@@ -1528,32 +1530,26 @@ function loadPendingConsents() {
|
||||
});
|
||||
}
|
||||
|
||||
// The Manage tab the pending-consent badge rides on. The standalone's Manage IA
|
||||
// (TS_ADMIN.ia, below) is a single Extensions > Connections tab where MCP server
|
||||
// connections live; the badge surfaces there (and, when that group is collapsed,
|
||||
// on its head — the rail handles that). The retired settings-gear it used to
|
||||
// hang on is gone with the L-shell renovation.
|
||||
const _CONSENT_BADGE_TAB = "connections";
|
||||
|
||||
function _refreshConsentBadge() {
|
||||
const btn = document.getElementById("settings-btn");
|
||||
if (!btn) return;
|
||||
let existing = btn.querySelector(".settings-consent-badge");
|
||||
const n = _pendingConsentServers.size;
|
||||
// Keep the visible badge and the accessible name in lockstep so screen-
|
||||
// reader users get the same pending-consent signal that sighted users
|
||||
// get from the red dot. The badge itself stays aria-hidden because the
|
||||
// count is already reflected in the button's aria-label/title.
|
||||
if (n === 0) {
|
||||
if (existing) existing.remove();
|
||||
btn.setAttribute("aria-label", "Settings");
|
||||
btn.setAttribute("title", "Settings");
|
||||
return;
|
||||
}
|
||||
if (!existing) {
|
||||
existing = document.createElement("span");
|
||||
existing.className = "settings-consent-badge";
|
||||
existing.setAttribute("aria-hidden", "true");
|
||||
btn.appendChild(existing);
|
||||
}
|
||||
existing.textContent = String(n);
|
||||
// Drive the rail's generic Manage-row badge through the shell bridge (classic
|
||||
// app.js can't import the ESM rail module). The chip's own ⚠ glyph + count
|
||||
// carry the signal; `label` keeps the accessible name in lockstep. A no-op
|
||||
// before the rail mounts — `loadPendingConsents` re-drives it after boot.
|
||||
const shell = window.TS_SHELL;
|
||||
if (!shell || typeof shell.setRowBadge !== "function") return;
|
||||
const label =
|
||||
"Settings (" + n + " MCP consent" + (n === 1 ? "" : "s") + " pending)";
|
||||
btn.setAttribute("aria-label", label);
|
||||
btn.setAttribute("title", label);
|
||||
n === 0
|
||||
? ""
|
||||
: n + " MCP server" + (n === 1 ? "" : "s") + " awaiting consent";
|
||||
shell.setRowBadge(_CONSENT_BADGE_TAB, n, label);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1569,141 +1565,6 @@ function _refreshConsentBadge() {
|
||||
* Render the action card for an MCP error envelope. Mirrors the
|
||||
* media-embed pattern: visible card on top, collapsible raw JSON below.
|
||||
*/
|
||||
// ---------------------------------------------------------------------------
|
||||
// HLS lazy-loader (follows the mermaid.js lazy-load pattern in
|
||||
// /shared/renderer.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
let _hlsState = "idle";
|
||||
let _hlsQueue = [];
|
||||
|
||||
function _loadHls(callback) {
|
||||
if (_hlsState === "ready") {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
_hlsQueue.push(callback);
|
||||
if (_hlsState === "loading") return;
|
||||
_hlsState = "loading";
|
||||
const script = document.createElement("script");
|
||||
script.src = "/shared/hls-1.6.16/hls.min.js";
|
||||
script.onload = function () {
|
||||
_hlsState = "ready";
|
||||
const q = _hlsQueue;
|
||||
_hlsQueue = [];
|
||||
for (let i = 0; i < q.length; i++) q[i]();
|
||||
};
|
||||
script.onerror = function () {
|
||||
_hlsState = "idle";
|
||||
const q = _hlsQueue;
|
||||
_hlsQueue = [];
|
||||
// Fall through — _activatePlayer will use stream_url since Hls is undefined
|
||||
for (let i = 0; i < q.length; i++) q[i]();
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
function _isHlsUrl(url) {
|
||||
return typeof url === "string" && /\.m3u8(\?|$)/i.test(url);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Click-to-play delegated handler (follows img-placeholder pattern)
|
||||
// ---------------------------------------------------------------------------
|
||||
function _activatePlayer(btn) {
|
||||
const url = btn.dataset.streamUrl;
|
||||
const hlsUrl = btn.dataset.hlsUrl;
|
||||
const isAudio = btn.dataset.audioOnly === "true";
|
||||
const directStream = btn.dataset.directStream === "true";
|
||||
|
||||
const player = document.createElement(isAudio ? "audio" : "video");
|
||||
player.controls = true;
|
||||
player.autoplay = true;
|
||||
player.className = "media-player";
|
||||
|
||||
// Prefer direct stream when the source supports it; fall back to HLS
|
||||
// only when transcoding is needed.
|
||||
if (directStream && url) {
|
||||
player.src = url;
|
||||
} else if (
|
||||
hlsUrl &&
|
||||
!isAudio &&
|
||||
typeof Hls !== "undefined" &&
|
||||
Hls.isSupported()
|
||||
) {
|
||||
const hls = new Hls();
|
||||
hls.loadSource(hlsUrl);
|
||||
hls.attachMedia(player);
|
||||
} else if (
|
||||
hlsUrl &&
|
||||
!isAudio &&
|
||||
player.canPlayType("application/vnd.apple.mpegurl")
|
||||
) {
|
||||
player.src = hlsUrl;
|
||||
} else {
|
||||
player.src = url;
|
||||
}
|
||||
|
||||
player.addEventListener("error", function () {
|
||||
const card = player.closest(".media-embed");
|
||||
const titleEl = card ? card.querySelector(".media-card-title") : null;
|
||||
const label = titleEl ? ": " + titleEl.textContent : "";
|
||||
|
||||
const err = document.createElement("div");
|
||||
err.className = "media-player-error";
|
||||
err.setAttribute("role", "alert");
|
||||
err.textContent = "Failed to load stream" + label;
|
||||
|
||||
const retry = document.createElement("button");
|
||||
retry.className = "media-play-btn";
|
||||
retry.type = "button";
|
||||
retry.dataset.streamUrl = url;
|
||||
retry.dataset.hlsUrl = hlsUrl || "";
|
||||
retry.dataset.audioOnly = String(isAudio);
|
||||
retry.dataset.directStream = String(directStream);
|
||||
retry.setAttribute("aria-label", "Retry" + label);
|
||||
retry.appendChild(document.createTextNode("\u25b6 Retry"));
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.appendChild(err);
|
||||
container.appendChild(retry);
|
||||
player.replaceWith(container);
|
||||
});
|
||||
|
||||
btn.replaceWith(player);
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (e) {
|
||||
const btn = e.target.closest(".media-play-btn");
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
btn.disabled = true;
|
||||
const labelEl = btn.querySelector("span:last-child");
|
||||
if (labelEl) {
|
||||
labelEl.textContent = "Loading\u2026";
|
||||
} else {
|
||||
btn.textContent = "\u25b6 Loading\u2026";
|
||||
}
|
||||
|
||||
const hlsUrl = btn.dataset.hlsUrl;
|
||||
const isAudio = btn.dataset.audioOnly === "true";
|
||||
|
||||
// If HLS URL present and not audio, ensure hls.js is loaded first
|
||||
if (hlsUrl && !isAudio && _isHlsUrl(hlsUrl)) {
|
||||
_loadHls(function () {
|
||||
_activatePlayer(btn);
|
||||
});
|
||||
} else {
|
||||
_activatePlayer(btn);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key !== "Enter") return;
|
||||
const btn = e.target.closest(".media-play-btn");
|
||||
if (!btn) return;
|
||||
btn.click();
|
||||
});
|
||||
|
||||
function _announce(text) {
|
||||
const el = document.getElementById("toast");
|
||||
if (!el) return;
|
||||
@@ -2365,6 +2226,11 @@ window.TS_APP.onRender = function (cb) {
|
||||
};
|
||||
window.TS_APP.bucketByParent = bucketByParent;
|
||||
window.TS_APP.boot = boot;
|
||||
// Live MCP-consent notifications from an interactive pane (the shared pane host
|
||||
// bridges its `onConsentDetected` here when this seam exists; the console leaves
|
||||
// it undefined, so the pane no-ops there). Adds the server to the pending set
|
||||
// and re-paints the Manage-row badge.
|
||||
window.TS_APP.onConsentDetected = _onConsentDetected;
|
||||
|
||||
// --- Manage seam: one Connections tab (MCP server connections) -------------
|
||||
const _CONN_IA = [
|
||||
|
||||
@@ -1822,16 +1822,3 @@ audio.media-player {
|
||||
.settings-revoke-btn:hover {
|
||||
background: rgba(255, 0, 0, 0.05);
|
||||
}
|
||||
.settings-consent-badge {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
background: var(--red);
|
||||
color: var(--bg);
|
||||
border-radius: 8px;
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
vertical-align: top;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
@@ -2324,7 +2324,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "turnstone"
|
||||
version = "1.6.0"
|
||||
version = "1.6.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
@@ -2385,7 +2385,7 @@ requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.28" },
|
||||
{ name = "httpx-sse", specifier = ">=0.4" },
|
||||
{ name = "lacme", specifier = ">=1.0.5" },
|
||||
{ name = "mcp", specifier = ">=1.27" },
|
||||
{ name = "mcp", specifier = ">=1.27,<2" },
|
||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14" },
|
||||
{ name = "openai", specifier = ">=2.37" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
|
||||
|
||||
Reference in New Issue
Block a user