mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
480a1426b3
* fix(session): fail-closed history-commit handoff (#981) The deleted-workstream discovery is now a terminal, ws_id-keyed latch: keyed conversation commits refuse admission once the durable parent is gone (convergence finalizers and force-abandon are exempt), history handoff refuses to mint a proof token so /history fails closed with a 503 instead of silently wiping the pane, and the SSE stream carries a workstream_gone resync reason. Discarded commits leave a forensic log of commit keys and roles, never content. Conversation rows gain a commit_key (migration 071): keyed saves are idempotent under retry, validated against the full commit identity, and refused when they would cross a workstream deletion. The prune orphan category now requires a NULL alias plus a two-hour updated grace, with cutoffs computed at discovery time and carried into both dialects' rechecks. The mid-turn interjection queue is owner-partitioned with no per-site mode flags: pops take the acting principal's and unowned rows, other participants' rows are structurally retained, and enforcement lives at queue admission plus the shared before_spawn gates. The retraction ledger is bounded by open pop windows: pops open a window atomically with the queue delete, restores close their ids atomically with the ledger consume, every other exit closes through one helper, and misses for unheld ids record nothing. The workstream-gone latch refuses unattended wakes at all three gates (watcher spawn, claim, delivery pre-pop), and the retry dispatcher regained its pre-envelope cancel/error convergence net. Persistence-state reporting derives through the session bound to each UI instead of a registry lookup by id that failed open to healthy during tombstone retention. The dashboard roster no longer re-inserts ghost entries from trailing activity events, the history tool-outcome scan tolerates interleaved non-turn rows, and the shared handoff-deadline handle owns its own retirement. Single-sourced across call sites: keyed-commit row values, attachment save wrappers, tail-truncation and conflict-resolution bodies for both storage dialects; worker-slot lifecycle field sets; the direct-commit admission frame; queued-row layout accessors; the string-aware comment stripper shared by every JS harness suite. Refs #981 #964 * fix(session): sweep handoff fixes to their sibling surfaces The interactive replay loop treated a system row as a tool-batch boundary, so every tool result after an interleaved row vanished from that pane while the coordinator rendered the same history correctly. Only a conversational turn ends the batch window now, matching the shared outcome index. Accepted user turns clear the composer's attachment chips on the same viewer policy that settles optimistic bubbles rather than on having matched a local bubble, so a workstream created with an upload no longer keeps a chip for an attachment the create dispatch already consumed. The coordinator's raced-Stop arm emits the stream-end hook it inherits alongside the idle state, leaving no unfinalized bubble or unflushed tool output. Ending a session surfaces a failure toast when the request never lands or answers with a non-JSON body. The per-second persistence reconcile now probes each session without blocking: a workstream whose generation and handoff locks are held is skipped until the next pass instead of contending the locks every commit needs. The one-shot repair that gates workstream creation at capacity keeps a definite probe — it has no next pass, and the sessions likeliest to be contended are the ones whose unresolved journals emptied its candidate list. Single-sourced: the attachment lane builds its conversation row through the shared commit-identity builder; the ordinary worker exit releases its slot through the lifecycle owner; both operator surfaces snapshot their counters through one non-consuming helper; the replay preamble loses its per-kind wrappers and its config hook; the browser harness suites share one brace walker; and each in-flight history attempt is one record carrying both its abort controller and its deadline. Refs #981 #964
1512 lines
76 KiB
Python
1512 lines
76 KiB
Python
"""Tests for the /coordinator/{ws_id} HTML page handler.
|
|
|
|
The handler serves the shared template with the ws_id injected as a
|
|
``data-ws-id`` attribute. It does NOT enforce auth on the page itself —
|
|
auth gating happens on the API endpoints the page calls (an unauthenticated
|
|
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
|
|
from starlette.testclient import TestClient
|
|
|
|
from tests._js_harness_helpers import strip_js_comments as _strip_comments
|
|
from turnstone.console.server import coordinator_page
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
app = Starlette(routes=[Route("/coordinator/{ws_id}", coordinator_page, methods=["GET"])])
|
|
return TestClient(app)
|
|
|
|
|
|
def test_valid_ws_id_injects_data_attr(client):
|
|
ws_id = "a" * 32
|
|
resp = client.get(f"/coordinator/{ws_id}")
|
|
assert resp.status_code == 200
|
|
assert "text/html" in resp.headers["content-type"]
|
|
body = resp.text
|
|
# ws_id is injected into the html data-ws-id attribute.
|
|
assert f'data-ws-id="{ws_id}"' in body
|
|
# Template placeholder is fully substituted.
|
|
assert "{{WS_ID}}" not in body
|
|
# Sanity: the shared static imports are wired.
|
|
assert "/shared/base.css" in body
|
|
assert "/static/coordinator/coordinator.js" in body
|
|
|
|
|
|
def test_non_hex_ws_id_returns_400(client):
|
|
"""Only hex chars are allowed to avoid HTML injection."""
|
|
resp = client.get("/coordinator/not-hex-chars-here")
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_ws_id_too_long_returns_400(client):
|
|
resp = client.get("/coordinator/" + "a" * 65)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_uppercase_hex_rejected(client):
|
|
# Our ws_ids are lowercase hex; reject mixed/upper to avoid surprises.
|
|
resp = client.get("/coordinator/" + "A" * 32)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_coordinator_js_exposes_inline_approval_helpers():
|
|
"""Smoke guard for two layers of the coord chat frontend: the
|
|
children-tree inline approve/deny block (the original Chunk 3
|
|
landing) and the PR #447 tool-batch construct that replaced the
|
|
pinned approval dock for the coord-self surface. Both layers'
|
|
helper symbols must remain reachable in the served JS so a
|
|
refactor that accidentally renames or removes them surfaces here
|
|
instead of in production where the affected gates silently stop
|
|
rendering. Asserts string presence only — no DOM parsing —
|
|
since coord.js has no JS test framework today (per the plan's
|
|
testing notes)."""
|
|
from pathlib import Path
|
|
|
|
coord_js = Path(__file__).resolve().parent.parent / (
|
|
"turnstone/console/static/coordinator/coordinator.js"
|
|
)
|
|
body = coord_js.read_text(encoding="utf-8")
|
|
# Approval-block rendering helpers
|
|
assert "function renderApprovalBlock" in body
|
|
assert "maxSeverityItem," in body # imported from conversation.js (5e.1b)
|
|
assert "function _renderSubItem" in body
|
|
# The submit + 409 race-handling path
|
|
assert "function submitChildApproval" in body or "submitChildApproval(" in body
|
|
# The shared approve POST helper (parameterized for child ws_ids)
|
|
assert "function approveWorkstream" in body or "approveWorkstream(" in body
|
|
# The 409 stale-call_id retry path uses invalidateLiveBadge +
|
|
# scheduleLiveFetch (Stage 3 cleanup removed the urgent flag —
|
|
# cache invalidation makes the TTL gate fall through naturally).
|
|
assert "invalidateLiveBadge(targetWsId)" in body
|
|
# Server-side payload field — drift here means the JS reads stale keys
|
|
assert "pending_approval_detail" in body
|
|
# Reconnect parity (chunk 4): the SSE re-open handler must drop
|
|
# non-permanent entries from the live-badge cache so a stale
|
|
# pending_approval_detail (left from before the disconnect)
|
|
# can't render zombie approve/deny buttons on a row whose
|
|
# approval was resolved during the gap. The implementation
|
|
# iterates the cache and deletes only !permanent entries —
|
|
# asserting the literal helper call keeps a refactor back to
|
|
# _liveBadgeCacheClear() (which would re-pay 403s on every
|
|
# reconnect for denied ids) from sneaking in.
|
|
assert "_liveBadgeCacheDelete" in body
|
|
# Edge-case matrix sentinel labels — POLICY-BLOCKED renders when
|
|
# an item has error set + needs_approval=False (server-side
|
|
# tool policy already blocked the call); "(judge unavailable)"
|
|
# renders when no verdict (judge or heuristic) and no
|
|
# judge_pending. Refactors that drop either branch silently
|
|
# regress to a buttoned approve UI on the wrong state.
|
|
assert "POLICY-BLOCKED" in body
|
|
assert "judge unavailable" in body
|
|
# Critical-risk handling: bug-1 was that risk_level='critical' rendered as
|
|
# low because the old severity table only mapped 'crit'. The crit/critical
|
|
# alias moved to the shared conversation.js (step 5e.1b); verify it there so
|
|
# a 'critical' verdict still ranks like 'crit'.
|
|
shared = Path(__file__).resolve().parent.parent / ("turnstone/shared_static/conversation.js")
|
|
assert 'crit: "critical"' in shared.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_coordinator_js_approval_keyboard_shortcuts():
|
|
"""Step 7 designer P2 (the console twin of the interactive.js fix): a pending
|
|
tool-batch's kbd hints (Enter approve / D deny / Shift+A approve-all) must
|
|
actually fire. Pane-owned keydown on `root` routing to _resolveBatchAction
|
|
via _currentPendingBatch (the last un-resolved pending batch), with a focus
|
|
guard (don't hijack composer typing) and the disabled-button double-fire guard.
|
|
Asserts string presence only (no JS framework for coord.js)."""
|
|
from pathlib import Path
|
|
|
|
body = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "turnstone/console/static/coordinator/coordinator.js"
|
|
).read_text(encoding="utf-8")
|
|
assert 'root.addEventListener("keydown"' in body, (
|
|
"the approval shortcuts must be a pane-owned keydown on root"
|
|
)
|
|
assert "function _currentPendingBatch()" in body, (
|
|
"the keydown must resolve the current pending batch (not a stale/resolved one)"
|
|
)
|
|
# The double-fire guard: skip a batch whose actions are already disabled.
|
|
assert "btn.disabled) continue" in body
|
|
# Routes the three verbs to the existing resolve path.
|
|
assert "_resolveBatchAction(batch, true, false)" in body # Enter -> approve
|
|
assert "_resolveBatchAction(batch, false, false)" in body # D/Esc -> deny
|
|
assert "_resolveBatchAction(batch, true, true)" in body # Shift+A -> approve-all
|
|
# Focus guard so the keys never hijack composer/input typing.
|
|
assert 'ae.tagName === "TEXTAREA"' in body and "ae.isContentEditable" in body
|
|
# Child approves must round-trip through the routing proxy at
|
|
# /v1/api/route/workstreams/{ws_id}/approve — the bare
|
|
# /v1/api/workstreams/.../approve path only works for the
|
|
# coord-self ws_id (the coord lives on the console process).
|
|
# Children live on cluster nodes and 404 without the prefix.
|
|
assert "/v1/api/route/workstreams/" in body
|
|
# Late-arriving LLM judge verdicts — Stage 3 Step 5 promoted
|
|
# ``intent_verdict`` and ``approval_resolved`` to first-class
|
|
# cluster-bus event types, so the coord adapter dispatches them
|
|
# as ``child_ws_intent_verdict`` / ``child_ws_approval_resolved``
|
|
# on the parent's SSE stream. The browser handlers write
|
|
# directly to liveBadgeCache (bypassing scheduleLiveFetch's
|
|
# visibility gate cleanly) so off-screen rows pick up verdicts
|
|
# without polling. Replaced the old ``_judgePollTick`` 90-second
|
|
# global poll loop and its visibility-gate-bypass workaround.
|
|
assert "handleChildIntentVerdict" in body
|
|
assert "handleChildApprovalResolved" in body
|
|
assert "child_ws_intent_verdict" in body
|
|
assert "child_ws_approval_resolved" in body
|
|
# Reload parity for the coord-self approval gate: init() must
|
|
# consume the authoritative GET /workstreams snapshot's
|
|
# pending_approval_detail so a freshly opened tab can render
|
|
# Approve/Deny before SSE replay arrives.
|
|
assert "wsSnapshot.pending_approval_detail" in body
|
|
assert "appendToolBatch(pendingDetail.items" in body
|
|
# Tool-batch construct (PR #447) — the inline replacement for the
|
|
# pinned approval-dock pattern. These helpers carry the
|
|
# state-machine that pairs each tool call with its result and
|
|
# embeds the approval flow. Refactors that rename or drop them
|
|
# silently regress the entire coord-self approval surface — the
|
|
# most novel and risky behavior in the PR.
|
|
assert "function appendToolBatch" in body
|
|
assert "function _morphBatchResolved" in body
|
|
assert "function _resolveBatchAction" in body
|
|
assert "function _refreshBatchTier" in body
|
|
assert "function _refreshRowStatus" in body
|
|
# State modifiers driven by the upgrade-in-place path
|
|
# (--running orphan promoted to --pending or --auto when SSE
|
|
# arrives with the authoritative shape). Both class names must
|
|
# remain reachable from JS — dropping either breaks the reload
|
|
# state machine that PR #447's review pass surfaced. (5e.2c: the
|
|
# coordinator now emits the shared neutral .conv-* vocabulary.)
|
|
assert "conv-batch--running" in body
|
|
assert "conv-batch--pending" in body
|
|
# History replay's outcome classifier — denied / errored tool
|
|
# turns must render with the correct batch state on reload, not
|
|
# the contradictory "✓ approved" pill that pre-fix showed for
|
|
# any prior denial. bug-1 / bug-3 from the second /review pass.
|
|
# Post wire-shape unification the deny/error classification is shared by
|
|
# both browser reducers. Pin the coordinator's use of the occurrence-aware
|
|
# helper and the helper's live reads of the projected flags; call ids may
|
|
# repeat across turns and therefore cannot be classified by a global map.
|
|
tool_projection = (
|
|
Path(__file__).resolve().parent.parent / "turnstone/shared_static/tool_projection.js"
|
|
).read_text(encoding="utf-8")
|
|
assert "indexHistoryToolOutcomes(historyMessages)" in body
|
|
assert "historyBatchOutcomes.get(m)" in body
|
|
assert "result.denied" in tool_projection
|
|
assert "result.is_error" in tool_projection
|
|
# User-message attachment pills — both live send (coordSend) and
|
|
# history replay route through appendUserMessageWithAttachments.
|
|
# Renaming or dropping the helper would silently regress the
|
|
# attachment affordance to the pre-fix plain-text bubble, which
|
|
# would only surface in manual testing of an attached-file flow.
|
|
# The CSS class is the visual anchor (coordinator.css) — keeping
|
|
# both literals in the smoke layer covers JS↔CSS drift in either
|
|
# direction.
|
|
assert "function appendUserMessageWithAttachments" in body
|
|
assert "msg-user-attach" in body
|
|
# PR #487 — whitespace-only assistant content (Qwen3 with vLLM
|
|
# ``--reasoning-parser`` strips ``<think>…</think>`` and emits only
|
|
# ``"\n\n"`` as content before a tool call) must be skipped on
|
|
# history replay or the empty ``.msg.assistant`` card surfaces as
|
|
# a phantom row. The literal substring ``content.trim()`` is the
|
|
# single-line guard the rendering branch uses; a refactor that
|
|
# drops the trim() (e.g. simplifies to ``if (!content)``) silently
|
|
# regresses the phantom-card fix on the multi-node coord path.
|
|
# Mirrors ``test_app_js.py``'s same-shape pin on ``app.js``.
|
|
assert "content.trim()" in body
|
|
# PR #487 — coord history replay must render the assistant content
|
|
# card BEFORE the tool batch, not after, so DOM order matches the
|
|
# chronological order the model emitted (text → dispatch → results).
|
|
# Pre-fix the tool_calls branch sat at the role-agnostic top of the
|
|
# loop and rendered ahead of the assistant text that announced the
|
|
# batch, putting parallel fan-outs visually above their narrating
|
|
# message. The fix hoisted the synthesis into ``renderAssistantToolBatch``
|
|
# called from inside the assistant branch AFTER the content card —
|
|
# asserting the helper name lets a refactor that re-inlines or
|
|
# renames it surface here instead of via manual reload testing.
|
|
assert "function renderAssistantToolBatch" in body
|
|
assert "renderAssistantToolBatch(m)" in body
|
|
|
|
|
|
def test_coordinator_js_handle_child_state_no_longer_reads_sse_pending_approval_detail():
|
|
"""Stage 3 cleanup — ``pending_approval_detail`` is no longer
|
|
piggybacked on child_ws_state events. Approval items now arrive
|
|
via bulk fetch on the activity_state="approval" transition;
|
|
verdicts via the explicit ``child_ws_intent_verdict`` event class;
|
|
resolution via ``child_ws_approval_resolved``. A refactor that
|
|
re-introduces the piggyback would silently re-open the
|
|
duplicate-path race the dedicated event classes were added to
|
|
eliminate.
|
|
|
|
Structural assertions (regex against multi-line source) — symbol-
|
|
presence alone wouldn't catch a guard that keeps the names but
|
|
inverts the comparison or drops the ``prev.live`` check. This
|
|
codebase has no JS test framework, so locking the guard's shape
|
|
here is the next-best thing to a behavioral test."""
|
|
import re
|
|
from pathlib import Path
|
|
|
|
coord_js = Path(__file__).resolve().parent.parent / (
|
|
"turnstone/console/static/coordinator/coordinator.js"
|
|
)
|
|
body = coord_js.read_text(encoding="utf-8")
|
|
|
|
# The piggyback read is gone from handleChildState. (The string
|
|
# may still appear elsewhere — e.g. handleChildIntentVerdict
|
|
# reading from cache, or comments — but never as ``ev.pending_approval_detail``.)
|
|
assert "ev.pending_approval_detail" not in body
|
|
# The pre-fix urgent-fetch on activity_state transitions is gone.
|
|
assert "enteredApproval" not in body
|
|
assert "leftApproval" not in body
|
|
# ``pendingApproval`` flag derivation must check BOTH state and
|
|
# activity_state. The worker thread can fire the state transition
|
|
# to "attention" before approve_tools updates activity_state, so
|
|
# checking only activity_state misses children that legitimately
|
|
# need approval. Pin the disjunction so the regression doesn't
|
|
# silently re-introduce.
|
|
assert re.search(
|
|
r'existing\.state\s*===\s*"attention"\s*\|\|\s*'
|
|
r'existing\.activity_state\s*===\s*"approval"',
|
|
body,
|
|
), (
|
|
"handleChildState must derive pendingApproval from "
|
|
"(state==='attention' || activity_state==='approval')"
|
|
)
|
|
|
|
# SSE-authoritative window constant is defined and used.
|
|
assert re.search(r"\bconst\s+SSE_AUTHORITATIVE_MS\s*=\s*\d+", body), (
|
|
"SSE_AUTHORITATIVE_MS constant must be defined as a numeric literal"
|
|
)
|
|
|
|
# SSE writers tag entries with sseUpdatedAt: Date.now() so the
|
|
# merge guard in flushLiveFetches preserves them against stale
|
|
# bulk-fetch responses. handleChildState only stamps when it
|
|
# AUTHORITATIVELY clears the detail (off-approval transition);
|
|
# writers that stamp unconditionally are intent_verdict (verdict
|
|
# stamp), approval_resolved (clear), and the optimistic-clear
|
|
# path in submitChildApproval. Pinning the literal Date.now()
|
|
# call keeps a refactor that drops the SSE-source tag entirely
|
|
# from sneaking in.
|
|
assert re.search(
|
|
r"sseUpdatedAt:\s*Date\.now\(\)",
|
|
body,
|
|
), "Critical SSE writers must stamp sseUpdatedAt: Date.now()"
|
|
|
|
# flushLiveFetches' merge guard structure: SSE-set pending_approval
|
|
# / _detail wins over a stale bulk-poll snapshot when (live) AND
|
|
# (prev exists) AND (prev.sseUpdatedAt set) AND (within window)
|
|
# AND (prev.live exists). Inverting the comparison or dropping
|
|
# any of these guards reopens the clobber bug.
|
|
merge_guard = re.search(
|
|
r"if\s*\(\s*live\s*&&\s*prev\s*&&\s*prev\.sseUpdatedAt\s*&&\s*"
|
|
r"now\s*-\s*prev\.sseUpdatedAt\s*<\s*SSE_AUTHORITATIVE_MS\s*&&\s*"
|
|
r"prev\.live\s*\)",
|
|
body,
|
|
)
|
|
assert merge_guard is not None, (
|
|
"flushLiveFetches merge guard must be the conjunction "
|
|
"(live && prev && prev.sseUpdatedAt && now - prev.sseUpdatedAt < "
|
|
"SSE_AUTHORITATIVE_MS && prev.live). An inverted comparison or "
|
|
"missing prev.live check would let a stale bulk-poll clobber a "
|
|
"fresh SSE-set approval."
|
|
)
|
|
|
|
# The merge body must preserve BOTH pending_approval and
|
|
# pending_approval_details from prev — preserving only one would
|
|
# render a row with a phantom badge but no buttons (or vice versa).
|
|
merge_body = re.search(
|
|
r"mergedLive\s*=\s*Object\.assign\(\s*\{\}\s*,\s*live\s*,\s*\{"
|
|
r"[^}]*pending_approval:\s*prev\.live\.pending_approval[^}]*"
|
|
r"pending_approval_details:\s*prev\.live\.pending_approval_details",
|
|
body,
|
|
)
|
|
assert merge_body is not None, (
|
|
"Merge body must preserve both pending_approval AND "
|
|
"pending_approval_details from prev.live — preserving only one "
|
|
"creates a half-rendered approval row."
|
|
)
|
|
|
|
# flushLiveFetches must forward sseUpdatedAt onto the new cache
|
|
# entry so the SSE-source tag survives the bulk-poll write back —
|
|
# without this, every bulk-poll resets the window and the next
|
|
# late-arriving poll silently clobbers.
|
|
assert re.search(
|
|
r"sseUpdatedAt:\s*prev\s*\?\s*prev\.sseUpdatedAt",
|
|
body,
|
|
), (
|
|
"flushLiveFetches must forward prev.sseUpdatedAt onto the new "
|
|
"cache entry (preserving the SSE-source window across bulk-poll "
|
|
"cycles) — without this, the second bulk-poll after an SSE "
|
|
"transition silently clobbers."
|
|
)
|
|
|
|
|
|
def test_coord_history_renders_system_turn_via_msg_variants():
|
|
"""First-class operator-context ``system`` turns (output-guard findings,
|
|
user interjections, metacognitive nudges) replay through the coord
|
|
history loop's ``system``-role branch, labelled with the turn's
|
|
``source`` and styled via the ``system`` ``_MSG_VARIANTS`` entry. The
|
|
legacy ``replayAdvisoriesAfterTool`` envelope path is gone.
|
|
|
|
Mirrors ``test_app_js.py``'s same-shape pin on interactive's
|
|
``replayHistory``."""
|
|
from pathlib import Path
|
|
|
|
coord_js = Path(__file__).resolve().parent.parent / (
|
|
"turnstone/console/static/coordinator/coordinator.js"
|
|
)
|
|
body = coord_js.read_text(encoding="utf-8")
|
|
|
|
# The advisory-envelope replay helper is gone.
|
|
assert "replayAdvisoriesAfterTool" not in body, (
|
|
"replayAdvisoriesAfterTool should be deleted — operator context now "
|
|
"rides first-class system rows, not the tool envelope."
|
|
)
|
|
# The coord history loop has an explicit system-role branch labelling
|
|
# the bubble with the turn's source kind.
|
|
assert 'role === "system"' in body, (
|
|
"coord history loop must have a system-role branch for first-class operator-context turns."
|
|
)
|
|
# The ``system`` _MSG_VARIANTS entry gives the bubble operator styling and
|
|
# tags it with the shared ``operator-context`` marker (so the retry-skip
|
|
# walk steps over it — see test_coord_retry_walk_skips_operator_context_cards).
|
|
assert 'system: "system-context operator-context"' in body, (
|
|
"coordinator.js must map the system role to the "
|
|
"'system-context operator-context' variant so operator-context turns "
|
|
"get the operator styling AND carry the retry-skip marker."
|
|
)
|
|
|
|
|
|
def test_coord_dedups_system_turn_against_history_by_event_id():
|
|
"""The coord live ``system_turn`` handler skips an event already painted
|
|
from ``/history`` (matched by ``_event_id``) so an SSE replay redelivering
|
|
it past the resume cursor doesn't double-render the operator bubble.
|
|
Symmetric with ``test_app_js.py``'s interactive dedup and the row/event
|
|
id-alignment backend fix — both panes share the seam."""
|
|
from pathlib import Path
|
|
|
|
coord_js = Path(__file__).resolve().parent.parent / (
|
|
"turnstone/console/static/coordinator/coordinator.js"
|
|
)
|
|
body = coord_js.read_text(encoding="utf-8")
|
|
|
|
assert "renderedSystemEventIds.has(" in body, (
|
|
"the coord system_turn handler must skip an event whose id was already "
|
|
"rendered from /history."
|
|
)
|
|
assert "renderedSystemEventIds.add(" in body, (
|
|
"the coord history loop (and live handler) must record system-turn ids."
|
|
)
|
|
assert "renderedSystemEventIds.clear(" in body, (
|
|
"refetchHistory must reset the dedup set so a re-render doesn't "
|
|
"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
|
|
a tool batch trailed by an operator-context row. ``_refreshRetryButton``
|
|
walks back past ``.operator-context`` rows before testing for
|
|
``.coord-tool-batch`` — which only works if EVERY operator row carries the
|
|
shared marker. Pin the walk predicate AND the marker on each structured
|
|
card so a new card kind (or a walk keyed on a single class) can't silently
|
|
re-introduce the wrong-turn retry regression."""
|
|
from pathlib import Path
|
|
|
|
coord_js = Path(__file__).resolve().parent.parent / (
|
|
"turnstone/console/static/coordinator/coordinator.js"
|
|
)
|
|
body = coord_js.read_text(encoding="utf-8")
|
|
|
|
assert 'classList.contains("operator-context")' in body, (
|
|
"_refreshRetryButton must walk back past .operator-context rows so the "
|
|
"tool-only retry skip fires even when a card trails the tool batch."
|
|
)
|
|
# The watch-result card moved to the shared conversation.js (step 5e.1); the
|
|
# guard-finding + idle-children cards stay in the coordinator pane.
|
|
shared = (
|
|
Path(__file__).resolve().parent.parent / "turnstone/shared_static/conversation.js"
|
|
).read_text(encoding="utf-8")
|
|
assert '"msg watch-result operator-context"' in shared, (
|
|
"buildWatchResultCard must tag its card with the operator-context marker."
|
|
)
|
|
for builder, cls in (
|
|
("appendGuardFinding", '"msg guard-finding operator-context"'),
|
|
("appendIdleChildren", '"msg idle-children operator-context"'),
|
|
("appendIdleTasks", '"msg idle-tasks operator-context"'),
|
|
):
|
|
assert cls in body, (
|
|
f"{builder} must tag its card with the shared operator-context "
|
|
f"marker ({cls}) or the retry walk won't skip it."
|
|
)
|
|
|
|
|
|
def test_coordinator_js_dispatches_idle_tasks_to_its_card():
|
|
"""``renderSystemTurn`` is shared by the live SSE handler and history
|
|
replay, so a missing branch means the card paints live but degrades to a
|
|
plain labelled bubble on refresh — the exact live-vs-replay drift the
|
|
dispatcher exists to prevent."""
|
|
from pathlib import Path
|
|
|
|
body = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "turnstone/console/static/coordinator/coordinator.js"
|
|
).read_text(encoding="utf-8")
|
|
assert 'if (source === "idle_tasks" && m) return appendIdleTasks(m);' in body, (
|
|
"renderSystemTurn must route idle_tasks to its structured card."
|
|
)
|
|
# The role literal now reaches the DOM through the shared builder's
|
|
# ``tsRole`` parameter rather than an inline setAttribute, so pin the
|
|
# argument at the call site instead.
|
|
idx = body.index("function appendIdleTasks(")
|
|
assert '"idle_tasks",' in body[idx : idx + 1400], (
|
|
"appendIdleTasks must pass its data-ts-role to buildIdleCard for the "
|
|
"headless render checks."
|
|
)
|
|
|
|
|
|
def test_idle_cards_share_one_style_block():
|
|
"""``idle_children`` and ``idle_tasks`` are the same class of notice and
|
|
share grouped CSS rules — pinned so a restyle of one can't silently skip
|
|
the other, which is how the two cards would drift apart visually."""
|
|
from pathlib import Path
|
|
|
|
css = (Path(__file__).resolve().parent.parent / "turnstone/shared_static/chat.css").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
for shared_rule in (
|
|
".msg.idle-children .msg-idle-header,\n.msg.idle-tasks .msg-idle-header",
|
|
".msg.idle-children .msg-idle-list,\n.msg.idle-tasks .msg-idle-list",
|
|
".msg.idle-children .msg-idle-child,\n.msg.idle-tasks .msg-idle-child",
|
|
):
|
|
assert shared_rule in css, f"idle cards must share the rule: {shared_rule!r}"
|
|
|
|
|
|
def test_coordinator_js_seeds_resume_cursor_only_on_initial_connect():
|
|
"""coordinator.js must consume the /history resume cursor the same way
|
|
ui/static/app.js does: the shared make_history_handler trims the
|
|
executing in-flight orphan turn and returns a cursor, so the coord
|
|
client MUST open its initial SSE with that cursor (?last_event_id=) or
|
|
the trimmed turn is neither in /history nor delta-replayed — it vanishes
|
|
from the dashboard (a regression vs the prior #610 in-flight render).
|
|
|
|
Pins three invariants mirroring the app.js guards:
|
|
1. ``refetchHistory`` takes a ``seedCursor`` flag (default false) and
|
|
seeds ``lastEventId`` from ``hist.cursor`` only when set + non-null,
|
|
so the clear_ui re-render caller (live stream, no reconnect — the
|
|
one remaining seedless caller after the #882 dead-stream ruling)
|
|
doesn't rewind the live cursor.
|
|
2. every caller that reconnects opts in: init via
|
|
``await refetchHistory(true)``, and the truncated resync via
|
|
``loadHistoryThenReconnect``'s ``refetchHistory(true).finally``
|
|
(cursor adoption is the #882 fix core — /history trims the
|
|
in-flight turn whenever it returns a cursor).
|
|
3. ``connectSSE`` gates ``?last_event_id=`` on ``connectCursor !=
|
|
null`` so a cursor of 0 (a brand-new ws's first-turn boundary)
|
|
isn't dropped — where ``connectCursor`` presents a recorded
|
|
truncation gap over the advanced live cursor (the gap-repair
|
|
chokepoint; see the truncated fresh-connect test in
|
|
test_app_js.py).
|
|
"""
|
|
import re
|
|
from pathlib import Path
|
|
|
|
coord_js = Path(__file__).resolve().parent.parent / (
|
|
"turnstone/console/static/coordinator/coordinator.js"
|
|
)
|
|
body = coord_js.read_text(encoding="utf-8")
|
|
assert "async function refetchHistory(seedCursor = false)" in body, (
|
|
"refetchHistory must take a seedCursor flag (default false) so only "
|
|
"the reconnecting callers seed the resume cursor."
|
|
)
|
|
assert re.search(
|
|
r"if\s*\(\s*seedCursor\s*&&\s*hist\.cursor\s*!=\s*null\s*\)\s*"
|
|
r"lastEventId\s*=\s*hist\.cursor",
|
|
body,
|
|
), "refetchHistory must seed lastEventId from hist.cursor only when seedCursor && != null."
|
|
assert "await refetchHistory(true)" in body, (
|
|
"the initial-connect path must call refetchHistory(true) to seed the cursor."
|
|
)
|
|
flow = re.search(
|
|
r"function loadHistoryThenReconnect\(manualAttempt = false\)\s*\{(.*?)\n \}",
|
|
body,
|
|
re.S,
|
|
)
|
|
assert flow is not None, "loadHistoryThenReconnect not found"
|
|
assert "refetchHistory(true)" in flow.group(1) and ".then((outcome) => {" in flow.group(1), (
|
|
"the truncated resync (loadHistoryThenReconnect) must seed via "
|
|
"refetchHistory(true) and reconnect in its outcome-threaded settle."
|
|
)
|
|
assert re.search(
|
|
r"if\s*\(\s*connectCursor\s*!=\s*null\s*\)\s*\{\s*url\s*\+=\s*\"\?last_event_id=\"",
|
|
body,
|
|
), "connectSSE must gate ?last_event_id= on connectCursor != null (so cursor 0 isn't dropped)."
|
|
|
|
|
|
def test_coordinator_initial_history_handoff_is_one_shot_and_mismatch_refetches():
|
|
"""Coordinator mirrors interactive's opaque REST -> initial-SSE handoff."""
|
|
import re
|
|
from pathlib import Path
|
|
|
|
body = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "turnstone/console/static/coordinator/coordinator.js"
|
|
).read_text(encoding="utf-8")
|
|
start = body.index("function connectSSE()")
|
|
end = body.index("function scheduleReconnect", start)
|
|
connect = body[start:end]
|
|
|
|
hidden = connect.index("if (document.hidden)")
|
|
capability = connect.index('"user_turn=1"')
|
|
handoff_query = connect.index('"history_token="')
|
|
construct = connect.index("new EventSource(url")
|
|
consume = connect.index("historyHandoffToken = null;", construct)
|
|
assert capability < hidden < handoff_query < construct < consume
|
|
assert 'url += "?last_event_id="' in connect
|
|
assert '(url.includes("?") ? "&" : "?")' in connect
|
|
assert connect.count('"user_turn=1"') == 1
|
|
|
|
refetch_start = body.index("async function refetchHistory(seedCursor = false)")
|
|
refetch = body[refetch_start : body.index("\n function ", refetch_start + 1)]
|
|
assert re.search(
|
|
r"if\s*\(seedCursor\)\s*\{\s*historyHandoffToken\s*=\s*"
|
|
r"typeof hist\.handoff_token === \"string\"",
|
|
refetch,
|
|
)
|
|
|
|
mismatch = body.index('case "history_resync"')
|
|
truncated = body.index('case "replay_truncated"', mismatch)
|
|
mismatch_case = body[mismatch:truncated]
|
|
assert "historyRepair.begin(wsId);" in mismatch_case
|
|
assert "last_event_id" not in mismatch_case
|
|
|
|
# A mismatch is a durable-history repair, not a transport retry. Once it
|
|
# is latched, connectSSE may only schedule the bounded REST retry and
|
|
# return; it cannot construct a cursorless/tokenless EventSource. The
|
|
# latch, budget, backoff, and parked prompt moved into the shared
|
|
# controller (history_handoff.createHistoryHandoffRepair) — pinned there,
|
|
# once; what stays pinned HERE is this pane's use of it.
|
|
repair_guard = connect.index("if (historyRepair.isRepairing(wsId))")
|
|
assert repair_guard < handoff_query < construct
|
|
guard_end = connect.index("if (historyHandoffToken != null)", repair_guard)
|
|
guard = connect[repair_guard:guard_end]
|
|
assert "historyRepair.schedule();" in guard
|
|
assert "return;" in guard
|
|
|
|
load_start = body.index("function loadHistoryThenReconnect(manualAttempt = false)")
|
|
load_end = body.index("function enterDegradedCatchup", load_start)
|
|
load = body[load_start:load_end]
|
|
# Admission before any work, then the budget charge, then exactly one
|
|
# handover of the verdict; the non-repair tail keeps its own reconnect.
|
|
admit = load.index("historyRepair.admitAttempt(manualAttempt)")
|
|
start_attempt = load.index("historyRepair.startAttempt(manualAttempt)", admit)
|
|
settle = load.index("historyRepair.settle({", start_attempt)
|
|
assert admit < start_attempt < settle
|
|
assert "hasToken: historyHandoffToken != null" in load[settle:]
|
|
repair_return = load.index("return;", settle)
|
|
assert "connectSSE();" not in load[settle:repair_return]
|
|
|
|
destroy_start = body.index("function destroy()")
|
|
destroy_end = body.index("function reconnect()", destroy_start)
|
|
assert "historyRepair.clear();" in body[destroy_start:destroy_end]
|
|
|
|
|
|
def test_coordinator_refetch_failure_preserves_the_pane():
|
|
"""A FAILED /history fetch must leave the message column and the
|
|
tool-row/batch tracking untouched (#882 G3): refetchHistory's wipe +
|
|
resets must sit AFTER the ``if (!hist) return`` guard. Pre-fix the
|
|
wipe ran first, so a failed fetch — likeliest exactly during the
|
|
restart windows that trigger truncated resyncs — left an EMPTY pane
|
|
on a live stream with no retry record. Stale-but-real beats blank,
|
|
and the maps stay valid against the untouched DOM."""
|
|
import re
|
|
from pathlib import Path
|
|
|
|
coord_js = Path(__file__).resolve().parent.parent / (
|
|
"turnstone/console/static/coordinator/coordinator.js"
|
|
)
|
|
body = coord_js.read_text(encoding="utf-8")
|
|
start = body.index("async function refetchHistory(seedCursor = false)")
|
|
# Slice to the next function boundary — the pinned invariant is the
|
|
# ORDER below, not the density, and a char-count window dies with a
|
|
# bare ValueError every time an at-site comment grows.
|
|
fn = body[start : body.index("\n function ", start + 1)]
|
|
guard = fn.index("if (!hist) return;")
|
|
wipe = fn.index("messagesEl.replaceChildren();")
|
|
resets = fn.index("toolRows.clear();")
|
|
assert guard < wipe and guard < resets, (
|
|
"refetchHistory must bail on a failed fetch BEFORE wiping the "
|
|
"pane or clearing the tool-row maps — a failed /history must be "
|
|
"a DOM no-op."
|
|
)
|
|
assert re.search(r"if \(!hist\) return;", fn), "failure guard missing"
|
|
|
|
|
|
def test_coordinator_history_stale_latch_contract():
|
|
"""#894 (the #890 port): a rewind/edit clicked during a clear_ui refetch
|
|
window — or after a FAILED refetch — must not count the stale pre-rewind
|
|
DOM. Pins the latch's structural contract:
|
|
|
|
1. clear_ui sets ``historyStale = true`` BEFORE its seedless
|
|
``refetchHistory()`` call (the only set site), so the gate closes
|
|
for the whole fetch window.
|
|
2. The DOM-counting affordances gate on ``busy || historyStale``
|
|
(_rewindToMessage / _editAndResend / _startEdit); _rewindToTurns
|
|
and _retryLast stay busy-only (explicit-arg / no-DOM-count paths —
|
|
the at-site rulings).
|
|
3. ``historyStale = false`` lives ONLY below the ``if (!hist)
|
|
return;`` failure guard in refetchHistory: a failed fetch keeps
|
|
the latch set (a refetch-in-flight flag would reopen on exactly
|
|
that exit — the over-rewind aftermath).
|
|
4. The idle-edge backstop is TRANSPORT-FREE: the arm behind the
|
|
pendingTruncatedResync consumer heals via plain
|
|
``refetchHistory()``, never ``loadHistoryThenReconnect()`` —
|
|
a reconnecting heal draws the server's synthetic
|
|
state_change:idle back into its own trigger (the #890 round-5
|
|
zero-backoff storm).
|
|
5. The retry is bounded by construction: ``staleRetryTimer =
|
|
setTimeout`` appears exactly once (the clear_ui .then), so no
|
|
path — including the retry's own — can re-arm it.
|
|
6. destroy() cancels staleRetryTimer (terminal-only) while
|
|
closeStreamTransport does NOT (transport redials keep the heal
|
|
intent alive).
|
|
7. The render-time gate (r5/r6-derived): refetchHistory re-checks,
|
|
AFTER the await and BEFORE the wipe, the UNIVERSAL terms —
|
|
dispatch currency (seq) and the content refs — and the
|
|
SEEDLESS-only terms: the event-driven live-tool-call set (never
|
|
a DOM probe, which the render's own orphan repaint can forge —
|
|
the r6 critical; never plain busy — the r5 critical), the
|
|
optimistic busySource flavor, and stream-OPENness. The
|
|
latch-clear sits below every skip point, so a skipped render
|
|
can never reopen the affordances over a stale DOM. (Joined
|
|
pre-rewind server flights are closed SERVER-side — the #884
|
|
flight key folds in the truncation generation; an r8
|
|
client-epoch guard here was unreachable, being redundant with
|
|
the seq gate, and was removed in r9.)
|
|
"""
|
|
from pathlib import Path
|
|
|
|
coord_js = Path(__file__).resolve().parent.parent / (
|
|
"turnstone/console/static/coordinator/coordinator.js"
|
|
)
|
|
body = coord_js.read_text(encoding="utf-8")
|
|
|
|
# 1. Set site: inside the clear_ui case, before the refetch call.
|
|
assert body.count("historyStale = true;") == 1, (
|
|
"historyStale must be set in exactly one place (clear_ui arrival)."
|
|
)
|
|
clear_case = body.index('case "clear_ui"')
|
|
set_site = body.index("historyStale = true;")
|
|
refetch_call = body.index("refetchHistory()", clear_case)
|
|
assert clear_case < set_site < refetch_call, (
|
|
"clear_ui must latch historyStale BEFORE calling refetchHistory() "
|
|
"so the gate covers the whole fetch window."
|
|
)
|
|
|
|
# 2. Gates: DOM-counting affordances latch-gated; explicit-arg /
|
|
# no-count paths stay busy-only.
|
|
def _fn_slice(name: str) -> str:
|
|
start = body.index("function " + name)
|
|
return body[start : body.index("\n function ", start + 1)]
|
|
|
|
for gated in ("_rewindToMessage", "_editAndResend", "_startEdit"):
|
|
assert "if (busy || historyStale) return;" in _fn_slice(gated), (
|
|
f"{gated} must gate on busy || historyStale (#894)."
|
|
)
|
|
for ungated in ("_rewindToTurns", "_retryLast"):
|
|
sl = _fn_slice(ungated)
|
|
assert "if (busy) return;" in sl and "busy || historyStale" not in sl, (
|
|
f"{ungated} must stay busy-only (at-site ruling: explicit turn "
|
|
"count / no DOM count — latch-gating it blocks legitimate calls)."
|
|
)
|
|
|
|
# 3. Clear site: exactly one assignment to false (past the decl),
|
|
# below the failure guard.
|
|
clears = re.findall(r"(?<!let )historyStale = false;", body)
|
|
assert len(clears) == 1, (
|
|
"historyStale must clear in exactly one place (refetchHistory's success path)."
|
|
)
|
|
fetch_start = body.index("async function refetchHistory(seedCursor = false)")
|
|
guard = body.index("if (!hist) return;", fetch_start)
|
|
clear_site = body.index("historyStale = false;", fetch_start)
|
|
assert guard < clear_site, (
|
|
"the latch clear must sit BELOW the failure guard — a failed fetch "
|
|
"keeps the latch set (that survival arms the retry/backstop)."
|
|
)
|
|
|
|
# 4. Transport-free backstop: the arm after the truncated consumer
|
|
# refetches over REST and never reconnects — and it must be the
|
|
# ELSE-IF of the truncated consumer (mutual exclusion: the
|
|
# truncated branch's own reload heals the latch too; two separate
|
|
# ifs would run both heals on one idle edge).
|
|
trunc_arm = body.index("if (pendingTruncatedResync)")
|
|
backstop = body.index("historyStale &&", trunc_arm)
|
|
assert "} else if (" in body[trunc_arm:backstop], (
|
|
"the staleness backstop must be the else-if sibling of the "
|
|
"pendingTruncatedResync consumer, never an independent if."
|
|
)
|
|
idle_block_end = body.index('ev.state === "running"', backstop)
|
|
# Comment-stripped: the arm's at-site ruling comments name every
|
|
# guard term, so raw-window presence asserts would go vacuous.
|
|
backstop_arm = _strip_comments(body[backstop:idle_block_end])
|
|
assert "refetchHistory();" in backstop_arm, (
|
|
"the staleness backstop must heal via a plain seedless refetchHistory()."
|
|
)
|
|
assert "loadHistoryThenReconnect();" not in backstop_arm, (
|
|
"TRANSPORT-FREE ruling: the staleness backstop must never "
|
|
"reconnect — a reload's fresh reconnect draws the synthetic "
|
|
"state_change:idle back into this trigger (zero-backoff storm)."
|
|
)
|
|
assert "!refetchesInFlight" in backstop_arm, (
|
|
"the backstop must yield to an in-flight refetch — without the "
|
|
"guard it stomps a same-snapshot fetch with a double render "
|
|
"(mirrors interactive's !_replayQueue pin)."
|
|
)
|
|
assert "!currentAssistantEl" in backstop_arm and "!currentReasoningEl" in backstop_arm, (
|
|
"the backstop's ref guards are LOAD-BEARING (coord's "
|
|
"refetchHistory does not reset streaming refs, and this arm "
|
|
"serves error edges where no stream_end nulled them) — a "
|
|
"simplify-to-match-interactive edit must not delete them."
|
|
)
|
|
|
|
# 5. Bounded retry: exactly one arm site; the ARM is teardown-gated;
|
|
# the fire guard yields to an in-flight fetch and carries the
|
|
# teardown sentinel.
|
|
assert body.count("staleRetryTimer = setTimeout") == 1, (
|
|
"the stale retry must be armed in exactly one place (clear_ui "
|
|
".then) — bounded by construction."
|
|
)
|
|
assert body.count("if (historyStale && visHandler) {") == 1, (
|
|
"the arm must be gated on the teardown sentinel too — the "
|
|
"clear_ui .then can settle after destroy()/coordCloseSession, "
|
|
"and an ungated arm recreates the orphan timer destroy's cancel "
|
|
"exists to kill."
|
|
)
|
|
retry_arm = body.index("staleRetryTimer = setTimeout")
|
|
# #900 made the delay an expression over two SHARED constants, so the old
|
|
# literal `}, 2000);` anchor is gone. Assert the new anchor EXISTS before
|
|
# slicing on it: `.index` would raise ValueError and the suite would ERROR
|
|
# with an anonymous traceback rather than fail on a named assertion, which
|
|
# is the exact failure mode this re-anchor exists to remove. Coord and
|
|
# interactive must carry the identical expression or the herd-spread
|
|
# invariant silently forks.
|
|
# Unwindowed on purpose: the expression occurs exactly once in coord, so a
|
|
# fixed slice buys nothing and can truncate mid-expression (it did).
|
|
# Locality is established by the retry_fire slice below, which starts at
|
|
# the arm — this assertion only has to prove the anchor exists at all.
|
|
assert "STALE_RETRY_BASE_MS + Math.random() * STALE_RETRY_JITTER_MS" in body, (
|
|
"the coord retry must keep the shared floor + jitter (#900)"
|
|
)
|
|
retry_fire = _strip_comments(body[retry_arm : body.index("STALE_RETRY_JITTER_MS", retry_arm)])
|
|
assert "!refetchesInFlight" in retry_fire, (
|
|
"the retry's fire guard must yield to an in-flight refetch "
|
|
"(mirrors interactive's !_replayQueue pin)."
|
|
)
|
|
assert "visHandler" in retry_fire, (
|
|
"the retry's fire guard must carry the teardown sentinel for the "
|
|
"coordCloseSession path, which nulls visHandler but does not "
|
|
"cancel the timer (evtSource is also null there post-suspend, "
|
|
"but the sentinel is the durable term)."
|
|
)
|
|
assert "!currentAssistantEl" in retry_fire and "!currentReasoningEl" in retry_fire, (
|
|
"the retry's ref guards skip a pointless fetch whose payload the "
|
|
"render-time gate would discard (the chokepoint carries the "
|
|
"correctness; these are the efficiency layer — keep them)."
|
|
)
|
|
# Whitespace-normalised, not indentation-exact: #900 made the delay an
|
|
# expression, which reflowed setTimeout to prettier's multi-line call form
|
|
# and re-indented this whole callback body. Still an ORDERED-tail pin, so
|
|
# a mere comment mention cannot satisfy it.
|
|
cond_start = body.index("if (", retry_arm)
|
|
guard = " ".join(body[cond_start : body.index(") {", cond_start)].split())
|
|
assert guard.endswith("visHandler && evtSource && evtSource.readyState === EventSource.OPEN"), (
|
|
"the retry's fire guard must require an OPEN stream — not handle "
|
|
"existence: CONNECTING keeps the handle with a frozen cursor and "
|
|
"a pending replay, and a seedless heal then double-renders when "
|
|
"the replay lands. Exact-tail pin so a comment mention cannot "
|
|
"satisfy it; re-anchor if the guard reflows."
|
|
)
|
|
assert "if (staleRetryTimer) clearTimeout(staleRetryTimer);" in body, (
|
|
"re-arming on a newer clear_ui must cancel the pending timer "
|
|
"first, or a double clear_ui leaks a timer."
|
|
)
|
|
# The consumer pins above are only meaningful while the PRODUCER
|
|
# brackets every fetch: without the ++/-- pair the counter is
|
|
# permanently 0 and both yield guards pass vacuously. Count alone
|
|
# is not enough — the ORDER is the bracket (an increment moved below
|
|
# the await leaves the counter 0 during every fetch with both counts
|
|
# intact), so pin inc < await < finally < dec inside refetchHistory.
|
|
assert body.count("refetchesInFlight++") == 1, (
|
|
"refetchHistory must increment the in-flight counter before its "
|
|
"await — the yield guards read it."
|
|
)
|
|
assert body.count("refetchesInFlight--") == 1, (
|
|
"the in-flight counter must decrement in exactly one place (the "
|
|
"fetch finally) so every exit rebalances it."
|
|
)
|
|
inc = body.index("refetchesInFlight++", fetch_start)
|
|
awt = body.index("await Promise.race([", fetch_start)
|
|
fin = body.index("} finally {", fetch_start)
|
|
dec = body.index("refetchesInFlight--", fetch_start)
|
|
assert inc < awt < fin < dec, (
|
|
"the counter must bracket the await window: increment BEFORE the "
|
|
"fetch, decrement in its finally — any other order un-brackets "
|
|
"the very window the yield guards protect."
|
|
)
|
|
|
|
# 6. Teardown: terminal cancel in destroy(); NOT in closeStreamTransport.
|
|
destroy_slice = _fn_slice("destroy()")
|
|
assert "clearTimeout(staleRetryTimer)" in destroy_slice, (
|
|
"destroy() must cancel the stale retry timer or it fires into "
|
|
"detached DOM (and pins the closure)."
|
|
)
|
|
cst_slice = _fn_slice("closeStreamTransport()")
|
|
assert "clearTimeout(staleRetryTimer)" not in cst_slice, (
|
|
"closeStreamTransport must NOT cancel the stale retry — transport "
|
|
"redials keep the pending heal intent (terminal-only cancel)."
|
|
)
|
|
|
|
# 7. Render-time gate (r5-derived, seedless-scoped after the
|
|
# coord-restart family find): the post-await re-checks sit between
|
|
# the failure guard and the wipe, so the latch-clear (below the
|
|
# wipe) sits below every skip point by construction. Universal
|
|
# terms: dispatch currency (seq) and the content refs (skipping
|
|
# always beats stranding a ref; seeded callers null theirs before
|
|
# fetching, so it never blocks them). SEEDLESS-only terms —
|
|
# keyed on the seedCursor ARG: the event-driven live-tool-call
|
|
# set (liveToolCalls — never a DOM probe, which the render's own
|
|
# orphan repaint forges: the r6 critical; on the SEEDED resync
|
|
# even genuine residue must not block — the render IS the
|
|
# recovery, and a universal term wedged coord-restart outright),
|
|
# the optimistic-send busySource flavor, and stream-OPENness
|
|
# (CONNECTING keeps the handle with a frozen cursor and a
|
|
# pending replay). Plain ``busy`` must not appear:
|
|
# it means a turn is EXECUTING, not that this DOM holds live
|
|
# state (the r5 critical).
|
|
hist_guard = body.index("if (!hist) return;", fetch_start)
|
|
wipe = body.index("messagesEl.replaceChildren();", fetch_start)
|
|
latch_clear = body.index("historyStale = false;", fetch_start)
|
|
assert hist_guard < wipe < latch_clear, (
|
|
"the wipe must sit between the failure guard and the latch-clear "
|
|
"— every gate skip above the wipe then leaves the latch set."
|
|
)
|
|
gate_code = _strip_comments(body[hist_guard:wipe])
|
|
for term, why in (
|
|
("if (seq !== refetchSeq) return;", "dispatch-currency (seq)"),
|
|
(
|
|
"if (currentAssistantEl || currentReasoningEl) return;",
|
|
"universal content-ref",
|
|
),
|
|
("!seedCursor &&", "seedless scoping"),
|
|
('busySource === "optimistic"', "optimistic-row"),
|
|
("liveToolCalls.size > 0", "live-tool-call"),
|
|
("evtSource.readyState !== EventSource.OPEN", "stream-OPENness"),
|
|
):
|
|
assert term in gate_code, (
|
|
f"the render-time gate must carry the {why} term (in CODE, not a comment)."
|
|
)
|
|
seedless_at = gate_code.index("!seedCursor &&")
|
|
assert gate_code.index("if (seq !== refetchSeq) return;") < seedless_at, (
|
|
"seq currency must be checked before the seedless group."
|
|
)
|
|
assert gate_code.index("if (currentAssistantEl || currentReasoningEl) return;") < seedless_at, (
|
|
"the universal ref check must precede the seedless group."
|
|
)
|
|
for term in (
|
|
'busySource === "optimistic"',
|
|
"liveToolCalls.size",
|
|
"evtSource.readyState !== EventSource.OPEN",
|
|
):
|
|
assert seedless_at < gate_code.index(term), (
|
|
f"{term} must live INSIDE the !seedCursor group — the seeded "
|
|
"resync renders over a dead --running batch / an optimistic "
|
|
"row deliberately (blocking it wedges the coord-restart "
|
|
"recovery)."
|
|
)
|
|
assert not re.search(r"\bbusy\b(?!Source)", gate_code), (
|
|
"plain busy must not gate the render — busy means a turn is "
|
|
"EXECUTING, not that this DOM holds live state (the r5 critical: "
|
|
"_editAndResend flips busy before its POST and /rewind emits no "
|
|
"state_change, so a busy term skips the truncation render the "
|
|
"rewind exists to produce)."
|
|
)
|
|
|
|
# 8. Live-set discipline (r6): the tool-phase liveness signal is fed
|
|
# ONLY by live SSE events and drained at settle/teardown edges —
|
|
# and NO render path may touch it. The r6 critical: the render's
|
|
# own replay paints orphan batches with the same --running class
|
|
# the live path uses, so a DOM-derived probe let one orphan paint
|
|
# poison every seedless heal for the life of the page.
|
|
assert body.count("liveToolCalls.add(") == 2, (
|
|
"liveToolCalls must be fed by exactly the two live announce "
|
|
"events (tool_pending + tool_info)."
|
|
)
|
|
assert body.count("liveToolCalls.delete(") == 1, (
|
|
"tool_result must retire its call_id from the live set."
|
|
)
|
|
assert body.count("liveToolCalls.clear()") == 1, (
|
|
"the live set must drain at the settle edge ONLY — a "
|
|
"closeStreamTransport drain fails OPEN (the reconnect replay "
|
|
"does not re-announce a live batch, so an emptied set lets a "
|
|
"post-redial seedless render wipe the live batch: the r7 major)."
|
|
)
|
|
settle_block = body[
|
|
body.index('if (ev.state === "idle" || ev.state === "error") {') : body.index(
|
|
'ev.state === "running"'
|
|
)
|
|
]
|
|
assert "liveToolCalls.clear()" in settle_block, (
|
|
"the one drain must sit inside the idle/error settle block — "
|
|
"anywhere else either leaks dead ids (gate stuck) or drains live "
|
|
"ones (gate forged open)."
|
|
)
|
|
cst_code = _strip_comments(cst_slice)
|
|
assert "liveToolCalls" not in cst_code, (
|
|
"closeStreamTransport must not touch the live set (r7): transport "
|
|
"death is not a retirement event — a stale id fails CLOSED, an "
|
|
"emptied set fails OPEN into the wipe. (The at-site ruling "
|
|
"comment may name it; the CODE may not.)"
|
|
)
|
|
tool_result_block = body[
|
|
body.index('case "tool_result":') : body.index(
|
|
"case ", body.index('case "tool_result":') + 10
|
|
)
|
|
]
|
|
assert "liveToolCalls.delete(" in tool_result_block, (
|
|
"tool_result must retire its call_id inside its own case arm."
|
|
)
|
|
for case_name in ('case "tool_pending":', 'case "tool_info":'):
|
|
case_block = body[body.index(case_name) : body.index("break;", body.index(case_name))]
|
|
assert "liveToolCalls.add(" in case_block, (
|
|
f"{case_name} must feed the live set inside its own case arm "
|
|
"(the live announce events are the ONLY producers)."
|
|
)
|
|
fetch_end = body.index("\n function ", fetch_start + 1)
|
|
fetch_body = body[fetch_start:fetch_end]
|
|
fetch_code = _strip_comments(fetch_body)
|
|
assert fetch_code.count("liveToolCalls") == 1, (
|
|
"refetchHistory may READ the live set exactly once (the gate) "
|
|
"and never write it — a render that touched liveness could "
|
|
"forge the very signal that guards it (the r6 lesson)."
|
|
)
|
|
# The await must be BOUNDED (r7): an accepted-but-never-answered
|
|
# /history would pin refetchesInFlight above zero for the life of
|
|
# the page and every heal would yield forever.
|
|
assert "if (histCtrl) histCtrl.abort();" in fetch_code
|
|
assert "deadlineHandle.dispose()" in fetch_code, (
|
|
"the fetch finally must retire its deadline through the module's "
|
|
"dispose() — direct state-slot pokes are the drift the shared "
|
|
"handle exists to prevent."
|
|
)
|
|
assert "createHistoryHandoffDeadline(" in fetch_code
|
|
assert "Promise.race([" in fetch_code and "deadlineHandle.promise" in fetch_code, (
|
|
"refetchHistory must have a logical deadline independent of abort — "
|
|
"older runtimes can lack AbortController and a request may ignore it."
|
|
)
|
|
# The seq stamp's PRODUCER must sit above the await, or the
|
|
# last-dispatch-wins gate is permanently vacuous (a stamp captured
|
|
# after the await always equals refetchSeq) — the twin of the
|
|
# refetchesInFlight bracket pin.
|
|
assert body.count("const seq = ++refetchSeq;") == 1, (
|
|
"refetchHistory must stamp its dispatch exactly once."
|
|
)
|
|
assert body.index("const seq = ++refetchSeq;", fetch_start) < awt, (
|
|
"the seq stamp must be captured BEFORE the await — captured "
|
|
"after, it always equals refetchSeq and the currency gate never "
|
|
"fires."
|
|
)
|
|
|
|
# The bound must actually be WIRED (r8): the signal must reach
|
|
# getJSON and getJSON must forward its init — the pinned abort/
|
|
# clearTimeout strings alone survive a dead bound.
|
|
assert "{ signal: histCtrl.signal }" in fetch_code, (
|
|
"the abort signal must reach the fetch — an unforwarded "
|
|
"controller aborts nothing and the await is unbounded again."
|
|
)
|
|
assert "function getJSON(url, init)" in body and (
|
|
'Object.assign({ credentials: "include" }, init || {})' in body
|
|
), (
|
|
"getJSON must forward its init while preserving credentials — "
|
|
"narrowing it back to getJSON(url) silently unwires the bound."
|
|
)
|
|
# destroy() must abort the in-flight fetch (dead-not-inert, the
|
|
# staleRetryTimer ruling applied to the r7 bound).
|
|
# Producer pins first — the destroy() consumer sweep below is
|
|
# satisfiable by an always-empty Set without them. ONE composite
|
|
# record per attempt: registering ctrl and deadline separately is the
|
|
# parallel-bookkeeping drift a future attempt site gets wrong.
|
|
assert body.count("histAttempts.add(attempt)") == 1, (
|
|
"every dispatch must register its composite {ctrl, deadline} record in the attempt Set."
|
|
)
|
|
assert body.count("histAttempts.delete(attempt)") == 1, (
|
|
"the fetch finally must release its own attempt record — without "
|
|
"the delete the Set grows for the life of the pane."
|
|
)
|
|
assert body.index("histAttempts.add(attempt)", fetch_start) < awt, (
|
|
"the attempt must be registered BEFORE the await."
|
|
)
|
|
assert fin < body.index("histAttempts.delete(attempt)", fetch_start), (
|
|
"the attempt release must sit in the fetch finally."
|
|
)
|
|
destroy_code = _strip_comments(destroy_slice)
|
|
assert "histAttempts.forEach" in destroy_code and ".abort()" in destroy_code, (
|
|
"destroy() must abort EVERY in-flight /history (a Set — a "
|
|
"newest-wins single slot left older overlapping fetches "
|
|
"unabortable); the 15s bound alone pins the destroyed closure "
|
|
"until it fires."
|
|
)
|
|
assert "attempt.deadline.dispose({ expire: true, resolve: true })" in destroy_code, (
|
|
"destroy must settle every logical deadline immediately (expired + "
|
|
"resolved, including when AbortController is unavailable) via the "
|
|
"module's dispose() — from the SAME composite record its abort "
|
|
"came from, never a parallel Set."
|
|
)
|
|
|
|
|
|
def test_coordinator_js_early_paints_pending_tool_calls():
|
|
"""The coord chat frontend must render a committed tool call on
|
|
``tool_pending`` — before the intent judge verdict + approval gate
|
|
resolve — reusing the idempotent ``appendToolBatch`` upgrade path so the
|
|
authoritative ``approve_request`` / ``tool_info`` morphs the same
|
|
construct in place. Guards the early-paint wiring (the #621 block) so a
|
|
refactor that drops the handler or the ``announce`` kicker branch surfaces
|
|
here instead of in production. String presence only — coord.js has no JS
|
|
test framework today."""
|
|
from pathlib import Path
|
|
|
|
coord_js = Path(__file__).resolve().parent.parent / (
|
|
"turnstone/console/static/coordinator/coordinator.js"
|
|
)
|
|
body = coord_js.read_text(encoding="utf-8")
|
|
assert 'case "tool_pending":' in body
|
|
assert "announce: true" in body
|
|
# Distinct "Evaluating" placeholder kicker for the pre-verdict shell.
|
|
assert "opts.announce" in body
|
|
assert '"Evaluating"' in body
|
|
|
|
|
|
def test_coordinator_js_early_paint_screen_reader_announce():
|
|
"""Coord screen-reader parity for the early paint: a committed tool call
|
|
routes to a POLITE off-screen announcer (not the assertive gate region,
|
|
not the messages log which is aria-live="off" mid-stream), and the
|
|
announced batch carries aria-busy until upgraded. Silent failures, so
|
|
pin both the JS wiring and the index.html region."""
|
|
from pathlib import Path
|
|
|
|
base = Path(__file__).resolve().parent.parent / "turnstone/console/static/coordinator"
|
|
coord_js = (base / "coordinator.js").read_text(encoding="utf-8")
|
|
|
|
# Dedicated polite announcer + helper, distinct from the assertive one. The
|
|
# markup is built by buildCoordChrome now (the standalone page went thin).
|
|
assert '"coord-sr-announcer-polite"' in coord_js
|
|
pos = coord_js.index('"coord-sr-announcer-polite"')
|
|
assert '"aria-live": "polite"' in coord_js[pos : pos + 200]
|
|
assert "function _announcePolite(" in coord_js
|
|
# Root-scoped now (de-globalized pane factory): the polite announcer is
|
|
# resolved off the pane root, not document.getElementById.
|
|
assert 'querySelector("#coord-sr-announcer-polite")' in coord_js
|
|
# tool_pending announces politely; the announce shell is marked busy.
|
|
assert "_announcePolite(_toolAnnounceText(ev.items" in coord_js
|
|
assert 'if (opts.announce) batch.setAttribute("aria-busy", "true")' in coord_js
|
|
|
|
|
|
def test_coordinator_de_globalized_to_pane_factory():
|
|
"""Step 4a: coordinator.js is a multi-instantiable pane factory, not a
|
|
page-global IIFE. ``createCoordinatorPane(root, wsId)`` root-scopes every
|
|
lookup, owns its lifecycle (connect / destroy / onLogin), and exposes no
|
|
page-global ``window.coord*`` / ``onLoginSuccess`` collision point; the
|
|
standalone page bootstraps one pane filling the body."""
|
|
from pathlib import Path
|
|
|
|
base = Path(__file__).resolve().parent.parent / "turnstone/console/static/coordinator"
|
|
coord_js = (base / "coordinator.js").read_text(encoding="utf-8")
|
|
index_html = (base / "index.html").read_text(encoding="utf-8")
|
|
|
|
assert "function createCoordinatorPane(root, wsId, opts) {" in coord_js
|
|
# Step 5e.0: coordinator.js is a real ES module the shell imports — the bare
|
|
# `export` is its only seam. No `window.*` bridge (unlike interactive.js,
|
|
# whose classic ui/static/app.js still needs the global): both the console
|
|
# shell and the standalone bootstrap import the factory.
|
|
assert "export { createCoordinatorPane };" in coord_js
|
|
assert "window.createCoordinatorPane" not in coord_js, (
|
|
"no dead window bridge — both consumers import the factory"
|
|
)
|
|
assert "function destroy() {" in coord_js, "a pane must have a teardown path"
|
|
# ws_id is a constructor arg now, not read off <html>; lookups are root-scoped.
|
|
assert "document.documentElement.dataset.wsId" not in coord_js
|
|
assert "document.getElementById(" not in coord_js, (
|
|
"pane code must root-scope, not getElementById"
|
|
)
|
|
# No page-global collision points (multi-instance safe).
|
|
for gone in ("window.coordSend", "window.coordCloseSession", "window.onLoginSuccess"):
|
|
assert gone not in coord_js, f"de-globalized: {gone} must be gone"
|
|
# Standalone page = one pane filling the body, bootstrapped by a MODULE that
|
|
# imports the factory (a classic eager IIFE would run before the deferred
|
|
# coordinator module loaded it); the inline close onclick is gone.
|
|
assert '<script type="module">' in index_html
|
|
assert (
|
|
'import { createCoordinatorPane } from "/static/coordinator/coordinator.js"' in index_html
|
|
)
|
|
assert "createCoordinatorPane(document.body" in index_html
|
|
assert 'onclick="coordCloseSession()"' not in index_html
|
|
|
|
|
|
def test_coordinator_chrome_builder_and_thin_page():
|
|
"""Step 4b: the coordinator chrome is built programmatically (createElement,
|
|
no innerHTML) by buildCoordChrome, so the SAME factory serves the standalone
|
|
page and a console pane. The standalone page is now a thin bootstrap passing
|
|
{standalone:true}; its static chrome + inline <style> are gone (CSS migrated)."""
|
|
from pathlib import Path
|
|
|
|
base = Path(__file__).resolve().parent.parent / "turnstone/console/static/coordinator"
|
|
coord_js = (base / "coordinator.js").read_text(encoding="utf-8")
|
|
index_html = (base / "index.html").read_text(encoding="utf-8")
|
|
|
|
assert "function buildCoordChrome(root, opts)" in coord_js
|
|
assert "buildCoordChrome(root, opts);" in coord_js, "the factory must build its own chrome"
|
|
assert ".innerHTML" not in coord_js, "the chrome builder must stay innerHTML-free"
|
|
# Pane-hosted close routes through opts.onClose (close the pane), not a redirect.
|
|
assert "opts.onClose" in coord_js, "coordCloseSession must close the pane when pane-hosted"
|
|
# Standalone page is thin: static chrome gone, links the migrated stylesheet,
|
|
# bootstraps with the standalone flag (adds back-link / theme / toast).
|
|
assert 'id="coord-header"' not in index_html, (
|
|
"static chrome must be gone (the factory builds it)"
|
|
)
|
|
assert "coord-chrome.css" in index_html, "standalone must link the migrated chrome CSS"
|
|
assert "standalone: true" in index_html
|
|
assert (base / "coord-chrome.css").exists(), "the migrated chrome stylesheet must exist"
|
|
|
|
|
|
def test_coordinator_close_409_uses_plain_retry_copy():
|
|
from pathlib import Path
|
|
|
|
body = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "turnstone/console/static/coordinator/coordinator.js"
|
|
).read_text(encoding="utf-8")
|
|
start = body.index("async function coordCloseSession()")
|
|
end = body.index("// ------------------------------------------------------------------", start)
|
|
close = body[start:end]
|
|
|
|
assert "resp.status === 409" in close
|
|
assert (
|
|
"Conversation history is still being saved. Try ending the session again shortly." in close
|
|
)
|
|
assert "resumeSse();" in close, "a refused close must retain and resume the live pane"
|
|
|
|
|
|
def test_coord_child_links_open_interactive_pane():
|
|
"""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
|
|
|
|
coord_js = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "turnstone/console/static/coordinator/coordinator.js"
|
|
).read_text(encoding="utf-8")
|
|
# 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.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
|
|
assert 'data-ws-id="' in coord_js # renderToolOutput (string)
|
|
assert 'data-node-id="' in coord_js
|
|
# The /node/{id}/?ws_id= href fallback must remain for the standalone page.
|
|
assert '"/node/"' in coord_js
|
|
|
|
|
|
def test_coordinator_js_gates_send_on_cross_user_busy():
|
|
"""The coordinator pane mirrors the interactive pane's shared-workstream
|
|
send gate: while another participant's turn is in flight it blocks this
|
|
viewer's send (the UX complement to the server-side 409). String-presence
|
|
guard — coord.js has no JS test framework."""
|
|
from pathlib import Path
|
|
|
|
coord_js = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "turnstone/console/static/coordinator/coordinator.js"
|
|
).read_text(encoding="utf-8")
|
|
# tracks the acting user from state_change, clears on settle
|
|
assert "actingUserId = ev.acting_user_id;" in coord_js
|
|
assert "actingUserId = null;" in coord_js
|
|
# compares against the viewer's own id and drives the composer hard block
|
|
assert 'sessionStorage.getItem("ts.user_id")' in coord_js
|
|
assert "actingUserId !== me" in coord_js
|
|
assert "composer.setSendBlocked(" in coord_js
|
|
assert "function reconcileSendBlock()" in coord_js
|
|
# reactive 409 fallback — the fetch-stage conversion and the status ARM
|
|
# both live in the shared helper (composer_queue.postAndSettleSend /
|
|
# settleSendResponse), one implementation for both panes and for both of
|
|
# each pane's send flows. The pane owns only the request, so its
|
|
# edit-and-resend flow can no longer miss the conversion and report a
|
|
# refused resend as a connection error.
|
|
assert "cross_user_interjection" not in coord_js, (
|
|
"the 409 conversion must not be re-derived per pane"
|
|
)
|
|
assert coord_js.count("postAndSettleSend(") == 2, "composer send + edit-and-resend"
|
|
helper = (
|
|
Path(__file__).resolve().parents[1] / "turnstone/shared_static/composer_queue.js"
|
|
).read_text(encoding="utf-8")
|
|
assert "response.status === 409" in helper
|
|
assert 'status: "cross_user_interjection"' in helper
|
|
assert 'status === "cross_user_interjection"' in helper
|
|
|
|
|
|
def test_task_status_vocabulary_is_pinned_across_every_surface():
|
|
"""A new task status must be classified on EVERY surface or fail CI.
|
|
|
|
``_TASK_STATUS_IS_OPEN`` in coordinator_client.py is the source of
|
|
truth; ``_TASK_STATUSES`` and ``TASK_OPEN_STATUSES`` derive from it,
|
|
so the observer's trigger set can't drift. The three surfaces that
|
|
cannot derive from Python — the model-facing JSON enum, the JS label
|
|
map, and the CSS chip rules — are pinned here instead. Without this
|
|
a new status silently defaults to "not open" in the observer (no
|
|
nudge ever fires for it) and to the neutral unlabelled chip in the
|
|
pane, with no test or type error anywhere.
|
|
"""
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from turnstone.console.coordinator_client import _TASK_STATUS_IS_OPEN, TASK_OPEN_STATUSES
|
|
|
|
root = Path(__file__).resolve().parent.parent
|
|
statuses = set(_TASK_STATUS_IS_OPEN)
|
|
|
|
# The derived sets stay consistent with the classification.
|
|
assert {s for s, is_open in _TASK_STATUS_IS_OPEN.items() if is_open} == TASK_OPEN_STATUSES
|
|
|
|
# 1. The model-facing tool schema enum.
|
|
schema = json.loads((root / "turnstone/tools/tasks.json").read_text(encoding="utf-8"))
|
|
assert set(schema["parameters"]["properties"]["status"]["enum"]) == statuses
|
|
|
|
# 2. The JS label map — every status needs operator-facing text, or
|
|
# the chip renders the raw machine string.
|
|
coord_js = (root / "turnstone/console/static/coordinator/coordinator.js").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
labels_block = coord_js.split("const TASK_STATUS_LABELS = {", 1)[1].split("};", 1)[0]
|
|
labelled = {line.split(":", 1)[0].strip() for line in labels_block.splitlines() if ":" in line}
|
|
assert labelled == statuses, "TASK_STATUS_LABELS must cover exactly the Python statuses"
|
|
|
|
# 3. The CSS chip rules. ``pending`` intentionally has no rule (it
|
|
# falls back to the neutral base chip), so every OTHER status needs
|
|
# one or it is visually indistinguishable from pending.
|
|
css = (root / "turnstone/console/static/coordinator/coord-chrome.css").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
for status in statuses - {"pending"}:
|
|
assert f".status-{status}" in css, f"status {status!r} needs a chip rule"
|
|
|
|
|
|
def test_task_field_caps_are_pinned_across_every_surface():
|
|
"""The cap the server enforces and the cap the model is told must be
|
|
the same number.
|
|
|
|
``TASK_TITLE_MAX`` / ``TASK_NOTE_MAX`` in metacognition.py are the
|
|
source of truth: the write path rejects on them and, since the
|
|
prepare-layer gate landed, so does the approval path. The schema
|
|
states the same limit twice per field — a ``maxLength`` a provider
|
|
may enforce client-side, and prose the model reads — and neither can
|
|
derive from Python, so they are pinned here.
|
|
|
|
Drift is silent and one-directional: raise the schema alone and the
|
|
model is invited to send a title the server then refuses, which is
|
|
the two-surfaces-disagree class the display sanitiser work spent
|
|
this branch removing.
|
|
"""
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from turnstone.core.metacognition import TASK_NOTE_MAX, TASK_TITLE_MAX
|
|
|
|
root = Path(__file__).resolve().parent.parent
|
|
props = json.loads((root / "turnstone/tools/tasks.json").read_text(encoding="utf-8"))[
|
|
"parameters"
|
|
]["properties"]
|
|
|
|
for field, cap in (("title", TASK_TITLE_MAX), ("note", TASK_NOTE_MAX)):
|
|
assert props[field]["maxLength"] == cap, (
|
|
f"{field} maxLength must equal TASK_{field.upper()}_MAX ({cap})"
|
|
)
|
|
# The prose is the half the model actually reads on providers
|
|
# that ignore ``maxLength``.
|
|
assert f"Max {cap} chars" in props[field]["description"], (
|
|
f"{field} description must state the {cap}-char cap"
|
|
)
|
|
|
|
|
|
def test_needs_user_chip_darkens_warn_for_contrast():
|
|
"""Raw ``var(--warn)`` on ``--warn-soft`` measures ~3.6:1 in the light
|
|
theme — under the 4.5:1 AA floor that applies at this chip's
|
|
10px/600/uppercase, and the worst of the four status chips on the one
|
|
status whose whole purpose is to be noticed. The house color-mix
|
|
darkening (the approval disclosure's ``rec-*`` scheme) puts it at
|
|
~5.0:1."""
|
|
from pathlib import Path
|
|
|
|
css = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "turnstone/console/static/coordinator/coord-chrome.css"
|
|
).read_text(encoding="utf-8")
|
|
block = css.split(".task-row .status-needs_user {", 1)[1].split("}", 1)[0]
|
|
assert "color-mix(in srgb, var(--warn) 70%, var(--ink-2))" in block, (
|
|
"needs_user chip text must darken --warn or it fails AA in the light theme"
|
|
)
|
|
assert "color: var(--warn);" not in block
|
|
|
|
|
|
def test_task_note_wraps_in_both_surfaces():
|
|
"""The same model-authored note renders in the sidebar and in the
|
|
idle-tasks card. A ``needs_user`` ask is exactly where an
|
|
unbroken token lands (a URL, a path), and flex items default to
|
|
``min-width: auto`` — so without word-break the sidebar row is forced
|
|
past its fixed width."""
|
|
from pathlib import Path
|
|
|
|
root = Path(__file__).resolve().parent.parent
|
|
pane_css = (root / "turnstone/console/static/coordinator/coord-chrome.css").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
card_css = (root / "turnstone/shared_static/chat.css").read_text(encoding="utf-8")
|
|
|
|
pane_block = pane_css.split(".task-row .meta {", 1)[1].split("}", 1)[0]
|
|
card_block = card_css.split(".msg.idle-tasks .msg-idle-note {", 1)[1].split("}", 1)[0]
|
|
assert "word-break: break-word" in pane_block
|
|
assert "word-break: break-word" in card_block
|
|
|
|
|
|
def test_idle_cards_share_one_dom_builder():
|
|
"""The two cards share grouped CSS *and* a DOM builder — pinned so a
|
|
change to one card's accessibility attributes or scroll behaviour
|
|
cannot silently skip the other."""
|
|
from pathlib import Path
|
|
|
|
body = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "turnstone/console/static/coordinator/coordinator.js"
|
|
).read_text(encoding="utf-8")
|
|
assert "function buildIdleCard(" in body
|
|
for caller in ("function appendIdleChildren(", "function appendIdleTasks("):
|
|
idx = body.index(caller)
|
|
assert "buildIdleCard(" in body[idx : idx + 1400], (
|
|
f"{caller} must delegate to the shared builder"
|
|
)
|
|
|
|
|
|
def test_idle_children_card_carries_an_ident_column():
|
|
"""Both idle cards need an identifier column, because a name is not an
|
|
identity — and on the children card the ident IS the row now.
|
|
|
|
Fresh ``idle_children`` meta is ids-and-states only (card honesty:
|
|
the card records what the model was told, and the delivered body
|
|
carries no child names), so ``appendIdleChildren`` renders ident +
|
|
state and builds a name cell ONLY for old persisted rows that still
|
|
carry one — those nudges did deliver the name to the model at the
|
|
time. The mapping must never invent a name (no ``|| c.ws_id``
|
|
fallback: that would render a "name" the model never received, and
|
|
duplicate the ident column). The 8-char slice matches the prefix
|
|
the model-facing body's bullet renders, so the operator and the
|
|
model name the same child the same way. The grouped CSS rule is
|
|
pinned too: an ident column styled for one card only butts against
|
|
the text on the other.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
root = Path(__file__).resolve().parent.parent
|
|
body = (root / "turnstone/console/static/coordinator/coordinator.js").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
idx = body.index("function appendIdleChildren(")
|
|
block = body[idx : idx + 1400]
|
|
assert 'ident: c && c.ws_id ? String(c.ws_id).slice(0, 8) : ""' in block, (
|
|
"appendIdleChildren must pass the ws_id prefix as `ident` — it is "
|
|
"the row's only identifier on fresh (nameless) meta."
|
|
)
|
|
assert 'name: c && c.name ? String(c.name) : ""' in block, (
|
|
"the name cell is old-rows-only: render a name exactly when the "
|
|
"persisted row carries one, and never fall back to the ws_id — a "
|
|
"fresh row must render ident + state with no name cell."
|
|
)
|
|
assert "c.name || c.ws_id" not in block, (
|
|
"the id-as-name fallback chain must not return: it renders a "
|
|
'"name" the model never received on fresh rows.'
|
|
)
|
|
css = (root / "turnstone/shared_static/chat.css").read_text(encoding="utf-8")
|
|
assert (
|
|
".msg.idle-children .msg-idle-child-ident,\n.msg.idle-tasks .msg-idle-child-ident" in css
|
|
), "the ident column must be styled on BOTH idle cards, not tasks-only"
|
|
|
|
|
|
def test_idle_tasks_card_uses_the_shared_status_label():
|
|
"""The conversation card and the sidebar chip must show one name per
|
|
state — ``in_progress`` in one place and ``in progress`` in the other
|
|
is two names for one thing on one screen."""
|
|
from pathlib import Path
|
|
|
|
body = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "turnstone/console/static/coordinator/coordinator.js"
|
|
).read_text(encoding="utf-8")
|
|
assert "function taskStatusLabel(" in body
|
|
idx = body.index("function appendIdleTasks(")
|
|
assert "taskStatusLabel(" in body[idx : idx + 1400]
|
|
idx_row = body.index("function renderTaskRow(")
|
|
assert "taskStatusLabel(" in body[idx_row : idx_row + 1400]
|