Files
turnstone/tests/test_history_handoff_js.py
T
Patrick Buckley 480a1426b3 Fail-closed history-commit handoff (#1005)
* 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
2026-08-11 04:18:36 -07:00

1334 lines
50 KiB
Python

"""Behavioral contracts for user-turn projection and handoff recovery JS."""
# Percent interpolation keeps the extracted JavaScript's many literal braces
# readable; converting these harnesses to ``str.format`` would require escaping
# every object/function body and make source-to-test review substantially harder.
# ruff: noqa: UP031
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
from tests._js_harness_helpers import extract_braced as _extract_braced
from tests._js_harness_helpers import strip_js_comments as _strip_comments
_ROOT = Path(__file__).resolve().parent.parent
_INTERACTIVE = _ROOT / "turnstone/shared_static/interactive.js"
_COORDINATOR = _ROOT / "turnstone/console/static/coordinator/coordinator.js"
_SHARED_HANDOFF = _ROOT / "turnstone/shared_static/history_handoff.js"
_QUEUE = _ROOT / "turnstone/shared_static/composer_queue.js"
_TOOL_PROJECTION = _ROOT / "turnstone/shared_static/tool_projection.js"
_PY_SDK = _ROOT / "turnstone/sdk/server.py"
_PY_CHANNEL = _ROOT / "turnstone/channels/_sse.py"
_TS_SDK = _ROOT / "sdk/typescript/src/server.ts"
def _run_module(tmp_path: Path, source: str) -> None:
script = tmp_path / "contract.mjs"
script.write_text(source, encoding="utf-8")
try:
proc = subprocess.run(
["node", str(script)],
capture_output=True,
text=True,
timeout=15,
check=False,
)
except FileNotFoundError:
pytest.skip("node binary not available on PATH")
assert proc.returncode == 0, f"stdout={proc.stdout!r} stderr={proc.stderr!r}"
# ``_extract_braced`` is the shared comment-and-string-aware brace walker
# from tests/_js_harness_helpers — one implementation for every suite, so
# the per-suite walkers cannot diverge on comment handling or bounds.
def _as_function(source: str, signature: str) -> str:
extracted = _extract_braced(source, signature).strip()
if extracted.startswith("function "):
return extracted
return "function " + extracted
def test_interactive_stale_backstop_waits_for_replay_tail_runtime(
tmp_path: Path,
) -> None:
"""A replay prelude idle must not split its canonical backlog around history."""
source = _INTERACTIVE.read_text(encoding="utf-8")
begin_replay = _as_function(source, " _beginReplayQuiesce(token) {")
end_replay = _as_function(source, " _endReplayQuiesce(token) {")
defer_backstop = _as_function(source, " _deferStaleHistoryBackstop() {")
handle_event = _as_function(source, " handleEvent(evt) {")
# These are the race owners that can change after a synthetic replay_ok
# idle queues the microtask but before the canonical backlog finishes.
for guard in (
"queueMicrotask(() => {",
"this.wsId !== staleWs",
"staleToken !== this._historyLoadToken",
"!this._historyStale",
"this._replayQueue",
"this.busy",
"this.currentAssistantEl",
"this.currentReasoningEl",
"this._pendingTruncatedResync",
"this._truncatedFromCursor != null",
"this._resyncTimer != null",
"!this.el",
"!this.el.isConnected",
):
assert guard in defer_backstop
script = """
function resetCompactionHolder() {}
function streamingRender(body, text) { body.textContent = text; }
function streamingRenderFinalize(body, text) { body.textContent = text; }
"""
script += "\n" + begin_replay
script += "\n" + end_replay
script += "\n" + defer_backstop
script += "\n" + handle_event
script += r"""
const sentinel = "DUPE-runtime";
let backstops = 0;
let releaseHeal = null;
const pane = {
wsId: "ws-1",
_historyLoadToken: 7,
_historyStale: true,
_replayQueue: null,
_staleBackstopMicrotaskPending: false,
_pendingTruncatedResync: false,
_truncatedFromCursor: null,
_resyncTimer: null,
busy: false,
currentAssistantEl: null,
currentAssistantBodyEl: null,
currentReasoningEl: null,
contentBuffer: "",
_cancelTimeout: null,
_forceTimeout: null,
_compaction: {},
_streamHealth: { renderThrows: 0 },
_actingUserId: null,
pendingApproval: false,
el: { isConnected: true },
inputEl: { focus() {} },
_host: { isFocused() { return false; } },
assistantRows: [],
userRows: [],
setBusy(value) { this.busy = value; },
_attachRetryToLastAssistant() {},
_acceptUserTurn(evt) { this.userRows.push(evt.content); },
removeThinkingIndicator() {},
removeEmptyState() {},
scrollToBottom() {},
_newAssistantBubble() {
const body = { textContent: "" };
this.currentAssistantBodyEl = body;
this.currentAssistantEl = { body };
this.assistantRows.push(body);
},
replayHistory() {
this.assistantRows = [{ textContent: sentinel }];
this._historyStale = false;
},
_refetchHistory(_wsId, token) {
backstops += 1;
return new Promise((resolve) => {
releaseHeal = () => {
this.replayHistory();
this._endReplayQuiesce(token);
resolve();
};
});
},
_beginReplayQuiesce: _beginReplayQuiesce,
_endReplayQuiesce: _endReplayQuiesce,
_deferStaleHistoryBackstop: _deferStaleHistoryBackstop,
handleEvent: handleEvent,
};
// This is the production replay_ok ordering: its synthetic current-state idle
// precedes the canonical ring slice for the turn that landed during /history.
pane._replayQueue = {
token: 7,
events: [
{ type: "state_change", state: "idle" },
{ type: "user_turn", content: "gap" },
{ type: "content", text: sentinel },
{ type: "stream_end" },
{ type: "state_change", state: "idle" },
],
};
pane._endReplayQuiesce(7);
if (backstops !== 0)
throw new Error("synthetic idle refetched before the canonical backlog tail");
if (pane._replayQueue !== null)
throw new Error("canonical backlog was diverted into a replacement queue");
if (pane.userRows.join("|") !== "gap")
throw new Error("canonical user turn was not delivered in FIFO order");
if (
pane.assistantRows.length !== 1 ||
pane.assistantRows[0].textContent !== sentinel
)
throw new Error("canonical content did not paint exactly once before heal");
await Promise.resolve();
if (backstops !== 1)
throw new Error("multiple idle edges did not collapse to one stale backstop");
if (!pane._replayQueue || pane._replayQueue.events.length !== 0)
throw new Error("stale backstop did not quiesce its delayed history repaint");
if (typeof releaseHeal !== "function")
throw new Error("delayed history repaint was not captured");
releaseHeal();
await Promise.resolve();
if (pane._replayQueue !== null)
throw new Error("successful stale heal left replay quiesced");
if (
pane.assistantRows.length !== 1 ||
pane.assistantRows[0].textContent !== sentinel
)
throw new Error("history repaint plus replay backlog duplicated assistant content");
"""
_run_module(tmp_path, script)
def test_history_handoff_attempt_budget_runtime(tmp_path: Path) -> None:
"""Four automatic attempts park; explicit manual recovery remains usable."""
_run_module(
tmp_path,
f"""
import {{
createHistoryHandoffDeadline,
HISTORY_HANDOFF_FETCH_TIMEOUT_MS,
HISTORY_HANDOFF_MAX_ATTEMPTS,
historyHandoffAttemptAllowed,
}} from {json.dumps(_SHARED_HANDOFF.as_uri())};
if (HISTORY_HANDOFF_MAX_ATTEMPTS !== 4) throw new Error("attempt budget");
if (HISTORY_HANDOFF_FETCH_TIMEOUT_MS !== 15000) throw new Error("deadline");
for (let attempts = 0; attempts < 4; attempts++) {{
if (!historyHandoffAttemptAllowed(attempts, false, false))
throw new Error("automatic attempt " + attempts + " was blocked");
}}
if (historyHandoffAttemptAllowed(4, false, false))
throw new Error("fifth automatic attempt was allowed");
if (historyHandoffAttemptAllowed(0, true, false))
throw new Error("manual-only state failed open");
if (!historyHandoffAttemptAllowed(4, true, true))
throw new Error("manual retry path was lost");
let expired = 0;
const deadline = createHistoryHandoffDeadline(() => expired++, 5);
const stalled = new Promise(() => {{}});
const result = await Promise.race([
stalled,
deadline.promise.then(() => "deadline"),
]);
if (result !== "deadline" || expired !== 1 || !deadline.state.expired)
throw new Error("logical deadline did not settle an unresolved fetch");
// dispose() owns retirement: a cancel (expire + resolve) marks the attempt
// dead, releases the race immediately, and never fires onExpire; it is
// idempotent, and a natural-settle dispose() just drops the timer + slot.
let cancelledExpires = 0;
const cancelled = createHistoryHandoffDeadline(() => cancelledExpires++, 60000);
cancelled.dispose({{ expire: true, resolve: true }});
await cancelled.promise;
if (
!cancelled.state.expired ||
cancelled.state.timer != null ||
cancelled.state.settle != null ||
cancelledExpires !== 0
)
throw new Error("cancel-dispose did not retire the deadline in place");
cancelled.dispose({{ expire: true, resolve: true }});
const settled = createHistoryHandoffDeadline(() => {{}}, 60000);
settled.dispose();
if (
settled.state.expired ||
settled.state.timer != null ||
settled.state.settle != null
)
throw new Error("natural-settle dispose must retire without expiring");
""",
)
def test_tool_occurrence_pairing_runtime(tmp_path: Path) -> None:
"""Reused, duplicate, and tail-leading ids retain structural row identity."""
_run_module(
tmp_path,
f"""
import {{
enqueueToolOccurrence,
indexHistoryToolOutcomes,
indexLatestToolRow,
shiftToolOccurrence,
}} from {json.dumps(_TOOL_PROJECTION.as_uri())};
const first = {{ role: "assistant", tool_calls: [{{ id: "c", name: "A" }}] }};
const second = {{ role: "assistant", tool_calls: [{{ id: "c", name: "B" }}] }};
const messages = [
first,
{{ role: "tool", tool_call_id: "c", content: "one" }},
second,
{{ role: "tool", tool_call_id: "c", content: "two", is_error: true }},
];
const indexed = indexHistoryToolOutcomes(messages);
if (indexed.get(first)[0] !== "ok" || indexed.get(second)[0] !== "error")
throw new Error("reused id inherited another turn's outcome");
const duplicate = {{
role: "assistant",
tool_calls: [{{ id: "dup", name: "A" }}, {{ id: "dup", name: "B" }}],
}};
const duplicateMessages = [
duplicate,
{{ role: "tool", tool_call_id: "dup", content: "first" }},
{{ role: "tool", tool_call_id: "dup", content: "second", denied: true }},
];
const duplicateOutcomes = indexHistoryToolOutcomes(duplicateMessages).get(duplicate);
if (duplicateOutcomes[0] !== "ok" || duplicateOutcomes[1] !== "denied")
throw new Error("same-batch duplicate occurrences collapsed");
const bounded = {{ role: "assistant", tool_calls: [{{ id: "c", name: "new" }}] }};
const boundedMessages = [
{{ role: "tool", tool_call_id: "c", content: "cut-off old", is_error: true }},
bounded,
{{ role: "tool", tool_call_id: "c", content: "new ok" }},
];
if (indexHistoryToolOutcomes(boundedMessages).get(bounded)[0] !== "ok")
throw new Error("leading orphan poisoned bounded-history batch");
// Non-turn rows interleaved inside a batch (a mid-turn system message, a
// second writer's append) are skipped, not window terminators — a
// fully-resolved batch must never index as a permanent orphan. Only the
// next conversational turn (assistant above; user here) ends the window.
const interleaved = {{
role: "assistant",
tool_calls: [{{ id: "i1", name: "A" }}, {{ id: "i2", name: "B" }}],
}};
const interleavedMessages = [
interleaved,
{{ role: "tool", tool_call_id: "i1", content: "one" }},
{{ role: "system", content: "operator note" }},
{{ role: "tool", tool_call_id: "i2", content: "two", is_error: true }},
];
const interleavedOutcomes =
indexHistoryToolOutcomes(interleavedMessages).get(interleaved);
if (interleavedOutcomes[0] !== "ok" || interleavedOutcomes[1] !== "error")
throw new Error("interleaved system row truncated the batch result window");
const userBounded = {{ role: "assistant", tool_calls: [{{ id: "u1", name: "A" }}] }};
const userBoundedMessages = [
userBounded,
{{ role: "user", content: "next turn" }},
{{ role: "tool", tool_call_id: "u1", content: "stray twin" }},
];
if (indexHistoryToolOutcomes(userBoundedMessages).get(userBounded)[0] !== undefined)
throw new Error("a user turn no longer bounds the batch result window");
const rowsById = new Map();
const resultsById = new Map();
const oldRow = {{ name: "A" }};
const newRow = {{ name: "B" }};
indexLatestToolRow(rowsById, resultsById, "c", oldRow);
resultsById.set("c", {{ output: "one" }});
indexLatestToolRow(rowsById, resultsById, "c", newRow);
if (rowsById.get("c") !== newRow || resultsById.has("c"))
throw new Error("newest reused-id row did not take ownership");
const occurrences = new Map();
enqueueToolOccurrence(occurrences, "dup", {{ row: "r1", output: "first" }});
enqueueToolOccurrence(occurrences, "dup", {{ row: "r2", output: "second" }});
const paired1 = shiftToolOccurrence(occurrences, "dup");
const paired2 = shiftToolOccurrence(occurrences, "dup");
if (paired1.row !== "r1" || paired1.output !== "first" ||
paired2.row !== "r2" || paired2.output !== "second" ||
occurrences.has("dup"))
throw new Error("occurrence queue did not preserve FIFO identity");
""",
)
def test_browser_tool_projection_reducers_runtime(tmp_path: Path) -> None:
"""Both real browser reducers replace provisional output on the newest row."""
source = _COORDINATOR.read_text(encoding="utf-8")
handle_event = _as_function(source, " function handleEvent(ev) {")
append_result = _as_function(
source,
" function appendToolResult(name, callId, output, isError, opts) {",
)
append_to_row = _as_function(
source,
" function _appendResultToRow(row, output, isError, opts) {",
)
append_batch = _as_function(
source,
" function appendToolBatch(items, opts) {",
)
_run_module(
tmp_path,
"""
import {
acceptedToolEventAlreadyRendered,
indexLatestToolRow,
recordAcceptedToolEvent,
shouldRefreshTasksForToolResult,
} from %(projection)s;
function classList(...initial) {
const values = new Set(initial);
return {
add(...names) { names.forEach((name) => values.add(name)); },
remove(...names) { names.forEach((name) => values.delete(name)); },
contains(name) { return values.has(name); },
};
}
function makeNode(kind) {
const node = {
kind,
output: "",
isError: false,
dataset: {},
attributes: {},
classList: classList(),
parent: null,
isConnected: false,
children: [],
appendChild(child) {
child.parent = this;
child.isConnected = true;
this.children.push(child);
return child;
},
setAttribute(name, value) { this.attributes[name] = String(value); },
removeAttribute(name) { delete this.attributes[name]; },
remove() {
if (this.parent) {
const at = this.parent.children.indexOf(this);
if (at >= 0) this.parent.children.splice(at, 1);
}
this.isConnected = false;
},
querySelector(selector) {
if (selector === ".conv-row-result")
return this.children.find((child) => child.classList.contains("conv-row-result")) || null;
return null;
},
querySelectorAll(selector) {
if (selector === ".conv-row-result")
return this.children.filter((child) => child.classList.contains("conv-row-result"));
return [];
},
closest(selector) {
return selector === ".conv-batch" ? this.parent : null;
},
};
return node;
}
function makeRow(callId) {
const batch = makeNode("batch");
batch.classList = classList("conv-batch", "conv-batch--approved");
const row = makeNode("row");
row.dataset.callId = callId;
batch.appendChild(row);
return { batch, row };
}
function _tryMcpErrorBlock() { return null; }
function buildConvResult(output, opts) {
const node = makeNode("output");
node.output = output;
node.isError = !!(opts && opts.isError);
node.classList.add("conv-row-result");
return node;
}
let previewOpens = 0;
function buildPreviewChip(preview, onOpen) {
const node = makeNode("preview");
node.preview = preview;
node.open = onOpen;
return node;
}
const window = {
TS_SHELL: { openPreview() { previewOpens++; } },
open() { previewOpens++; },
};
function _unsetBatchRunningIfAllResults() {}
function _scheduleScroll() {}
// The mapped-row scenarios below must never fall to the orphan path; the last
// scenario opts in explicitly to exercise it.
let allowOrphanResult = false;
function appendMsg(_role, html, opts) {
if (!allowOrphanResult) throw new Error("unexpected orphan result path");
const el = makeNode("msg");
el.output = html;
el.dataset.callId = (opts && opts.callId) || "";
messagesEl.appendChild(el);
return el;
}
function renderToolOutput(output) {
if (!allowOrphanResult) throw new Error("unexpected orphan result path");
return String(output || "");
}
function buildConvBatchShell() {
const batch = makeNode("batch");
batch.classList = classList("conv-batch");
return batch;
}
function _renderBatchRow(item) {
const row = makeNode("row");
row.classList = classList("conv-row");
row.dataset.callId = item.call_id || "";
row.dataset.funcName = item.func_name || "tool";
return row;
}
function _pickBatchTier() { return ""; }
function indexLabel() { return ""; }
function batchKicker() { return "Tool"; }
function _pendingKickerText() { return "Pending"; }
function _approvalAriaLabel() { return "Approval required"; }
function _buildBatchActions() { return makeNode("actions"); }
function _buildStatusPill() { return makeNode("status"); }
function _refreshRowStatus() {}
function _appendVerdictLineTo() {}
function _appendJudgePendingLineTo() {}
function _announceAssertive() {}
function _announcePolite() {}
function _toolAnnounceText() { return "tool"; }
const toolRows = new Map();
const latestToolRowElements = new Map();
const toolResultNodes = new Map();
const renderedToolEventIds = new Set();
const liveToolCalls = new Set();
const judgeVerdicts = new Map();
const messagesEl = makeNode("messages");
let activeBatch = null;
let taskRefreshes = 0;
function loadTasksDebounced() { taskRefreshes++; }
const _appendResultToRow = %(append_to_row)s;
const appendToolResult = %(append_result)s;
const appendToolBatch = %(append_batch)s;
const handleEvent = %(handle)s;
// Exercise the real live admission path. An unresolved replay is the same
// occurrence and upgrades in place; a completed row with a provider-reused id
// starts a fresh batch, whose accepted output cannot mutate the older row.
handleEvent({
type: "tool_pending", items: [{ call_id: "live-reuse", func_name: "A" }],
});
const unresolvedBatch = toolRows.get("live-reuse").batch;
handleEvent({
type: "tool_info", items: [{ call_id: "live-reuse", func_name: "A" }],
});
if (messagesEl.children.length !== 1 || toolRows.get("live-reuse").batch !== unresolvedBatch)
throw new Error("coordinator unresolved occurrence did not upgrade in place");
handleEvent({
type: "tool_result", accepted: true, _event_id: 1,
call_id: "live-reuse", name: "A", output: "old final",
});
const oldLiveRow = toolRows.get("live-reuse").row;
const oldLiveOutput = oldLiveRow.children.find((node) => node.kind === "output");
handleEvent({
type: "tool_info", items: [{ call_id: "live-reuse", func_name: "B" }],
});
const newLiveRow = toolRows.get("live-reuse").row;
if (messagesEl.children.length !== 2 || newLiveRow === oldLiveRow)
throw new Error("coordinator completed reused id did not create a new live batch");
handleEvent({
type: "tool_result", accepted: true, _event_id: 2,
call_id: "live-reuse", name: "B", output: "new final",
});
if (!oldLiveOutput || oldLiveOutput.output !== "old final" || !oldLiveOutput.isConnected)
throw new Error("coordinator reused live id mutated the completed prior row");
const newLiveOutput = newLiveRow.children.find((node) => node.kind === "output");
if (!newLiveOutput || newLiveOutput.output !== "new final")
throw new Error("coordinator reused live id missed the newest row");
// /history calls the same admission function once per assistant occurrence.
// Resolved rows must always append even when the provider reuses the id.
const historyOne = appendToolBatch(
[{ call_id: "history-reuse", func_name: "History A" }],
{ resolved: { approved: true } },
);
const historyOneRow = historyOne.children.find((node) => node.kind === "row");
_appendResultToRow(historyOneRow, "history old", false, { accepted: true });
const historyTwo = appendToolBatch(
[{ call_id: "history-reuse", func_name: "History B" }],
{ resolved: { approved: true } },
);
const historyTwoRow = historyTwo.children.find((node) => node.kind === "row");
_appendResultToRow(historyTwoRow, "history new", true, { accepted: true });
if (historyOne === historyTwo || messagesEl.children.length !== 4)
throw new Error("coordinator history reused id collapsed assistant occurrences");
if (historyOneRow.children.find((node) => node.kind === "output").output !== "history old" ||
historyTwoRow.children.find((node) => node.kind === "output").output !== "history new")
throw new Error("coordinator history reused id cross-attributed outputs");
const first = makeRow("c");
indexLatestToolRow(latestToolRowElements, toolResultNodes, "c", first.row);
toolRows.set("c", { batch: first.batch, row: first.row });
handleEvent({
type: "tool_result", call_id: "c", name: "tasks", output: "one provisional",
});
if (taskRefreshes !== 1) throw new Error("tasks provisional did not refresh once");
handleEvent({
type: "tool_result", accepted: true, _event_id: 10,
call_id: "c", name: "tasks", output: "one final", is_error: true,
preview: { title: "accepted error preview" }, effect_status: "unknown",
});
if (taskRefreshes !== 1) throw new Error("accepted tasks replacement refreshed twice");
if (previewOpens !== 0) throw new Error("coordinator accepted preview auto-opened");
const firstOutput = first.row.children.find((node) => node.kind === "output");
const firstPreview = first.row.children.find((node) => node.kind === "preview");
if (!firstOutput || firstOutput.output !== "one final" || !firstOutput.isError)
throw new Error("coordinator accepted error did not replace provisional output");
if (!firstPreview || firstPreview.preview.title !== "accepted error preview")
throw new Error("coordinator accepted error preview chip missing");
if (first.row.dataset.effectStatus !== "unknown")
throw new Error("coordinator effect status was not retained");
const second = makeRow("c");
indexLatestToolRow(latestToolRowElements, toolResultNodes, "c", second.row);
toolRows.set("c", { batch: second.batch, row: second.row });
handleEvent({
type: "tool_result", call_id: "c", name: "other", output: "two provisional",
});
handleEvent({
type: "tool_result", accepted: true, _event_id: 20,
call_id: "c", name: "other", output: "two final",
});
if (firstOutput.output !== "one final" || !firstOutput.isConnected)
throw new Error("coordinator reused id mutated earlier turn");
const secondOutputs = second.row.children.filter((node) => node.kind === "output");
if (secondOutputs.length !== 1 || secondOutputs[0].output !== "two final")
throw new Error("coordinator newest row did not own accepted replacement");
const acceptedOutput = secondOutputs[0];
handleEvent({
type: "tool_result", accepted: true, _event_id: 20,
call_id: "c", name: "other", output: "duplicate corruption",
});
if (second.row.children.find((node) => node.kind === "output") !== acceptedOutput)
throw new Error("coordinator accepted event replay rendered twice");
renderedToolEventIds.add("7");
handleEvent({
type: "tool_result", accepted: true, _event_id: 7,
call_id: "c", name: "other", output: "history corruption",
});
if (second.row.children.some((node) => node.output === "history corruption"))
throw new Error("coordinator history-seeded replay mutated newest row");
// A result whose batch has not rendered yet paints an orphan bubble (bounded
// /history window, replay edge). When that batch lands afterwards it adopts the
// occurrence, so the accepted result must upgrade the row AND retire the
// bubble — the removal guard can only fire while the orphan's ownership entry
// survives its batch being indexed.
allowOrphanResult = true;
handleEvent({
type: "tool_result", call_id: "late-batch", name: "A", output: "orphan provisional",
});
const orphanBubble = messagesEl.children[messagesEl.children.length - 1];
const orphanOwner = toolResultNodes.get("late-batch");
if (orphanBubble.kind !== "msg" || !orphanOwner || orphanOwner.row !== null)
throw new Error("coordinator unmapped result did not paint an orphan bubble");
handleEvent({
type: "tool_info", items: [{ call_id: "late-batch", func_name: "A" }],
});
handleEvent({
type: "tool_result", accepted: true, _event_id: 30,
call_id: "late-batch", name: "A", output: "orphan final",
});
if (orphanBubble.isConnected)
throw new Error("coordinator late batch left its orphan bubble beside the row");
const lateRow = toolRows.get("late-batch").row;
const lateOutputs = lateRow.children.filter((node) => node.kind === "output");
if (lateOutputs.length !== 1 || lateOutputs[0].output !== "orphan final")
throw new Error("coordinator late batch row did not own the accepted result");
"""
% {
"projection": json.dumps(_TOOL_PROJECTION.as_uri()),
"handle": handle_event,
"append_result": append_result,
"append_to_row": append_to_row,
"append_batch": append_batch,
},
)
source = _INTERACTIVE.read_text(encoding="utf-8")
handle_event = _as_function(source, " handleEvent(evt) {")
append_output = _as_function(
source,
" appendToolOutput(callId, name, output, isError, preview, opts = {}) {",
)
announce_block = _as_function(source, " announceToolBlock(items) {")
announce_key = _as_function(source, " _announceKey(items) {")
_run_module(
tmp_path,
"""
import {
acceptedToolEventAlreadyRendered,
indexLatestToolRow,
recordAcceptedToolEvent,
} from %(projection)s;
function classList(...initial) {
const values = new Set(initial);
return {
add(...names) { names.forEach((name) => values.add(name)); },
remove(...names) { names.forEach((name) => values.delete(name)); },
contains(name) { return values.has(name); },
};
}
function makeNode(kind) {
const node = {
kind,
output: "",
isError: false,
dataset: {},
attributes: {},
classList: classList(),
parent: null,
isConnected: false,
children: [],
appendChild(child) {
child.parent = this;
child.isConnected = true;
this.children.push(child);
return child;
},
setAttribute(name, value) { this.attributes[name] = String(value); },
removeAttribute(name) { delete this.attributes[name]; },
after(child) {
const siblings = this.parent.children;
const at = siblings.indexOf(this);
child.parent = this.parent;
child.isConnected = true;
siblings.splice(at + 1, 0, child);
},
remove() {
if (this.parent) {
const at = this.parent.children.indexOf(this);
if (at >= 0) this.parent.children.splice(at, 1);
}
this.isConnected = false;
},
querySelector(selector) {
if (selector === ".conv-agent") return null;
return null;
},
querySelectorAll(selector) {
if (selector === ".conv-batch")
return this.children.filter((child) => child.classList.contains("conv-batch"));
if (selector === ".conv-row")
return this.children.filter((child) => child.classList.contains("conv-row"));
return [];
},
closest(selector) {
return selector === ".conv-batch" ? this.parent : null;
},
get nextElementSibling() {
if (!this.parent) return null;
return this.parent.children[this.parent.children.indexOf(this) + 1] || null;
},
};
Object.defineProperty(node, "className", {
get() { return this._className || ""; },
set(value) {
this._className = String(value);
this.classList = classList(...String(value).split(/\\s+/).filter(Boolean));
},
});
return node;
}
function makeRow(callId) {
const batch = makeNode("batch");
batch.classList = classList("conv-batch", "conv-batch--approved");
const row = makeNode("row");
row.dataset.callId = callId;
row.dataset.funcName = "tool";
batch.appendChild(row);
return { batch, row };
}
function stripAnsi(value) { return String(value || ""); }
function tryParseMedia() { return null; }
function buildMediaEmbed(_media, raw) {
const node = makeNode("media");
node.output = raw;
return node;
}
function tryParseMcpError() { return null; }
function buildMcpErrorEmbed() { throw new Error("unexpected MCP path"); }
function renderCollapsibleOutput(output, isError) {
const node = makeNode("output");
node.output = output;
node.isError = !!isError;
return node;
}
function appendToolErrorBadge(batch) { batch.errorBadges = 1; }
let previewOpens = 0;
function buildPreviewChip(preview, onOpen) {
const node = makeNode("preview");
node.preview = preview;
node.open = onOpen;
return node;
}
const document = { createElement() { return makeNode("element"); } };
function _convApprovalHead() { return makeNode("head"); }
function batchKicker() { return "Tool"; }
function buildToolDiv(item) {
const row = makeNode("row");
row.classList = classList("conv-row");
row.dataset.callId = item.call_id || "";
row.dataset.funcName = item.func_name || "tool";
return row;
}
function indexLabel() { return ""; }
function buildConvVerdict() { return makeNode("verdict"); }
function toolAnnounce() {}
function _toolAnnounceText() { return "tool"; }
const handleEvent = %(handle)s;
const appendToolOutput = %(append)s;
const announceToolBlock = %(announce)s;
const _announceKey = %(announce_key)s;
const latestRows = new Map();
const messagesEl = makeNode("messages");
const pane = {
_agentCards: null,
_renderedToolEventIds: new Set(),
_toolResultNodes: new Map(),
_streamElIndex: new Map(),
announcedBlocks: new Map(),
messagesEl,
_host: {
isFocused() { return true; },
onPreview() { previewOpens++; },
onConsentDetected() {},
},
_toolRow(callId) { return latestRows.get(callId) || null; },
_routeAgentItems() { return false; },
_announceKey,
_indexToolRows(block) {
block.querySelectorAll(".conv-row").forEach((row) => {
indexLatestToolRow(latestRows, this._toolResultNodes, row.dataset.callId, row);
});
},
_relinkAgentCards() {},
_streamEl() { return null; },
isNearBottom() { return false; },
scrollToBottom() {},
appendToolOutput,
announceToolBlock,
handleEvent,
};
// Stop may accept a synthesized cancellation while only the early shell is
// present. Acceptance retires that exact shell's map ownership without
// deleting its committed DOM, so a later turn reusing the id paints anew.
pane.handleEvent({
type: "tool_pending", items: [{ call_id: "cancel-reuse", func_name: "Cancel A" }],
});
const cancelledBatch = messagesEl.children[0];
if (!cancelledBatch || pane.announcedBlocks.size !== 1)
throw new Error("interactive early tool shell was not announced");
pane.handleEvent({
type: "tool_result", accepted: true, _event_id: 1,
call_id: "cancel-reuse", name: "Cancel A", output: "cancelled", is_error: true,
});
if (pane.announcedBlocks.size !== 0 || !cancelledBatch.isConnected)
throw new Error("interactive accepted cancellation did not retire exact shell ownership");
pane.handleEvent({
type: "tool_pending", items: [{ call_id: "cancel-reuse", func_name: "Cancel B" }],
});
if (messagesEl.children.length !== 2 || messagesEl.children[0] !== cancelledBatch ||
!cancelledBatch.isConnected)
throw new Error("interactive reused pending id removed an accepted prior batch");
const first = makeRow("c");
indexLatestToolRow(latestRows, pane._toolResultNodes, "c", first.row);
pane.handleEvent({
type: "tool_result", call_id: "c", name: "tool",
output: "one provisional", preview: { title: "one" },
});
if (previewOpens !== 1) throw new Error("provisional preview did not auto-open once");
const firstOutput = first.batch.children.find((node) => node.kind === "output");
const second = makeRow("c");
indexLatestToolRow(latestRows, pane._toolResultNodes, "c", second.row);
pane.handleEvent({
type: "tool_result", call_id: "c", name: "tool",
output: "two provisional", preview: { title: "two" },
});
if (previewOpens !== 2) throw new Error("second provisional preview auto-open mismatch");
pane.handleEvent({
type: "tool_result", accepted: true, _event_id: 20,
call_id: "c", name: "tool", output: "two final", is_error: true,
preview: { title: "two accepted" }, effect_status: "unknown",
});
if (previewOpens !== 2) throw new Error("accepted preview auto-opened a second time");
if (firstOutput.output !== "one provisional" || !firstOutput.isConnected)
throw new Error("reused id mutated the older turn");
const secondOutputs = second.batch.children.filter((node) => node.kind === "output");
const secondPreviews = second.batch.children.filter((node) => node.kind === "preview");
if (secondOutputs.length !== 1 || secondOutputs[0].output !== "two final" ||
secondOutputs[0].isError !== true)
throw new Error("accepted output did not replace provisional output exactly");
if (secondPreviews.length !== 1 || secondPreviews[0].preview.title !== "two accepted")
throw new Error("accepted error preview chip was lost or duplicated");
if (second.row.dataset.effectStatus !== "unknown")
throw new Error("accepted effect status was not retained on the row");
const acceptedOutput = secondOutputs[0];
pane.handleEvent({
type: "tool_result", accepted: true, _event_id: 20,
call_id: "c", name: "tool", output: "duplicate corruption",
});
if (second.batch.children.filter((node) => node.kind === "output")[0] !== acceptedOutput)
throw new Error("accepted event-id replay replaced the row twice");
pane._renderedToolEventIds.add("7");
pane.handleEvent({
type: "tool_result", accepted: true, _event_id: 7,
call_id: "c", name: "tool", output: "old history corruption",
});
if (second.batch.children.some((node) => node.output === "old history corruption"))
throw new Error("history-seeded accepted replay mutated newest reused-id row");
"""
% {
"projection": json.dumps(_TOOL_PROJECTION.as_uri()),
"handle": handle_event,
"append": append_output,
"announce": announce_block,
"announce_key": announce_key,
},
)
def test_client_send_correlation_and_lost_ack_runtime(tmp_path: Path) -> None:
"""Repeated tokens match FIFO, and SSE acceptance dominates a lost HTTP ACK."""
_run_module(
tmp_path,
f"""
import {{
clientSendMaySettleForViewer,
markAcceptedClientSendBubbles,
sendBubbleWasAccepted,
settleSendResponse,
}} from {json.dumps(_QUEUE.as_uri())};
const first = {{ dataset: {{ clientSendId: "repeat" }} }};
const second = {{ dataset: {{ clientSendId: "repeat" }} }};
const matched = markAcceptedClientSendBubbles(
[first, second], ["repeat", "repeat"]
);
if (matched.length !== 2 || matched[0] !== first || matched[1] !== second)
throw new Error("repeated correlation token collapsed distinct sends");
if (!sendBubbleWasAccepted(first) || !sendBubbleWasAccepted(second))
throw new Error("acceptance proof missing");
const third = {{ dataset: {{ clientSendId: "repeat" }} }};
const queued = markAcceptedClientSendBubbles(
[first, second, third], ["repeat"], true
);
if (queued.length !== 1 || queued[0] !== third)
throw new Error("replayed message_queued re-accepted an earlier bubble");
if (clientSendMaySettleForViewer("mallory", "alice"))
throw new Error("foreign sender settled this viewer's bubble");
if (!clientSendMaySettleForViewer("alice", "alice"))
throw new Error("origin sender could not settle its bubble");
if (!clientSendMaySettleForViewer("", "alice"))
throw new Error("legacy sender-less event lost compatibility");
let consumed = 0;
settleSendResponse(
{{ addQueuedMessage() {{ throw new Error("recreated accepted bubble"); }} }},
{{ status: "queue_full", attached_ids: ["a1"] }},
{{
queuedEl: null,
optimisticEl: first,
isBusy: false,
displayText: "hello",
priority: "notice",
clientSendId: "repeat",
setBusy() {{ throw new Error("accepted event changed busy"); }},
busyIsOptimistic() {{ return true; }},
paneIsBusy() {{ return false; }},
renderError() {{ throw new Error("accepted event rendered an error"); }},
consumeAttachments() {{ consumed++; }},
}},
);
if (consumed !== 1) throw new Error("accepted event did not settle attachments");
""",
)
def test_accept_user_turn_reducer_runtime(tmp_path: Path) -> None:
"""The one user_turn reducer both panes run: gate, settle, dedupe, nudge."""
_run_module(
tmp_path,
f"""
import {{ acceptUserTurnEvent }} from {json.dumps(_QUEUE.as_uri())};
globalThis.sessionStorage = {{
getItem(key) {{ return key === "ts.user_id" ? "alice" : null; }},
}};
function makeBubble(clientSendId, queued) {{
return {{
dataset: {{ clientSendId }},
isConnected: true,
classList: {{ contains: (name) => queued && name === "msg-queued" }},
remove() {{ this.isConnected = false; }},
}};
}}
function makeHost(bubbles) {{
const removedByQueue = [];
return {{
renderedEventIds: new Set(),
messagesEl: {{ querySelectorAll: () => bubbles }},
queue: {{
remove(el) {{
removedByQueue.push(el);
el.isConnected = false;
}},
}},
removedByQueue,
consumed: [],
nudges: 0,
painted: [],
consumeAttachments(ids) {{ this.consumed.push(ids); }},
renderNudgeMarker() {{ this.nudges++; }},
renderUserTurn(content, attachments, opts) {{
this.painted.push({{ content, attachments, opts }});
}},
}};
}}
// A peer's turn must not settle this viewer's optimistic bubble, and with no
// bubble settled there is no attachment handoff to make.
const foreign = makeBubble("tok", false);
let host = makeHost([foreign]);
acceptUserTurnEvent(
{{ _event_id: 1, sender: "mallory", client_send_ids: ["tok"],
attachments: [{{ attachment_id: "a1" }}], content: "hi" }},
host,
);
if (!foreign.isConnected || host.consumed.length !== 0)
throw new Error("a foreign sender settled this viewer's bubble");
if (host.painted.length !== 1 || host.painted[0].opts.viewer !== "alice")
throw new Error("foreign turn did not paint with the resolved viewer");
// The viewer's own turn settles its queued chip THROUGH the queue controller
// and hands the attachment ids to the composer exactly once.
const mine = makeBubble("tok", true);
host = makeHost([mine]);
const own = {{
_event_id: 2, sender: "alice", client_send_ids: ["tok"],
attachments: [{{ attachment_id: "a1" }}, {{}}], content: "hi",
}};
acceptUserTurnEvent(own, host);
if (mine.isConnected || host.removedByQueue[0] !== mine)
throw new Error("queued chip did not leave through the queue controller");
if (host.consumed.length !== 1 || host.consumed[0].join(",") !== "a1")
throw new Error("settled send did not hand off exactly its attachment ids");
// Replay of the same event id is inert — the paint already happened.
acceptUserTurnEvent(own, host);
if (host.painted.length !== 1 || host.consumed.length !== 1)
throw new Error("a replayed event id projected twice");
// The create path: the viewer's own accepted first turn carries consumed
// attachment ids but matched NO optimistic bubble (the create dispatch never
// made one). The chip sync must follow the viewer policy, not the
// matched-bubble gate — a rehydrated pending chip for a spent upload would
// re-submit a drained id on the next send.
host = makeHost([]);
acceptUserTurnEvent(
{{ _event_id: 5, sender: "alice",
attachments: [{{ attachment_id: "c1" }}], content: "first turn" }},
host,
);
if (host.consumed.length !== 1 || host.consumed[0].join(",") !== "c1")
throw new Error("create-dispatched turn did not clear its composer chips");
// A wake-driven turn paints the marker instead of a user bubble, and is still
// recorded so its own replay stays inert.
host = makeHost([]);
acceptUserTurnEvent({{ _event_id: 3, source: "system_nudge" }}, host);
acceptUserTurnEvent({{ _event_id: 3, source: "system_nudge" }}, host);
if (host.nudges !== 1 || host.painted.length !== 0)
throw new Error("system_nudge did not project exactly one marker");
// An id-less turn cannot be deduped, so it must still paint.
host = makeHost([]);
acceptUserTurnEvent({{ content: "no id" }}, host);
acceptUserTurnEvent({{ content: "no id" }}, host);
if (host.painted.length !== 2)
throw new Error("id-less turns were suppressed by the dedupe set");
""",
)
@pytest.mark.parametrize(
("path", "accept_call", "accept_def", "render_call", "set_name", "next_case"),
[
(
_INTERACTIVE,
"this._acceptUserTurn(evt);",
" _acceptUserTurn(evt) {",
"this.addUserMessage(",
"this._renderedUserEventIds",
'case "stream_overflow"',
),
(
_COORDINATOR,
"acceptUserTurn(ev);",
" function acceptUserTurn(ev) {",
"appendUserMessageWithAttachments(",
"renderedUserEventIds",
'case "tool_pending"',
),
],
ids=["interactive", "coordinator"],
)
def test_user_turn_projects_exactly_once_without_rest_or_stream_reopen(
path: Path,
accept_call: str,
accept_def: str,
render_call: str,
set_name: str,
next_case: str,
) -> None:
"""An upgraded pane renders the live row once without refetch/redial."""
body = path.read_text(encoding="utf-8")
case_start = body.index('case "user_turn"')
case_end = body.index('case "system_turn"', case_start)
case = _strip_comments(body[case_start:case_end])
assert accept_call in case
assert "refetchHistory" not in case
assert "connectSSE" not in case
accept_start = body.index(accept_def)
accept_end = body.index("\n }", accept_start) + 4
accept = _strip_comments(body[accept_start:accept_end])
# The projection ORDER — dedupe check, then paint, then record — is pinned
# once in the shared reducer now that both panes route through it; the pane
# contributes only its dedupe set and its DOM writes. Pinning the order per
# pane would have re-asserted the same three lines twice and left the one
# place they actually live unpinned.
assert "acceptUserTurnEvent(" in accept
assert f"renderedEventIds: {set_name}" in accept
assert render_call in accept
assert "refetchHistory" not in accept
assert "connectSSE" not in accept
shared = _strip_comments(_QUEUE.read_text(encoding="utf-8"))
reducer_start = shared.index("export function acceptUserTurnEvent(evt, host) {")
reducer = shared[reducer_start : shared.index("\n}", reducer_start)]
seen = reducer.index("host.renderedEventIds.has(eventId)")
render = reducer.index("host.renderUserTurn(")
record = reducer.index("host.renderedEventIds.add(eventId)")
assert seen < render < record
# An unpainted turn must stay replayable: the nudge branch is the only
# other paint, and it sits inside the same record-after-paint window.
assert reducer.index("host.renderNudgeMarker()") < record
truncated_start = body.index('case "replay_truncated"')
truncated_end = body.index(next_case, truncated_start)
truncated = body[truncated_start:truncated_end]
assert "projection_unsupported" not in truncated
@pytest.mark.parametrize(
("path", "append_call", "next_case"),
[
(
_INTERACTIVE,
"this.appendToolOutput(",
'case "status"',
),
(
_COORDINATOR,
"appendToolResult(",
'case "approve_request"',
),
],
ids=["interactive", "coordinator"],
)
def test_accepted_tool_turn_upserts_once_without_rest_or_stream_reopen(
path: Path,
append_call: str,
next_case: str,
) -> None:
"""The final guarded TOOL row stays on the open stream's ordinary path."""
body = path.read_text(encoding="utf-8")
case_start = body.index('case "tool_result"')
case_end = body.index(next_case, case_start)
case = _strip_comments(body[case_start:case_end])
seen = case.index("acceptedToolEventAlreadyRendered(")
render = case.index(append_call)
record = case.index("recordAcceptedToolEvent(")
assert seen < render < record
for forbidden in ("refetchHistory", "connectSSE", "EventSource"):
assert forbidden not in case
def test_tool_turn_capability_is_browser_only_and_url_sticky() -> None:
"""Every browser redial opts in; SDK/channel consumers stay legacy-safe."""
for path in (_INTERACTIVE, _COORDINATOR):
body = _strip_comments(path.read_text(encoding="utf-8"))
connect_start = body.index("connectSSE(")
source_start = body.index("new EventSource", connect_start)
connect = body[connect_start:source_start]
assert '"user_turn=1"' in connect
assert '"&tool_turn=1"' in connect
for path in (_PY_SDK, _PY_CHANNEL, _TS_SDK):
assert "tool_turn" not in _strip_comments(path.read_text(encoding="utf-8"))
def test_tool_turn_history_seed_and_reused_id_contracts_are_pinned() -> None:
"""History overlap cannot mutate a later row that reused a provider id."""
interactive = _strip_comments(_INTERACTIVE.read_text(encoding="utf-8"))
assert "this._renderedToolEventIds.add(String(msg.event_id))" in interactive
assert "rows[rows.length - 1]" in interactive
assert "this._toolResultNodes.delete(callId)" in interactive
assert "target.dataset.effectStatus" in interactive
assert "!accepted && !isError && this._host.isFocused(this)" in interactive
assert "preview && !isDenied && (!isError || accepted)" in interactive
coordinator = _strip_comments(_COORDINATOR.read_text(encoding="utf-8"))
assert "renderedToolEventIds.add(String(m.event_id))" in coordinator
assert "toolRows.set(it.call_id, { batch, row })" in coordinator
assert "indexLatestToolRow(" in coordinator
assert "indexHistoryToolOutcomes(historyMessages)" in coordinator
assert "shiftToolOccurrence(pendingHistoryToolRows, callId)" in coordinator
assert "row.dataset.effectStatus" in coordinator
assert "buildPreviewChip(opts.preview" in coordinator
@pytest.mark.parametrize(
("path", "refetch_call"),
[
(_INTERACTIVE, "this._refetchHistory(this.wsId, token)"),
(_COORDINATOR, "refetchHistory()"),
],
ids=["pre-projection-interactive", "pre-projection-coordinator"],
)
def test_tokenless_bootstrap_uses_pre_projection_in_place_clear_ui_contract(
path: Path,
refetch_call: str,
) -> None:
"""The old clear_ui reducer repairs one open listener and cannot loop."""
body = path.read_text(encoding="utf-8")
start = body.index('case "clear_ui"')
end = body.index('case "history_resync"', start)
clear_ui = _strip_comments(body[start:end])
assert refetch_call in clear_ui
for forbidden in (
"disconnectSSE",
"suspendStream",
"_loadHistoryThenConnect",
"loadHistoryThenReconnect",
"connectSSE",
):
assert forbidden not in clear_ui
@pytest.mark.parametrize("path", [_INTERACTIVE, _COORDINATOR], ids=["interactive", "coordinator"])
def test_history_handoff_manual_state_is_fail_closed(path: Path) -> None:
"""Budget exhaustion exposes manual actions without constructing EventSource."""
body = path.read_text(encoding="utf-8")
# The attempt budget, the backoff, and the parked prompt live once in the
# shared controller. Each pane must reach them THROUGH it: re-deriving any
# of them pane-side is exactly the drift this consolidation removes, so
# their absence from the pane is the pin.
assert "createHistoryHandoffRepair(" in body
for reimplemented in (
"historyHandoffAttemptAllowed(",
"HISTORY_HANDOFF_MAX_ATTEMPTS",
"nextHistoryHandoffDelay(",
"buildHistoryHandoffPrompt(",
):
assert reimplemented not in body, f"{reimplemented} must stay shared"
shared_body = _SHARED_HANDOFF.read_text(encoding="utf-8")
assert "historyHandoffAttemptAllowed(" in shared_body
assert "HISTORY_HANDOFF_MAX_ATTEMPTS" in shared_body
assert "nextHistoryHandoffDelay(" in shared_body
assert "buildHistoryHandoffPrompt(" in shared_body
assert (
'"Live updates are paused because conversation history could not be verified."'
in shared_body
)
assert '"Retry now"' in shared_body
assert '"Reload page"' in shared_body
connect_start = body.index("connectSSE(")
source_start = body.index("new EventSource", connect_start)
connect_prefix = body[connect_start:source_start]
assert "isRepairing(wsId)" in connect_prefix
assert "schedule();" in connect_prefix
assert "return;" in connect_prefix
def test_repair_mode_downgrades_completed_tokenless_render_to_bootstrap() -> None:
"""A rendered tokenless 200 clears the latch instead of parking the pane.
The server's deliberate cold storage-only read carries no token; the
repair settle must distinguish it (loader outcome "rendered") from a
failed fetch/render and downgrade to the tokenless bootstrap — the
cursorless connect whose convergence the server owns via clear_ui —
rather than burning the attempt budget against a healthy response.
The three-way verdict is pinned where it now lives — once, in the shared
controller. Each pane is pinned only to produce the outcome and hand it
over; the previous per-pane character-window checks re-asserted the same
branch twice and could not see the branch ORDER at all.
"""
for path in (_INTERACTIVE, _COORDINATOR):
body = path.read_text(encoding="utf-8")
assert 'return "rendered";' in body
assert "historyRepair.settle({" in body
# The proof token stays pane-owned; the pane reports only whether the
# same response armed one.
assert "hasToken: this._historyHandoffToken != null" in body or (
"hasToken: historyHandoffToken != null" in body
)
assert 'outcome === "rendered"' not in body, "the verdict must stay shared"
shared = _strip_comments(_SHARED_HANDOFF.read_text(encoding="utf-8"))
settle_start = shared.index("settle({ outcome, hasToken, manualAttempt }) {")
settle = shared[settle_start : shared.index("\n },", settle_start)]
# Token handoff and the deliberate tokenless downgrade share ONE success
# body (round-3 review: the identical twin arms invited drift — a latch
# added to one but not the other would split pane behavior by outcome).
success_arm = settle.index('if (hasToken || outcome === "rendered")')
fail_closed = settle.index("deps.setStale(pending)")
assert success_arm < fail_closed
# The success arm clears the latch and reconnects exactly once; only the
# fail-closed arm may re-arm the budget, and it never reconnects.
assert settle.count("clear();") == 1
assert settle.count("deps.connect(target)") == 1
assert "deps.connect" not in settle[fail_closed:]
assert "showManual();" in settle[fail_closed:]
assert "schedule();" in settle[fail_closed:]