Merge pull request #1051 from turnstonelabs/main

Forward merge of the stable/1.8 changes into dev branch.
This commit is contained in:
Patrick Buckley
2026-08-22 14:44:22 -07:00
committed by GitHub
47 changed files with 6558 additions and 1413 deletions
+12
View File
@@ -12,6 +12,18 @@ that minor, so the current stable line never has two independently writable
branches. Earlier stable lines (`stable/1.7`, `stable/1.6`, `stable/1.5`) are
frozen.
## [Unreleased]
### Added
- **Task-agent compaction.** Long-running agents now receive the same soft
warning, hard compaction, recursive overflow recovery, and visible progress
as the foreground while retaining bounded execution evidence for cancellation
disposition and task recall. The generalized `compaction` lifecycle targets
either the workstream or a parent task card; task compaction is transient and
never persists the agent's private summary. Python and TypeScript SDKs expose
the target fields.
## [1.8.0]
Turnstone 1.8 focuses on dependable long-running workstreams: one model-call
+47 -26
View File
@@ -624,44 +624,64 @@ Each item in `items` (shared by `tool_info` and `approve_request`):
{"type": "info", "message": "Session cleared."}
```
**`compaction`** -- context-compaction lifecycle (manual `/compact` and
auto-compaction). `phase: "start"` opens the operation (`trigger` is
`"manual"` or `"auto"`; auto adds `where` — e.g. `"mid-turn"` — and, when
the percentage threshold actually fired, `pct`; the context-overflow retry
path compacts without a `pct` since no threshold was evaluated).
**`compaction`** -- context-compaction lifecycle. `target` is
`"workstream"` for manual `/compact` and foreground auto-compaction, or
`"task_agent"` for a delegated agent's transient model context. Task-agent
events also carry `parent_call_id`, which keys the existing task card. A
missing `target` means `"workstream"`. `phase: "start"` opens the operation
(`trigger` is `"manual"` or `"auto"`; auto adds `where` — e.g. `"mid-turn"`
— and, when the percentage threshold actually fired, `pct`; the
context-overflow retry path compacts without a `pct` since no threshold was
evaluated).
`phase: "progress"` reports chunked summarization (`part`/`total`/`depth`,
where depth 0 summarizes transcript batches and deeper levels merge partial
summaries), a transient-error retry wait (`retry_in` seconds + `error`), or
`warning: "summary_truncated"`. `phase: "end"` settles it: `ok: true`
carries `before_tokens`/`after_tokens` and the produced `summary`;
carries `before_tokens`/`after_tokens`; workstream ends also carry the produced
`summary`, while task-agent ends deliberately omit it because that summary is
private transient model context;
`ok: false` carries a `reason`
(`"not_enough_messages"` / `"irreducible"` / `"empty_summary"` /
`"cancelled"` / `"error"`) and a human-readable `message` — for
`reason: "error"` the same message is also emitted as a paired typed
`error` event (that is the renderable error surface; the end event is
card-teardown). Failed ends also carry `notice`: the emitter-computed
display verdict — show `message` only when it is `true` (the server
suppresses error-reason, superseded, and cancelled-auto notices once,
centrally, so clients don't re-derive that policy). Every end (ok or
failed) carries `trigger`, and every event carries `compaction_id` — an
opaque integer correlating the start/progress/end of one compaction run (a
client that force-stopped one compaction can use it to ignore stragglers
from the abandoned run). End events also carry `superseded`: `true` marks
`"cancelled"` / `"error"`) and a human-readable `message`. A workstream
`reason: "error"` end is paired with a typed `error` event, so its `notice`
is false and the end only tears down the compaction card. A task-agent error
has no workstream-level `error` twin: its targeted end carries `notice: true`
so the message renders inside the parent task card. Failed ends always carry
the emitter-computed `notice` verdict; clients display `message` only when it
is true rather than reconstructing policy from `reason`, `trigger`, and
`superseded`. Superseded failures and automatic cancellation stay silent.
Every end (ok or failed) carries `trigger`, and every event carries
`compaction_id` — an
opaque session-local integer correlating the start/progress/end of one
compaction attempt. It is independent of the send generation, so concurrent
task agents and repeated compactions in one turn receive distinct IDs (a client
that force-stopped one compaction can use it to ignore stragglers from the
abandoned run). End events also carry `superseded`: `true` marks
a force-abandoned compaction retiring after a successor generation took
over (an OK end's result card still stands: the history swap happened).
Superseded start/progress events are never emitted.
Exactly one `start` and one `end` are emitted per attempt,
Exactly one `start` and one `end` are emitted per admitted attempt,
so clients can key an in-progress affordance (progress bar) on the pair. A
successful end is also persisted: the summary replays from `/history` as a
successful **workstream** end is also persisted: the summary replays from
`/history` as a
`role: "system"`, `source: "compaction"` entry whose `meta` carries
`{watermark, before_tokens, after_tokens, trigger}` and whose `event_id`
matches the end event's id (dedup across repaint + replay).
matches the end event's id (dedup across repaint + replay). Task-agent
compaction is nested progress only: fresh/truncated SSE recovery includes its
latest active lifecycle edge, and the matching parent `tool_result` retires it.
Its summary is used only as the task's next private model context: it is absent
from the workstream transcript, persistence, task recall, and every event.
```json
{"type": "compaction", "phase": "start", "compaction_id": 7, "trigger": "auto", "where": "mid-turn", "pct": 80}
{"type": "compaction", "phase": "progress", "compaction_id": 7, "part": 2, "total": 5, "depth": 0}
{"type": "compaction", "phase": "end", "ok": true, "compaction_id": 7, "trigger": "auto",
{"type": "compaction", "target": "workstream", "phase": "start", "compaction_id": 7, "trigger": "auto", "where": "mid-turn", "pct": 80}
{"type": "compaction", "target": "workstream", "phase": "progress", "compaction_id": 7, "part": 2, "total": 5, "depth": 0}
{"type": "compaction", "target": "workstream", "phase": "end", "ok": true, "compaction_id": 7, "trigger": "auto",
"before_tokens": 128400, "after_tokens": 9200, "summary": "## Decisions\n..."}
{"type": "compaction", "target": "task_agent", "parent_call_id": "call_task_abc123",
"phase": "start", "compaction_id": 8, "trigger": "auto", "where": "mid-turn", "pct": 80}
{"type": "compaction", "target": "task_agent", "parent_call_id": "call_task_abc123",
"phase": "end", "ok": true, "compaction_id": 8, "trigger": "auto",
"before_tokens": 115000, "after_tokens": 18000}
```
**`error`** -- an error message.
@@ -789,9 +809,10 @@ either the event-ring delta after its cursor or a synthetic recovery replay.
The synthetic replay includes `connected`, cached `status`, every pending
approval cycle, the current `state_change`, an optional
`in_progress_snapshot` with partial content/reasoning, and the latest
`agent_context` reading for each running task agent. A matching `tool_result`
ends that reading. Conversation history stays on the REST `/history` endpoint;
completed task-agent readings are not retained.
`agent_context` reading and active targeted `compaction` edge for each running
task agent. A matching `tool_result` ends both transient states. Conversation
history stays on the REST `/history` endpoint; completed task-agent readings
and compactions are not retained.
---
+44 -16
View File
@@ -367,11 +367,12 @@ eviction from discarding that recovery state. Soft close returns 409 and leaves
the workstream loaded; hard delete remains the explicit discard boundary.
`on_stream_discarded` removes a failed attempt's partial projection before a
mid-stream retry. `on_system_turn` and `on_compaction` return the assigned SSE
event ID when the frontend has one; persistence stamps the corresponding row
with that cursor so reconnect replay and `/history` agree. The `judge_event`
argument is the intent-judge generation identity used to reject stale verdicts
from a prior approval round.
mid-stream retry. `on_system_turn` and workstream-targeted `on_compaction`
return the assigned SSE event ID when the frontend has one; persistence stamps
the corresponding row with that cursor so reconnect replay and `/history`
agree. Task-targeted compaction also receives an SSE ID for stream ordering but
has no durable row. The `judge_event` argument is the intent-judge generation
identity used to reject stale verdicts from a prior approval round.
`on_rename` is called by the `/name` command (on success) and after a successful `/resume` (if the resumed session has an alias or title). `WebUI.on_rename` broadcasts a `ws_rename` event on the global SSE channel and updates the in-memory `Workstream.name`; `TerminalUI.on_rename` is a no-op.
@@ -755,6 +756,18 @@ then returns the final content as the tool result.
stops calling tools or hits `finish_reason: "length"`.
- **Retry**: each API call in the agent loop uses the same retry+backoff logic
as the main loop's per-lane ladder (`_model_turn_with_retry`).
- **Context compaction**: the task lane resolves its own context window and
applies the same soft warning, hard ceiling, recursive summarizer, and
compact-and-retry overflow backstop as the foreground. The immutable
system/skill/delegation prefix is reattached verbatim; only the autonomous
suffix is summarized. The replaceable model context is separate from a
purpose-built execution journal: it keeps the exact unresolved call batch,
aggregate effect dispositions across a bounded tool-name set, and the latest
100 clipped recall steps with bounded, digest-disambiguated identifiers.
Resolved raw provider blocks and large tool payloads can therefore be released
after compaction without erasing cancellation evidence. Successful replacement
clears that task's file-read set and adds a resume nudge requiring exact files
to be read again.
- **Finish reason handling**: `finish_reason: "length"` stops the agent early
and returns whatever content was generated. `finish_reason: "content_filter"`
returns a placeholder.
@@ -1603,8 +1616,10 @@ Every model call streams (#831); retry lives at two stacked layers:
`model_turn`) use the same pattern: 4 total attempts (1 initial + 3 retries,
`_MAX_RETRIES = 3`), exponential backoff base 1 second
(`delay = 1s * 2^attempt`), `ui.on_info()` on retry, exception
propagates on final failure. `_compact_messages()` wraps its drained
call in the same loop.
propagates on final failure. `CompactionEngine.summarize_once()` owns the
equivalent cancellable retry ladder for summary calls; its owner-supplied
`SummaryRuntime` provides retry classification and backoff while routing
progress to the foreground or task-agent lifecycle owner.
- **`model_turn`'s drain ladder** — inside every single-shot call,
mid-stream deaths (errors raised while draining, e.g.
`IncompleteStreamError`) are re-issued up to 2 more times with a
@@ -1635,8 +1650,10 @@ Agent sub-sessions (`_run_agent()`) check `finish_reason` on each
drained turn and stop the agent early on `"length"` or
`"content_filter"`.
`_compact_messages()` checks `finish_reason` on the compaction response and
warns if the summary was truncated.
`CompactionEngine.summarize_once()` also checks the summary call's
`finish_reason`; on `"length"` it emits a `summary_truncated` warning through
the owner's compaction-progress callback for both foreground and task-agent
compaction.
### State Emission on Errors
@@ -1985,8 +2002,15 @@ summarizing the selected trajectory into a structured summary. Manual
the send generation that triggered it. Both use the same cancellation,
publication, and FIFO durability fences as a model turn.
Compaction pins one `ModelLane` for the complete operation. Blocks are packed
to an estimated window budget; a real provider overflow recursively
Foreground and task-agent compaction share the lifecycle-agnostic policy,
provider-calibrated estimator, input packing, retry, and recursive summarizer
in `turnstone/core/compaction.py`; their lifecycle owners remain separate.
Foreground owns a durable conversation swap and checkpoint. A task agent owns
only its private replaceable model context beside the bounded execution journal
described above.
Each compaction pins one `ModelLane` for the complete operation. Blocks are
packed to an estimated window budget; a real provider overflow recursively
subdivides the batch and merges partial summaries rather than silently dropping
the newest messages. The summary preserves:
@@ -2021,11 +2045,15 @@ message loss.
Cancellation or generation supersession before the final commit leaves both
the live trajectory and checkpoint untouched. Typed `compaction` lifecycle
events (`start`, `progress`, exactly one `end`) let SSE clients correlate and
retire one run even when a force-abandoned predecessor finishes after a
successor generation begins. After a successful swap, `_read_files` is cleared
so edits require fresh file reads against content no longer present in the
bounded model context.
events (`start`, `progress`, exactly one `end`) carry a session-local
`compaction_id` and a `target`. Workstream events own the durable transcript
card; task-agent events carry `parent_call_id`, render transiently under that
task card, and never expose or persist their private summary. The independent
attempt ID lets clients retire one run even when task agents compact in
parallel, a generation compacts repeatedly, or a force-abandoned predecessor
finishes after its successor. After a successful swap, the relevant file-read
set is cleared so edits require fresh file reads against content no longer
present in the bounded model context.
---
+1
View File
@@ -142,6 +142,7 @@ SSE events are deserialized into typed dataclasses. Use `event.type` to discrimi
| `state_change` | `StateChangeEvent` | `state``running`/`thinking`/`attention`/`idle`/`error` |
| `in_progress_snapshot` | `InProgressSnapshotEvent` | `content`, `reasoning` (one-shot mid-stream refresh resume) |
| `agent_context` | `AgentContextEvent` | `parent_call_id`, `prompt_tokens`, `context_window` (replace the latest reading for a running task agent; discard it on the matching `tool_result`) |
| `compaction` | `CompactionEvent` | `target`, `parent_call_id`, `compaction_id`, `phase`, `ok`, `notice`; events without `target` are workstream events, while `task_agent` is transient nested progress and omits its private `summary` |
| `approval_resolved` | `ApprovalResolvedEvent` | `cycle_id`, `call_ids`, `approved`, `feedback`, `always` |
| `cancelled` | `CancelledEvent` | — |
| `history_resync` | `HistoryResyncEvent` | `reason`, optional `ws_id` |
+8 -1
View File
@@ -26,7 +26,14 @@ dependencies = [
# Version 3 moves the default transport to HTTPX2. Keep major upgrades
# deliberate because the stream retry boundary depends on that contract.
"openai>=3,<4",
"anthropic>=0.117", # tracks the release current at claude-opus-5 onboarding; hard runtime floor is still 0.105 (mid-conversation system blocks) — Opus 5 itself needs no new SDK surface (model ids are opaque strings; "refusal" has been in the StopReason literal since ~0.95). Raise this when adopting fast mode / server-side fallbacks / advisor / mid-conversation tool changes, which DO need newer typed params.
# 0.117 tracks the release current at Claude Opus 5 onboarding; the hard runtime
# floor remains 0.105 (mid-conversation system blocks). Opus 5 itself needs no new
# SDK surface: model IDs are opaque strings, and "refusal" has been in StopReason
# since about 0.95. Raise the floor when adopting fast mode, server-side fallbacks,
# advisor, or mid-conversation tool changes, which need newer typed parameters.
# Version 1 moves to HTTPX2 and removes Messages sampling kwargs, so migrate the
# provider boundary before removing the cap.
"anthropic>=0.117,<1",
"httpx>=0.28",
# Direct because the provider boundary catches this exception family;
# OpenAI v3's transitive dependency alone is not an import contract.
+10 -1
View File
@@ -1861,7 +1861,8 @@ def _poll_title(cdp: CDP, timeout: float) -> str:
def _storm_scripts() -> tuple[Any, ...]:
"""A bash storm plus task agents with and without nested sub-tools.
Both agents report warning-level prompt usage. The browser therefore proves
Both agents report warning-level prompt usage. The nested agent therefore
cooperatively compacts and resumes before finishing. The browser proves
live and refresh-restored context badges, accessibility, nested routing,
recycled-id relinking, and context-only terminal cleanup.
"""
@@ -1912,6 +1913,11 @@ def _storm_scripts() -> tuple[Any, ...]:
task,
sub,
{**final_text_script("sub done"), "prompt_tokens": 27_000},
# The warning-level no-tool response above cooperatively winds down.
# Supply the private summary call and the resumed agent completion
# before the parent harness continues.
final_text_script("task compaction summary"),
final_text_script("sub done after compaction"),
final_text_script("all done"),
no_step_task,
{**final_text_script("direct answer"), "prompt_tokens": 27_000},
@@ -1962,6 +1968,9 @@ def run_storm(chrome: str) -> str:
return f"RECOVERY-FAILED-STORM-context-resume-late-calls{calls_at_resume}"
node.wait_turn(ws_id, timeout=40)
calls_after_nested = node.model_call_count(ws_id)
if calls_after_nested != 8:
return f"RECOVERY-FAILED-STORM-context-compaction-calls{calls_after_nested}"
if not _poll_until(
lambda: cdp.evaluate("!!window.__pane && !window.__pane.busy"),
5,
+11 -5
View File
@@ -214,13 +214,19 @@ export interface CancelledEvent {
* auto adds `where` + `pct`); `progress` carries chunked-summarization
* `part`/`total`/`depth` (or `retry_in`/`error` for a retry wait); `end`
* carries `ok` plus either `before_tokens`/`after_tokens`/`summary` or the
* failure `reason`/`message`. The successful end's summary also replays from
* `/history` as a `role: "system"`, `source: "compaction"` entry.
* failure `reason`/`message`. `target: "workstream"` owns the transcript and
* durable marker. `target: "task_agent"` carries `parent_call_id`, is
* transient nested progress, and omits its private `summary` on success.
* Events without a target are workstream events because that was the only
* compaction scope before targeted events existed.
*/
export interface CompactionEvent {
type: "compaction";
phase: "start" | "progress" | "end";
/** Correlates every event of one compaction run (0 from legacy emitters). */
target?: "workstream" | "task_agent";
/** Present when target is `task_agent`; keys the nested task card. */
parent_call_id?: string;
/** Correlates every event of one compaction attempt. */
compaction_id?: number;
/**
* End events only: true marks a force-abandoned compaction retiring
@@ -230,8 +236,8 @@ export interface CompactionEvent {
superseded?: boolean;
/**
* Failed ends only: the emitter-computed display verdict show
* `message` only when true, instead of re-deriving suppression from
* reason/trigger/superseded client-side.
* `message` only when true. Workstream errors use their paired typed error;
* task-agent errors use this targeted notice instead.
*/
notice?: boolean;
/** Present on start and on every end (ok or failed). */
+170
View File
@@ -13,6 +13,176 @@ if TYPE_CHECKING:
from pathlib import Path
FAKE_DOM = r"""
class FakeElement {
constructor(tag) {
this.tagName = String(tag || "div").toUpperCase();
this.parentNode = null;
this.children = [];
this.dataset = {};
this.attributes = {};
this.className = "";
this.textContent = "";
this.title = "";
this.type = "";
this.paused = true;
this.ended = false;
this.scrollHeight = 0;
this.scrollTop = 0;
this.clientHeight = 0;
this._connected = false;
this._listeners = new Map();
this.classList = {
contains: (name) => this.className.split(/\s+/).filter(Boolean).includes(name),
add: (...names) => {
const set = new Set(this.className.split(/\s+/).filter(Boolean));
names.forEach((name) => set.add(name));
this.className = Array.from(set).join(" ");
},
remove: (...names) => {
const drop = new Set(names);
this.className = this.className
.split(/\s+/)
.filter((name) => name && !drop.has(name))
.join(" ");
},
toggle: (name, force) => {
const next = force == null ? !this.classList.contains(name) : !!force;
if (next) this.classList.add(name);
else this.classList.remove(name);
return next;
},
};
}
get isConnected() { return this._connected; }
_setConnected(value) {
this._connected = value;
this.children.forEach((child) => child._setConnected(value));
}
appendChild(child) {
if (child.parentNode) child.remove();
this.children.push(child);
child.parentNode = this;
child._setConnected(this._connected);
return child;
}
append(...children) { children.forEach((child) => this.appendChild(child)); }
remove() {
if (!this.parentNode) return;
const i = this.parentNode.children.indexOf(this);
if (i >= 0) this.parentNode.children.splice(i, 1);
this.parentNode = null;
this._setConnected(false);
}
setAttribute(name, value) { this.attributes[name] = String(value); }
getAttribute(name) { return this.attributes[name] ?? null; }
hasAttribute(name) { return Object.hasOwn(this.attributes, name); }
removeAttribute(name) { delete this.attributes[name]; }
addEventListener(name, fn, options) {
if (!this._listeners.has(name)) this._listeners.set(name, []);
this._listeners.get(name).push({ fn, once: !!(options && options.once) });
}
removeEventListener(name, fn) {
const rows = this._listeners.get(name) || [];
this._listeners.set(name, rows.filter((row) => row.fn !== fn));
}
dispatch(name) {
const rows = [...(this._listeners.get(name) || [])];
for (const row of rows) {
row.fn({ target: this });
if (row.once) this.removeEventListener(name, row.fn);
}
}
click() { this.dispatch("click"); }
focus() { document.activeElement = this; }
contains(node) {
for (let current = node; current; current = current.parentNode) {
if (current === this) return true;
}
return false;
}
_matches(selector) {
selector = selector.trim();
const attr = selector.match(/^(?:\.([\w-]+))?\[([\w-]+)="([^"]*)"\]$/);
if (attr) {
if (attr[1] && !this.classList.contains(attr[1])) return false;
const name = attr[2];
const value = name.startsWith("data-")
? this.dataset[name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())]
: this.getAttribute(name);
return String(value) === attr[3];
}
if (selector.startsWith(".")) {
return selector.slice(1).split(".").every((name) => this.classList.contains(name));
}
return this.tagName.toLowerCase() === selector.toLowerCase();
}
closest(selector) {
for (let current = this; current; current = current.parentNode) {
if (current._matches(selector)) return current;
}
return null;
}
querySelectorAll(selector) {
const selectors = selector.split(",").map((part) => part.trim());
const found = [];
const visit = (node) => {
for (const child of node.children) {
if (selectors.some((part) => child._matches(part))) found.push(child);
visit(child);
}
};
visit(this);
return found;
}
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
getClientRects() { return this._visible === false ? [] : [{}]; }
}
const html = new FakeElement("html");
html._setConnected(true);
globalThis.document = {
documentElement: html,
activeElement: null,
createElement: (tag) => new FakeElement(tag),
};
const storage = new Map();
let failGet = false;
let failSet = false;
globalThis.localStorage = {
getItem: (key) => {
if (failGet) throw new Error("get blocked");
return storage.has(key) ? storage.get(key) : null;
},
setItem: (key, value) => {
if (failSet) throw new Error("set blocked");
storage.set(key, String(value));
},
};
const windowListeners = new Map();
globalThis.window = {
addEventListener: (name, fn) => windowListeners.set(name, fn),
};
globalThis.requestAnimationFrame = (fn) => { fn(); return 1; };
const resizeObservers = [];
globalThis.ResizeObserver = class {
constructor(fn) {
this.fn = fn;
this.targets = new Set();
this.disconnected = false;
resizeObservers.push(this);
}
observe(target) { this.targets.add(target); }
disconnect() { this.disconnected = true; this.targets.clear(); }
};
globalThis.triggerResize = (target) => {
for (const observer of resizeObservers) {
if (!observer.disconnected && observer.targets.has(target)) observer.fn([]);
}
};
"""
def has_node() -> bool:
return shutil.which("node") is not None
+70 -44
View File
@@ -32,6 +32,7 @@ from turnstone.core.session import (
_CancelledToolResult,
_CancelRef,
_StreamTurnConsumer,
_TaskExecutionJournal,
_tool_turn_meta,
)
from turnstone.core.session_manager import SessionManager
@@ -1556,12 +1557,13 @@ class TestTaskAgentStreamAbort:
generation = kwargs["origin_generation"]
if generation == old_generation:
session._current_read_files.add("old-generation.txt")
agent_turns.append(
Turn.assistant(
"",
tool_calls=(ToolCall(id="old-action", name="bash", arguments="{}"),),
)
issued = Turn.assistant(
"",
tool_calls=(ToolCall(id="old-action", name="bash", arguments="{}"),),
)
agent_turns.append(issued)
kwargs["execution_journal"].record_assistant(issued)
kwargs["execution_journal"].mark_started("old-action")
old_running.set()
if not release_old.wait(2):
raise RuntimeError("old task wrapper was not released")
@@ -1661,12 +1663,14 @@ class TestTaskAgentStreamAbort:
with owners_lock:
owners[key] = _active_shell_owner.get()
agent_turns.append(
Turn.assistant(
"",
tool_calls=(ToolCall(id=child_id, name="bash", arguments="{}"),),
)
issued = Turn.assistant(
"",
tool_calls=(ToolCall(id=child_id, name="bash", arguments="{}"),),
)
agent_turns.append(issued)
kwargs["execution_journal"].record_assistant(issued)
kwargs["execution_journal"].mark_started(child_id)
kwargs["execution_journal"].register_child(child_id)
session._note_agent_child(child_id, parent_call_id)
ready.set()
if not release.wait(2):
@@ -1674,6 +1678,12 @@ class TestTaskAgentStreamAbort:
if key == "old":
raise GenerationCancelled()
agent_turns.append(Turn.tool(child_id, "done"))
kwargs["execution_journal"].record_result(
child_id,
"done",
is_error=False,
effect_status=None,
)
return "fresh result"
def record_reap(*, owner):
@@ -2103,7 +2113,7 @@ class TestTaskAgentStreamAbort:
generation_event = session._cancel_event
parent_call_id = "task-with-unknown-child"
issued_child_ids: list[str] = []
stashed_turns: list[Turn] = []
stashed_steps: list[dict[str, object]] = []
outcomes: list[object] = []
second_request = _BlockingAgentStream()
@@ -2159,8 +2169,8 @@ class TestTaskAgentStreamAbort:
patch.object(session, "_prepare_tool", side_effect=prepare_child),
patch.object(
session,
"_stash_agent_trajectory",
side_effect=lambda _call_id, turns: stashed_turns.extend(turns),
"_stash_agent_steps",
side_effect=lambda _call_id, steps: stashed_steps.extend(steps),
),
):
thread = self._start_task(session, item, outcomes)
@@ -2182,10 +2192,10 @@ class TestTaskAgentStreamAbort:
assert "Results received with UNKNOWN effects before cancel: read_file." in disposition
assert "Completed before cancel: read_file." not in disposition
child_turns = [turn for turn in stashed_turns if turn.role is Role.TOOL]
assert len(child_turns) == 1
assert child_turns[0].tool_call_id == issued_child_ids[0]
assert child_turns[0].effect_status is EffectStatus.UNKNOWN
assert len(stashed_steps) == 1
assert stashed_steps[0]["id"] == issued_child_ids[0]
assert stashed_steps[0]["is_error"] is True
assert "Outcome UNKNOWN" in str(stashed_steps[0]["output"])
assert issued_child_ids[0] not in session._tool_status
assert session._cancelled_tool_results[parent_call_id] == _CancelledToolResult(
detail=disposition,
@@ -4787,32 +4797,35 @@ class TestCancelledAgentDisposition:
def _result(call_id, text="ok"):
return Turn.tool(call_id, text)
@staticmethod
def _journal(turns, *started_ids):
journal = _TaskExecutionJournal(turns)
for call_id in started_ids:
journal.mark_started(call_id)
journal.materialize_unstarted(turns)
return journal
def test_status_none_when_no_actions(self):
"""Typed twin of the disposition: a task cancelled before any action is
NONE, not UNKNOWN the complement of the in-flight case."""
session = _make_session()
assert session._cancelled_agent_status([]) is EffectStatus.NONE
assert self._journal([]).cancelled_status() is EffectStatus.NONE
def test_status_unknown_when_in_flight(self):
session = _make_session()
msgs = [self._assistant("t1", "bash")] # issued, no result → in flight
assert session._cancelled_agent_status(msgs) is EffectStatus.UNKNOWN
assert self._journal(msgs, "t1").cancelled_status() is EffectStatus.UNKNOWN
def test_status_partial_when_all_answered(self):
"""Every issued call returned but the agent was stopped before finishing
effects are known (not UNKNOWN) yet the task is incomplete: PARTIAL."""
session = _make_session()
msgs = [self._assistant("t1", "bash"), self._result("t1")]
assert session._cancelled_agent_status(msgs) is EffectStatus.PARTIAL
assert self._journal(msgs).cancelled_status() is EffectStatus.PARTIAL
def test_no_actions_reports_no_side_effects(self, tmp_db):
session = _make_session()
out = session._cancelled_agent_disposition([], "task")
out = self._journal([]).cancelled_disposition("task")
assert "no side effects" in out
assert "UNKNOWN" not in out
def test_marks_in_flight_action_unknown(self, tmp_db):
session = _make_session()
# bash completed; web_fetch was in flight (issued, no result yet) —
# the first unanswered call is the in-flight boundary.
msgs = [
@@ -4820,7 +4833,7 @@ class TestCancelledAgentDisposition:
self._result("t1"),
self._assistant("t2", "web_fetch"),
]
out = session._cancelled_agent_disposition(msgs, "task")
out = self._journal(msgs, "t2").cancelled_disposition("task")
assert out != "(task interrupted by user)"
assert "Completed before cancel: bash." in out
assert "In flight at cancel: web_fetch" in out
@@ -4829,9 +4842,8 @@ class TestCancelledAgentDisposition:
def test_unanswered_tool_is_in_flight_unknown(self, tmp_db):
# An output-flowing bash SIGKILL'd mid-stream raises (no result row) —
# it is the in-flight boundary and must read UNKNOWN, never completed.
session = _make_session()
msgs = [self._assistant("t1", "bash")] # issued, no result
out = session._cancelled_agent_disposition(msgs, "task")
out = self._journal(msgs, "t1").cancelled_disposition("task")
assert "In flight at cancel: bash" in out
assert "UNKNOWN" in out
assert "Completed before cancel" not in out
@@ -4840,9 +4852,8 @@ class TestCancelledAgentDisposition:
# Every issued call returned a result — cancel landed between turns,
# nothing in flight. Each result carries its own disposition; the
# summary just lists what completed, with no UNKNOWN boundary.
session = _make_session()
msgs = [self._assistant("t1", "bash"), self._result("t1", "(killed)")]
out = session._cancelled_agent_disposition(msgs, "task")
out = self._journal(msgs).cancelled_disposition("task")
assert "Completed before cancel: bash." in out
assert "In flight at cancel" not in out
@@ -4850,11 +4861,11 @@ class TestCancelledAgentDisposition:
# Regression (bug-1): a turn issues [bash, web_fetch] executed
# sequentially; cancel hits during bash (unanswered, side effects
# possible) and web_fetch never runs. The in-flight UNKNOWN must be
# bash (the FIRST gap), and web_fetch must read "not started" — NOT
# bash, and the journal's executor witness must record web_fetch as
# confirmed no-effect — NOT
# the inverse. The old code took the LAST issued call, labelling the
# never-run web_fetch UNKNOWN and the actually-in-flight bash "not
# started" — inviting a re-run of the destructive bash.
session = _make_session()
msgs = [
Turn.assistant(
"",
@@ -4864,16 +4875,15 @@ class TestCancelledAgentDisposition:
),
)
] # neither answered: bash raised mid-flight, web_fetch never ran
out = session._cancelled_agent_disposition(msgs, "task")
out = self._journal(msgs, "t1").cancelled_disposition("task")
assert "In flight at cancel: bash" in out
assert "In flight at cancel: web_fetch" not in out
assert "Not started (cancelled first): web_fetch." in out
assert "Confirmed no effect before cancel: web_fetch." in out
def test_counts_and_not_started(self, tmp_db):
def test_counts_and_confirmed_unstarted(self, tmp_db):
# Turn 1 completes [bash, bash, read_file]; turn 2 issues
# [web_fetch (in flight), search (never ran)]. Exercises the ×N
# count summary, the first-gap boundary, and not-started.
session = _make_session()
msgs = [
Turn.assistant(
"",
@@ -4894,20 +4904,32 @@ class TestCancelledAgentDisposition:
),
),
]
out = session._cancelled_agent_disposition(msgs, "task")
out = self._journal(msgs, "t4").cancelled_disposition("task")
assert "Completed before cancel: bash×2, read_file." in out
assert "In flight at cancel: web_fetch" in out
assert "Not started (cancelled first): search." in out
assert "Confirmed no effect before cancel: search." in out
def test_exec_task_routes_cancel_to_disposition(self, tmp_db):
"""_exec_task converts a GenerationCancelled from _run_agent into the
honest disposition, reading the in-place-mutated agent_turns."""
honest disposition from the production execution journal."""
session = _make_session()
def fake_run_agent(agent_turns, **kwargs):
agent_turns.append(self._assistant("t1", "bash"))
agent_turns.append(self._result("t1"))
agent_turns.append(self._assistant("t2", "web_fetch"))
journal = kwargs["execution_journal"]
first = self._assistant("t1", "bash")
first_result = self._result("t1")
second = self._assistant("t2", "web_fetch")
agent_turns.extend((first, first_result, second))
journal.record_assistant(first)
journal.mark_started("t1")
journal.record_result(
"t1",
first_result.text,
is_error=first_result.is_error,
effect_status=first_result.effect_status,
)
journal.record_assistant(second)
journal.mark_started("t2")
raise GenerationCancelled()
with patch.object(session, "_run_agent", side_effect=fake_run_agent):
@@ -4945,10 +4967,14 @@ class TestCancelledAgentDisposition:
session = _make_session(ui=ui)
generation = session._claim_generation()
issued_child = self._assistant("child-1", "bash")
expected_disposition = session._cancelled_agent_disposition([issued_child], "task")
expected_journal = self._journal([issued_child], "child-1")
expected_disposition = expected_journal.cancelled_disposition("task")
def fake_run_agent(agent_turns, **kwargs):
agent_turns.append(issued_child)
journal = kwargs["execution_journal"]
journal.record_assistant(issued_child)
journal.mark_started("child-1")
raise GenerationCancelled()
prepared = {
+13 -13
View File
@@ -26,7 +26,7 @@ import json
import pytest
from tests._session_helpers import make_session
from turnstone.core.session import _SummaryResult
from turnstone.core.compaction import SummaryResult
from turnstone.core.storage._utils import _fork_turn_insert_row
from turnstone.core.trajectory import PROVENANCE_META_KEY, TurnProvenance, turns_from_dicts
@@ -250,9 +250,9 @@ def test_compaction_persists_checkpoint_and_resume_is_bounded(tmp_db, mock_opena
sess.messages = turns_from_dicts(history)
sess._msg_tokens = [1] * len(history)
with patch.object(
sess,
"_summarize_blocks",
return_value=_SummaryResult(text="DENSE SUMMARY", producer="summary-producer"),
sess._compaction_engine,
"summarize_blocks",
return_value=SummaryResult(text="DENSE SUMMARY", producer="summary-producer"),
):
assert sess._compact_messages(auto=False) is True
@@ -308,9 +308,9 @@ def test_marker_watermark_read_blip_recovers_on_retry(tmp_db, mock_openai_client
with (
patch.object(
sess,
"_summarize_blocks",
return_value=_SummaryResult(text="DENSE SUMMARY", producer="summary-producer"),
sess._compaction_engine,
"summarize_blocks",
return_value=SummaryResult(text="DENSE SUMMARY", producer="summary-producer"),
),
patch.object(st, "get_compaction_watermark", side_effect=_flaky_watermark),
):
@@ -360,9 +360,9 @@ def test_compaction_summary_producer_survives_storage_round_trip(
)
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(
sess,
"_summarize_blocks",
lambda *_args, **_kwargs: _SummaryResult(
sess._compaction_engine,
"summarize_blocks",
lambda *_args, **_kwargs: SummaryResult(
text="DENSE SUMMARY",
producer="final-summary-producer",
provenance=provenance,
@@ -677,9 +677,9 @@ def test_watermark_reads_inside_the_marker_persist_not_before_the_commit(
patch.object(sess, "_journal_conversation_row_locked", side_effect=_journal_spy),
patch.object(st, "get_compaction_watermark", side_effect=_watermark_spy),
patch.object(
sess,
"_summarize_blocks",
return_value=_SummaryResult(text="DENSE SUMMARY", producer="summary-producer"),
sess._compaction_engine,
"summarize_blocks",
return_value=SummaryResult(text="DENSE SUMMARY", producer="summary-producer"),
),
):
assert sess._compact_messages(auto=False) is True
+40 -17
View File
@@ -73,6 +73,21 @@ def _stub_summary(text: str = "DENSE"):
)
def _summary_runtime(session):
return session._build_summary_runtime(session._primary_lane())
def _carry_budget_chars(session, carries: int = 1) -> int:
return session._compaction_engine.carry_budget_chars(
_summary_runtime(session),
carries,
)
def _summary_output_tokens(session) -> int:
return session._compaction_engine.summary_output_tokens(_summary_runtime(session))
# ---------------------------------------------------------------------------
# Provenance tags on the synthetic summary turns
# ---------------------------------------------------------------------------
@@ -186,12 +201,12 @@ class TestCarryBudget:
# overhead=0, reserve=100 (compact_max_tokens), margin=500,
# spare=9_400; min(10_000 // 4, 9_400) = 2_500 tokens * 4.0 chars/token.
_isolate_overhead(session)
assert session._carry_budget_chars() == 10_000
assert _carry_budget_chars(session) == 10_000
def test_floors_on_tiny_window(self, tmp_db, mock_openai_client):
tiny = make_session(client=mock_openai_client, context_window=1_000, tool_timeout=10)
_isolate_overhead(tiny)
assert tiny._carry_budget_chars() == tiny._MIN_CARRY_BUDGET_CHARS
assert _carry_budget_chars(tiny) == tiny._compaction_engine.MIN_CARRY_BUDGET_CHARS
@pytest.mark.parametrize("carries", [1, 2])
def test_overhead_reserve_and_carries_fit_window_at_shipped_defaults(
@@ -206,9 +221,9 @@ class TestCarryBudget:
away."""
s = make_session(client=mock_openai_client, tool_timeout=10)
_isolate_overhead(s, system_tokens=4_000) # a chunky composed prompt
reserve = s._summary_output_tokens()
per_carry_tokens = s._carry_budget_chars(carries) / s._chars_per_token
margin = int(s.context_window * s._SUMMARY_SAFETY_MARGIN)
reserve = _summary_output_tokens(s)
per_carry_tokens = _carry_budget_chars(s, carries) / s._chars_per_token
margin = int(s.context_window * s._compaction_engine.SUMMARY_SAFETY_MARGIN)
assert 4_000 + reserve + carries * per_carry_tokens + margin <= s.context_window
def test_budget_shrinks_with_prompt_overhead(self, tmp_db, mock_openai_client):
@@ -216,9 +231,9 @@ class TestCarryBudget:
a bigger system prompt leaves less to carry."""
s = make_session(client=mock_openai_client, tool_timeout=10)
_isolate_overhead(s, system_tokens=0)
roomy = s._carry_budget_chars(2)
roomy = _carry_budget_chars(s, 2)
_isolate_overhead(s, system_tokens=8_000)
assert s._carry_budget_chars(2) < roomy
assert _carry_budget_chars(s, 2) < roomy
def test_double_carry_splits_the_spare(self, tmp_db, mock_openai_client):
"""At shipped defaults the spare (window overhead reserve
@@ -226,11 +241,11 @@ class TestCarryBudget:
the solo quarter-window allowance."""
s = make_session(client=mock_openai_client, tool_timeout=10)
_isolate_overhead(s, system_tokens=2_000)
reserve = s._summary_output_tokens()
margin = int(s.context_window * s._SUMMARY_SAFETY_MARGIN)
reserve = _summary_output_tokens(s)
margin = int(s.context_window * s._compaction_engine.SUMMARY_SAFETY_MARGIN)
spare = s.context_window - reserve - margin - 2_000
assert s._carry_budget_chars(2) == int((spare // 2) * s._chars_per_token)
assert s._carry_budget_chars(2) < s._carry_budget_chars(1)
assert _carry_budget_chars(s, 2) == int((spare // 2) * s._chars_per_token)
assert _carry_budget_chars(s, 2) < _carry_budget_chars(s, 1)
class TestContinuationHintCarry:
@@ -372,7 +387,7 @@ class TestWindDownSpill:
budget instead of stacking two solo quarter-window allowances on top
of the half-window summary reserve."""
s = _register_session_workstream(make_session(client=mock_openai_client, tool_timeout=10))
per_carry = s._carry_budget_chars(2)
per_carry = _carry_budget_chars(s, 2)
ask = "ASK-HEAD " + "a" * (per_carry * 2) + " ASK-TAIL"
spill = "PLAN-HEAD " + "b" * (per_carry * 2) + " PLAN-TAIL"
s.messages = turns_from_dicts(
@@ -639,14 +654,22 @@ class TestCoordinatorHandles:
miscount comes from forgetting the term or from rendering before it.
"""
s = _coord_session(mock_openai_client)
with patch.object(s, "_carry_budget_chars", wraps=s._carry_budget_chars) as budget:
with patch.object(
s._compaction_engine,
"carry_budget_chars",
wraps=s._compaction_engine.carry_budget_chars,
) as budget:
_compact(s, carry_spill=True)
assert budget.call_args[0][0] == 3 # handles + spill + ask
assert budget.call_args.args[1] == 3 # handles + spill + ask
bare = _coord_session(mock_openai_client, coord_client=_coord_client([], []))
with patch.object(bare, "_carry_budget_chars", wraps=bare._carry_budget_chars) as budget:
with patch.object(
bare._compaction_engine,
"carry_budget_chars",
wraps=bare._compaction_engine.carry_budget_chars,
) as budget:
_compact(bare, carry_spill=True)
assert budget.call_args[0][0] == 2 # no handles, no third share
assert budget.call_args.args[1] == 2 # no handles, no third share
def test_block_fits_the_budget_and_cuts_only_at_row_boundaries(
self, tmp_db, mock_openai_client
@@ -656,7 +679,7 @@ class TestCoordinatorHandles:
cannot resolve, dressed as one that can."""
many = [{"id": f"tsk_{i:04d}", "title": "x" * 180, "status": "pending"} for i in range(60)]
s = _coord_session(mock_openai_client, coord_client=_coord_client(many, CHILDREN))
budget = s._carry_budget_chars(1)
budget = _carry_budget_chars(s, 1)
block = s._render_handles_block(*s._coordinator_handle_rows(), budget)
assert len(block) <= budget
+75
View File
@@ -14,8 +14,11 @@ from __future__ import annotations
import re
from pathlib import Path
import pytest
_ROOT = Path(__file__).resolve().parent.parent
_CSS = _ROOT / "turnstone/shared_static/conversation.css"
_CHAT_CSS = _ROOT / "turnstone/shared_static/chat.css"
_INTERACTIVE_CSS = _ROOT / "turnstone/shared_static/interactive.css"
_PAGES = (
_ROOT / "turnstone/console/static/index.html",
@@ -104,6 +107,78 @@ def test_spinner_keyframe_is_self_contained() -> None:
assert "@keyframes ts-spin" not in body
_COMPACT_FAIL_OPEN_TOKENS = (
".conv-batch--pending",
".conv-batch--running",
".conv-batch--denied",
".conv-batch--error",
'[aria-busy="true"]',
".conv-actions",
".conv-verdict-spinner",
".conv-warning",
".conv-row.error",
".conv-row-result--error",
".conv-row-status--error",
".conv-status--error",
'.conv-row[data-tool-name="task_agent"]',
'.conv-agent[data-state="running"]',
'[data-agent-step-exceptional="true"]',
".compaction-running",
".conv-agent-compaction-notice",
'[data-output-review-incomplete="true"]',
".conv-verdict--high",
".conv-verdict--critical",
".conv-verdict-rec--deny",
".conv-verdict-rec--review",
'[data-effect-status="committed"]',
)
def _compact_section(css: str) -> str:
start = css.index("/* Compact transcript presentation.")
end = css.index("/* Row container.", start)
return css[start:end]
def _assert_compact_fail_open_contract(section: str) -> None:
assert ':root[data-transcript-presentation="compact"]' in section
assert "[data-transcript-root]" in section
assert '[data-results-settled="true"]' in section
assert '[data-compact-folded="true"]' in section
assert "> :not(.conv-batch-head)" in section
for token in _COMPACT_FAIL_OPEN_TOKENS:
assert section.count(token) >= 2, f"compact selectors lost mirrored fail-open term {token}"
def test_compact_batch_fold_is_explicit_scoped_and_fail_open() -> None:
_assert_compact_fail_open_contract(_compact_section(_css()))
@pytest.mark.parametrize("token", _COMPACT_FAIL_OPEN_TOKENS)
def test_compact_contract_guard_detects_each_missing_exclusion(token: str) -> None:
section = _compact_section(_css()).replace(token, "")
with pytest.raises(AssertionError):
_assert_compact_fail_open_contract(section)
def test_compact_message_density_cannot_match_shell_status_messages() -> None:
css = re.sub(r"\s+", " ", _CHAT_CSS.read_text(encoding="utf-8"))
prefix = ':root[data-transcript-presentation="compact"] [data-transcript-root]'
assert prefix + " .msg {" in css
assert prefix + " .msg.reasoning {" in css
assert prefix + ' .msg.reasoning[data-reasoning-active="true"] {' in css
assert prefix + ' .msg.reasoning[data-reasoning-active="true"] > .msg-body {' in css
assert (
prefix + ' .msg.reasoning[data-reasoning-active="true"] > .reasoning-activity-status {'
in css
)
assert prefix + ' .msg.reasoning[data-reasoning-active="true"] > * {' not in css
assert ".reasoning-activity-status {" in css
assert "@keyframes transcript-reasoning-spin" in css
assert "prefers-reduced-motion: reduce" in css
assert ':root[data-transcript-presentation="compact"] .msg {' not in css
def test_linked_by_console_and_both_standalone_pages() -> None:
"""Loaded everywhere a ``.conv-*`` emitter renders: the console (hosts both
panes), the standalone coordinator page, and the standalone interactive page
+458 -3
View File
@@ -15,7 +15,7 @@ import json
import subprocess
from pathlib import Path
from tests._js_harness_helpers import node_skip
from tests._js_harness_helpers import FAKE_DOM, node_skip
_CONVERSATION_JS = (
Path(__file__).resolve().parent.parent / "turnstone/shared_static/conversation.js"
@@ -30,7 +30,20 @@ def test_exports_the_shared_helpers() -> None:
"""The three helpers both panes import must be exported — drop one and the
importing pane module fails to load entirely."""
body = _body()
for name in ("stripAnsi", "buildWatchResultCard", "buildSystemNudgeMarker"):
for name in (
"stripAnsi",
"buildWatchResultCard",
"buildSystemNudgeMarker",
"buildConvBatchDisclosure",
"clearConvVerdictPending",
"convBatchSummaryText",
"isConvBatchCompactEligible",
"isConvVerdictCompactBlocker",
"markConvRowResultSettled",
"setReasoningActivity",
"setConvBatchExpanded",
"setToolOutputReviewState",
):
assert f"export function {name}" in body, f"{name} must be exported"
@@ -78,7 +91,342 @@ def test_agent_card_exposes_hidden_context_badge() -> None:
assert 'context.className = "conv-agent-context";' in body
assert "context.hidden = true;" in body
assert 'context.setAttribute("aria-hidden", "true");' in body
assert "return { wrap, body, label, context, toggle };" in body
assert 'issue.className = "conv-agent-step-issue";' in body
assert "issue.hidden = true;" in body
assert "return { wrap, body, label, context, issue, toggle };" in body
@node_skip
def test_compact_batch_settlement_disclosure_and_fail_open_behavior() -> None:
script = (
FAKE_DOM
+ f"""
const conv = await import({json.dumps(_CONVERSATION_JS.as_uri())});
const assert = (condition, message) => {{ if (!condition) throw new Error(message); }};
for (const [verdict, expected] of [
[{{ risk_level: "low", recommendation: "approve" }}, false],
[{{ risk_level: "medium", recommendation: "approve" }}, false],
[{{ risk_level: "high", recommendation: "approve" }}, true],
[{{ risk_level: "critical", recommendation: "approve" }}, true],
[{{ risk_level: "low", recommendation: "deny" }}, true],
[{{ risk_level: "low", recommendation: "review" }}, true],
[{{ risk_level: "low", recommendation: "future-value" }}, true],
[{{ risk_level: "low" }}, true],
]) {{
assert(
conv.isConvVerdictCompactBlocker(verdict) === expected,
"verdict blocker classification drifted",
);
}}
const reasoning = new FakeElement("div");
reasoning.setAttribute("role", "article");
reasoning.setAttribute("aria-label", "reasoning");
const reasoningTrace = new FakeElement("div");
reasoningTrace.className = "msg-body";
reasoningTrace.setAttribute("aria-hidden", "false");
reasoning.appendChild(reasoningTrace);
assert(conv.setReasoningActivity(reasoning, true), "reasoning did not activate");
assert(reasoning.dataset.reasoningActive === "true", "activity marker missing");
assert(reasoning.getAttribute("role") === "article", "row role was replaced");
assert(
reasoning.getAttribute("aria-label") === "reasoning",
"row label was replaced",
);
assert(reasoning.getAttribute("aria-live") === null, "row became a live region");
assert(reasoningTrace.getAttribute("aria-hidden") === "true", "streamed trace stayed exposed");
const reasoningStatus = reasoning.querySelector(".reasoning-activity-status");
assert(reasoningStatus, "dedicated activity status missing");
assert(reasoningStatus.parentNode === reasoning, "activity status is not a trace sibling");
assert(reasoningStatus.getAttribute("role") === "status", "activity role missing");
assert(reasoningStatus.getAttribute("aria-live") === "polite", "activity live mode missing");
assert(reasoningStatus.getAttribute("aria-atomic") === "true", "activity status is not atomic");
assert(
reasoningStatus.getAttribute("aria-label") === "Model reasoning in progress",
"activity label missing",
);
const stableStatusText = reasoningStatus.textContent;
reasoningTrace.textContent += "first token";
reasoningTrace.textContent += " second token";
assert(reasoningStatus.textContent === stableStatusText, "token stream mutated live status");
assert(!conv.setReasoningActivity(reasoning, true), "duplicate activation transitioned");
assert(conv.setReasoningActivity(reasoning, false), "reasoning did not settle");
assert(!Object.hasOwn(reasoning.dataset, "reasoningActive"), "activity marker survived");
assert(reasoningTrace.getAttribute("aria-hidden") === "false", "trace visibility was not restored");
assert(!reasoning.querySelector(".reasoning-activity-status"), "activity status survived");
function batchWithRows(count, state = "conv-batch--approved") {{
const batch = conv.buildConvBatchShell({{
parallel: count > 1,
kickerText: "Tool",
summaryText: count > 1 ? "bash + " + (count - 1) + " more" : "bash",
}});
batch.classList.add(state);
const rows = [];
for (let i = 0; i < count; i += 1) {{
const row = new FakeElement("div");
row.className = "conv-row";
batch.appendChild(row);
rows.push(row);
}}
html.appendChild(batch);
return {{ batch, rows, disclosure: batch.querySelector(".conv-batch-disclosure") }};
}}
let item = batchWithRows(2);
let first = conv.markConvRowResultSettled(item.rows[0]);
assert(!first.becameSettled, "partial batch settled early");
assert(!Object.hasOwn(item.batch.dataset, "resultsSettled"), "partial marker leaked");
let final = conv.markConvRowResultSettled(item.rows[1]);
assert(final.becameSettled && final.autoFolded, "routine batch did not auto-fold");
assert(item.batch.dataset.compactFolded === "true", "explicit fold marker missing");
assert(item.disclosure.getAttribute("aria-expanded") === "false", "fold ARIA drifted");
assert(item.disclosure.textContent === "Completed · Show details", "fold label drifted");
const duplicate = conv.markConvRowResultSettled(item.rows[1]);
assert(!duplicate.becameSettled && !duplicate.autoFolded, "duplicate result transitioned");
item.disclosure.click();
assert(!Object.hasOwn(item.batch.dataset, "compactFolded"), "manual expand failed");
assert(item.disclosure.getAttribute("aria-expanded") === "true", "expand ARIA drifted");
item.disclosure.click();
assert(item.batch.dataset.compactFolded === "true", "manual re-fold failed");
const addedA = new FakeElement("div");
addedA.className = "conv-row";
const addedB = new FakeElement("div");
addedB.className = "conv-row";
item.batch.append(addedA, addedB);
conv.markConvRowResultSettled(addedA);
assert(!Object.hasOwn(item.batch.dataset, "resultsSettled"), "unresolved upgrade stayed settled");
assert(!Object.hasOwn(item.batch.dataset, "compactFolded"), "unresolved upgrade stayed folded");
const emptyBatch = conv.buildConvBatchShell({{ kickerText: "Tool" }});
emptyBatch.classList.add("conv-batch--approved");
emptyBatch.dataset.resultsSettled = "true";
assert(!conv.isConvBatchCompactEligible(emptyBatch), "zero-row batch became eligible");
item = batchWithRows(1);
const agent = new FakeElement("div");
agent.className = "conv-agent";
const nestedRow = new FakeElement("div");
nestedRow.className = "conv-row";
agent.appendChild(nestedRow);
item.rows[0].appendChild(agent);
const nested = conv.markConvRowResultSettled(nestedRow);
assert(!nested.becameSettled, "nested row settled the parent batch");
assert(!Object.hasOwn(nestedRow.dataset, "resultSettled"), "nested row was stamped");
final = conv.markConvRowResultSettled(item.rows[0]);
assert(final.becameSettled, "nested row blocked direct-row settlement");
assert(final.autoFolded, "routine nested agent did not fold");
const stampedTaskRow = conv.buildConvRow({{ func_name: "task_agent" }});
assert(stampedTaskRow.dataset.toolName === "task_agent", "task agent identity was not stamped");
for (const recallRetained of [false, true]) {{
item = batchWithRows(1);
item.rows[0].dataset.toolName = "task_agent";
if (recallRetained) {{
const recalledAgent = new FakeElement("div");
recalledAgent.className = "conv-agent";
recalledAgent.dataset.state = "done";
item.rows[0].appendChild(recalledAgent);
}}
final = conv.markConvRowResultSettled(item.rows[0]);
const scenario = recallRetained ? "retained" : "unknown";
assert(final.becameSettled && !final.autoFolded, scenario + " task agent folded");
assert(!conv.isConvBatchCompactEligible(item.batch), scenario + " task agent was eligible");
}}
item = batchWithRows(1);
const exceptionalAgent = new FakeElement("div");
exceptionalAgent.className = "conv-agent";
exceptionalAgent.dataset.state = "done";
exceptionalAgent.dataset.agentStepExceptional = "true";
item.rows[0].appendChild(exceptionalAgent);
final = conv.markConvRowResultSettled(item.rows[0]);
assert(final.becameSettled && !final.autoFolded, "exceptional child agent folded");
assert(!conv.isConvBatchCompactEligible(item.batch), "exceptional child agent was eligible");
for (const effect of ["none", "unknown", "partial", "rolled_back", "future-value"]) {{
item = batchWithRows(1);
item.rows[0].dataset.effectStatus = effect;
final = conv.markConvRowResultSettled(item.rows[0]);
assert(final.becameSettled && !final.autoFolded, effect + " effect folded");
assert(!conv.isConvBatchCompactEligible(item.batch), effect + " effect eligible");
}}
for (const effect of [null, "committed"]) {{
item = batchWithRows(1);
if (effect) item.rows[0].dataset.effectStatus = effect;
final = conv.markConvRowResultSettled(item.rows[0]);
assert(final.autoFolded, String(effect) + " routine effect did not fold");
}}
item = batchWithRows(1);
item.rows[0].dataset.effectStatus = "committed";
assert(
conv.setToolOutputReviewState(
item.rows[0],
"Tool result was observed before cancellation. Effect status: committed. " +
"Output review did not complete, so result content was omitted.",
),
"unreviewed cancellation receipt was not classified",
);
final = conv.markConvRowResultSettled(item.rows[0]);
assert(final.becameSettled && !final.autoFolded, "unreviewed receipt folded");
assert(!conv.isConvBatchCompactEligible(item.batch), "unreviewed receipt was eligible");
assert(
!conv.setToolOutputReviewState(item.rows[0], "ordinary accepted output"),
"ordinary output was classified as unreviewed",
);
assert(
!Object.hasOwn(item.rows[0].dataset, "outputReviewIncomplete"),
"replacement output retained stale review state",
);
for (const state of [
"conv-batch--pending",
"conv-batch--running",
"conv-batch--denied",
"conv-batch--error",
]) {{
item = batchWithRows(1);
item.batch.classList.add(state);
final = conv.markConvRowResultSettled(item.rows[0]);
assert(!final.autoFolded, state + " batch folded");
}}
for (const blocker of [
{{ className: "conv-actions" }},
{{ className: "conv-warning" }},
{{ className: "conv-row error" }},
{{ className: "conv-row-result--error" }},
{{ className: "conv-row-status--error" }},
{{ className: "conv-status--error" }},
{{ className: "conv-batch--pending" }},
{{ className: "conv-batch--running" }},
{{ className: "conv-agent", state: "running" }},
{{ className: "conv-agent", agentExceptional: true }},
{{ className: "compaction-running" }},
{{ className: "conv-agent-compaction-notice" }},
{{ className: "conv-verdict--high" }},
{{ className: "conv-verdict--critical" }},
{{ className: "conv-verdict-rec--deny" }},
{{ className: "conv-verdict-rec--review" }},
{{ ariaBusy: true }},
]) {{
item = batchWithRows(1);
const node = new FakeElement("div");
node.className = blocker.className || "";
if (blocker.state) node.dataset.state = blocker.state;
if (blocker.agentExceptional) node.dataset.agentStepExceptional = "true";
if (blocker.ariaBusy) node.setAttribute("aria-busy", "true");
item.rows[0].appendChild(node);
final = conv.markConvRowResultSettled(item.rows[0]);
assert(!final.autoFolded, (blocker.className || "aria-busy") + " folded");
}}
item = batchWithRows(1);
const spinner = new FakeElement("span");
spinner.className = "conv-verdict-spinner";
item.rows[0].appendChild(spinner);
final = conv.markConvRowResultSettled(item.rows[0]);
assert(final.becameSettled && !final.autoFolded, "judging batch folded");
spinner.remove();
assert(conv.isConvBatchCompactEligible(item.batch), "settled batch did not become eligible");
assert(!Object.hasOwn(item.batch.dataset, "compactFolded"), "spinner removal collapsed batch");
item = batchWithRows(1);
const pendingBadge = new FakeElement("div");
pendingBadge.className = "conv-verdict";
const pendingSpinner = new FakeElement("span");
pendingSpinner.className = "conv-verdict-spinner";
pendingBadge.appendChild(pendingSpinner);
item.rows[0].appendChild(pendingBadge);
assert(conv.clearConvVerdictPending(item.rows[0]), "terminal result did not clear judge spinner");
assert(!item.rows[0].querySelector(".conv-verdict"), "empty pending badge survived");
final = conv.markConvRowResultSettled(item.rows[0]);
assert(final.autoFolded, "stale live judge spinner prevented terminal fold");
item = batchWithRows(1);
const siblingBadge = new FakeElement("div");
siblingBadge.className = "conv-verdict conv-verdict--low";
const siblingLabel = new FakeElement("span");
siblingLabel.className = "conv-verdict-risk";
const siblingSpinner = new FakeElement("span");
siblingSpinner.className = "conv-verdict-spinner";
siblingBadge.append(siblingLabel, siblingSpinner);
const provisionalOutput = new FakeElement("pre");
provisionalOutput.className = "tool-output";
item.batch.append(provisionalOutput, siblingBadge);
assert(conv.clearConvVerdictPending(item.rows[0]), "batch-level judge spinner survived");
assert(siblingBadge.parentNode === item.batch, "landed batch verdict was removed");
assert(!siblingBadge.querySelector(".conv-verdict-spinner"), "batch spinner survived");
item = batchWithRows(2);
const laterBadge = new FakeElement("div");
laterBadge.className = "conv-verdict";
const laterSpinner = new FakeElement("span");
laterSpinner.className = "conv-verdict-spinner";
laterBadge.appendChild(laterSpinner);
item.batch.appendChild(laterBadge);
assert(!conv.clearConvVerdictPending(item.rows[0]), "cleanup crossed into the next row");
assert(laterSpinner.parentNode === laterBadge, "later row's judge spinner was removed");
item = batchWithRows(1);
const childAgent = new FakeElement("div");
childAgent.className = "conv-agent";
const childBadge = new FakeElement("div");
childBadge.className = "conv-verdict";
const childSpinner = new FakeElement("span");
childSpinner.className = "conv-verdict-spinner";
childBadge.appendChild(childSpinner);
childAgent.appendChild(childBadge);
item.rows[0].appendChild(childAgent);
assert(!conv.clearConvVerdictPending(item.rows[0]), "parent consumed nested judge spinner");
assert(childSpinner.parentNode === childBadge, "nested judge spinner was removed");
item = batchWithRows(1);
const detail = new FakeElement("button");
item.rows[0].appendChild(detail);
detail.focus();
final = conv.markConvRowResultSettled(item.rows[0]);
assert(!final.autoFolded, "focused detail folded");
item = batchWithRows(1);
const media = new FakeElement("audio");
media.paused = false;
media.pause = () => {{ media.paused = true; }};
item.rows[0].appendChild(media);
document.activeElement = null;
final = conv.markConvRowResultSettled(item.rows[0]);
assert(!final.autoFolded, "playing media auto-folded");
item.disclosure.click();
assert(media.paused, "manual fold did not pause media");
assert(item.batch.dataset.compactFolded === "true", "manual media fold failed");
item.disclosure.focus();
conv.setConvBatchExpanded(item.batch, true, {{ blocker: true }});
const head = item.batch.children[0];
assert(document.activeElement === head, "late blocker stranded disclosure focus");
const actions = new FakeElement("div");
actions.className = "conv-actions";
item.batch.appendChild(actions);
assert(!conv.isConvBatchCompactEligible(item.batch), "late actions remained eligible");
actions.remove();
assert(!Object.hasOwn(item.batch.dataset, "compactFolded"), "blocker removal re-folded");
const orphan = new FakeElement("div");
orphan.className = "conv-row";
const orphanResult = conv.markConvRowResultSettled(orphan);
assert(orphanResult.batch === null && !orphanResult.becameSettled, "orphan did not fail open");
"""
)
proc = subprocess.run(
["node", "--input-type=module", "-e", script],
capture_output=True,
text=True,
timeout=20,
)
assert proc.returncode == 0, proc.stderr
@node_skip
@@ -118,6 +466,113 @@ if (agentContextIsWarning(1, 0)) throw new Error("zero window warned");
assert proc.returncode == 0, proc.stderr
@node_skip
def test_task_agent_compaction_reducer_is_nested_transient_and_id_safe() -> None:
"""Task compaction reuses the shared lifecycle without leaking a result."""
script = f"""
class Element {{
constructor(tag) {{
this.tagName = tag;
this.children = [];
this.parentNode = null;
this.className = "";
this.style = {{}};
this.textContent = "";
this.attributes = {{}};
this.classList = {{
remove: (...names) => {{
const drop = new Set(names);
this.className = this.className
.split(/\\s+/)
.filter((name) => name && !drop.has(name))
.join(" ");
}},
contains: (name) => this.className.split(/\\s+/).includes(name),
}};
}}
setAttribute(name, value) {{ this.attributes[name] = String(value); }}
appendChild(child) {{
if (child.parentNode) child.remove();
this.children.push(child);
child.parentNode = this;
return child;
}}
querySelector(selector) {{
const cls = selector.startsWith(".") ? selector.slice(1) : "";
for (const child of this.children) {{
if (cls && child.className.split(/\\s+/).includes(cls)) return child;
const nested = child.querySelector(selector);
if (nested) return nested;
}}
return null;
}}
remove() {{
if (!this.parentNode) return;
const i = this.parentNode.children.indexOf(this);
if (i >= 0) this.parentNode.children.splice(i, 1);
this.parentNode = null;
}}
}}
globalThis.document = {{ createElement: (tag) => new Element(tag) }};
const {{ applyCompactionEvent }} = await import({json.dumps(_CONVERSATION_JS.as_uri())});
const container = new Element("div");
const nested = new Element("div");
const holder = {{ card: null, cid: null }};
let notices = 0;
let scrolls = 0;
const hooks = {{
container,
renderedIds: new Set(),
renderResult: false,
append: (node) => nested.appendChild(node),
onNotice: () => {{ notices += 1; }},
scroll: () => {{ scrolls += 1; }},
}};
applyCompactionEvent(holder, {{
phase: "start", target: "task_agent", trigger: "auto", compaction_id: 12,
}}, hooks);
if (!holder.card || holder.cid !== "12") throw new Error("start was not retained");
if (nested.children.length !== 1 || container.children.length !== 0)
throw new Error("task progress escaped its nested placement");
const header = holder.card.querySelector(".msg-compaction-header");
if (!header || header.textContent !== "compacting task context… · auto")
throw new Error("task-specific progress copy was not rendered");
applyCompactionEvent(holder, {{
phase: "progress", target: "task_agent", compaction_id: 12,
part: 2, total: 4, depth: 0,
}}, hooks);
const fill = holder.card.querySelector(".msg-compaction-bar-fill");
if (fill.style.width !== "25%") throw new Error("progress did not advance");
applyCompactionEvent(holder, {{
phase: "end", target: "task_agent", compaction_id: 11, ok: true,
summary: "must stay private",
}}, hooks);
if (!holder.card || nested.children.length !== 1)
throw new Error("stale end retired the live task compaction");
if (container.children.length !== 0) throw new Error("task summary leaked to transcript");
applyCompactionEvent(holder, {{
phase: "end", target: "task_agent", compaction_id: 12, ok: true,
summary: "must stay private",
}}, hooks);
if (holder.card || holder.cid != null || nested.children.length !== 0)
throw new Error("owning end did not retire progress");
if (container.children.length !== 0 || notices !== 0)
throw new Error("task end rendered a durable result or notice");
if (scrolls !== 2) throw new Error("unexpected lifecycle scroll count: " + scrolls);
"""
proc = subprocess.run(
["node", "--input-type=module", "-e", script],
capture_output=True,
text=True,
timeout=15,
)
assert proc.returncode == 0, proc.stderr
def test_normalize_risk_level_unknown_to_medium() -> None:
"""Unified canonical fallback (step 5e.1b): an unknown / unrecognized risk
normalizes to "medium" (the user's decision; the coordinator's old rank used
+251 -117
View File
@@ -21,13 +21,17 @@ from unittest.mock import MagicMock, patch
import pytest
from tests._session_helpers import make_result, make_session
from turnstone.core.compaction import (
CompactionIrreducibleError,
CompactionPolicy,
SummaryResult,
SummaryRuntime,
)
from turnstone.core.session import (
COMPACTION_SOURCE,
COMPACTION_SUMMARY_LABEL,
GenerationCancelled,
_CompactionIrreducibleError,
_is_ctx_overflow,
_SummaryResult,
)
from turnstone.core.storage import get_storage
from turnstone.core.trajectory import dicts_from_turns, turns_from_dicts
@@ -57,6 +61,45 @@ def session(tmp_db, mock_openai_client):
return s
def _summary_runtime(
session,
*,
my_generation: int = 0,
) -> SummaryRuntime:
"""Build the foreground adapter while engine tests target its real owner."""
return session._build_summary_runtime(
session._primary_lane(),
my_generation=my_generation,
)
def _summarize_blocks(session, blocks, *, my_generation: int = 0):
return session._compaction_engine.summarize_blocks(
blocks,
_summary_runtime(session, my_generation=my_generation),
)
def _summarize_once(session, system_prompt, body, *, my_generation: int = 0):
return session._compaction_engine.summarize_once(
system_prompt,
body,
_summary_runtime(session, my_generation=my_generation),
)
def _mock_policy(*, owed=False, over_soft=False, over_hard=False):
"""Return a policy-shaped mock for lifecycle wiring tests."""
policy = MagicMock(spec=CompactionPolicy)
policy.owed.side_effect = owed if callable(owed) else None
policy.owed.return_value = owed if not callable(owed) else False
policy.over_soft.return_value = over_soft
policy.over_hard.return_value = over_hard
return policy
# ---------------------------------------------------------------------------
# _estimated_prompt_tokens — the shared fullness measure
# ---------------------------------------------------------------------------
@@ -189,7 +232,7 @@ class TestMidturnCompactionPolicy:
patch.object(session.ui, "on_compaction") as on_compaction,
):
session._do_auto_compact("mid-turn")
impl.assert_called_once_with(True, 0, 0, False)
impl.assert_called_once_with(True, 0, 0, False, compaction_id=1)
start = on_compaction.call_args_list[0].args[0]
assert start["phase"] == "start"
assert start["trigger"] == "auto"
@@ -515,20 +558,14 @@ class TestCompactBeforeTruncate:
assert "call_1" in ids
def test_compaction_owed_predicate(self, session):
policy = session._compaction_policy()
# over hard ceiling (>9000) → owed regardless of the latch
with patch.object(session, "_estimated_prompt_tokens", return_value=9_500):
session._compaction_advised = False
assert session._compaction_owed() is True
assert policy.owed(9_500, advised=False) is True
# over soft (>8000) → owed only when advised
with patch.object(session, "_estimated_prompt_tokens", return_value=8_500):
session._compaction_advised = True
assert session._compaction_owed() is True
session._compaction_advised = False
assert session._compaction_owed() is False
assert policy.owed(8_500, advised=True) is True
assert policy.owed(8_500, advised=False) is False
# under soft → never owed
with patch.object(session, "_estimated_prompt_tokens", return_value=7_000):
session._compaction_advised = True
assert session._compaction_owed() is False
assert policy.owed(7_000, advised=True) is False
def test_owed_compaction_runs_before_truncation_in_tool_path(self, session):
"""Wiring: in the tool path, an owed compaction fires with preserve_tail=1
@@ -554,7 +591,11 @@ class TestCompactBeforeTruncate:
patch.object(session, "_emit_state"),
# Owed on the tool turn (pre-truncation); _estimated_prompt_tokens stays
# small so the end-of-turn path doesn't also compact.
patch.object(session, "_compaction_owed", side_effect=lambda: n["i"] == 1),
patch.object(
session,
"_compaction_policy",
return_value=_mock_policy(owed=lambda *_a, **_k: n["i"] == 1),
),
patch.object(session, "_maybe_compact_midturn"), # isolate the pre-truncation call
patch.object(session, "_do_auto_compact") as compact,
patch("turnstone.core.session.save_message"),
@@ -591,8 +632,11 @@ class TestCompactBeforeTruncate:
patch.object(session, "_print_status_line"),
patch.object(session, "_emit_state"),
# Owed on the tool turn → the pre-truncation compaction fires.
# _compaction_owed takes an optional ``used`` arg, so accept *a/**k.
patch.object(session, "_compaction_owed", side_effect=lambda *a, **k: n["i"] == 1),
patch.object(
session,
"_compaction_policy",
return_value=_mock_policy(owed=lambda *_a, **_k: n["i"] == 1),
),
patch.object(session, "_do_auto_compact") as compact,
patch.object(session, "_maybe_compact_midturn") as midturn,
patch("turnstone.core.session.save_message"),
@@ -631,7 +675,11 @@ class TestCompactBeforeTruncate:
patch.object(session, "_print_status_line"),
patch.object(session, "_emit_state"),
# Never owed → no pre-truncation compaction this iteration.
patch.object(session, "_compaction_owed", side_effect=lambda *a, **k: False),
patch.object(
session,
"_compaction_policy",
return_value=_mock_policy(owed=False),
),
patch.object(session, "_do_auto_compact") as compact,
patch.object(session, "_maybe_compact_midturn") as midturn,
patch("turnstone.core.session.save_message"),
@@ -688,7 +736,11 @@ class TestCompactBeforeTruncate:
patch.object(session, "_update_token_table"),
patch.object(session, "_emit_state"),
patch.object(session, "_estimated_prompt_tokens", return_value=0),
patch.object(session, "_compaction_owed", return_value=False),
patch.object(
session,
"_compaction_policy",
return_value=_mock_policy(owed=False),
),
patch.object(session, "_remaining_token_budget", return_value=0),
patch.object(
session, "_compact_messages", side_effect=compact_then_terminal
@@ -731,7 +783,7 @@ class TestPackBlocks:
def test_preserves_all_blocks_and_order(self, session):
blocks = [f"block-{i}-{'x' * 50}" for i in range(10)]
batches = session._pack_blocks(blocks, budget_chars=200)
batches = session._compaction_engine.pack_blocks(blocks, budget_chars=200)
flat = [b for batch in batches for b in batch]
assert flat == blocks # every block present, order preserved
assert all(batch for batch in batches) # never an empty batch
@@ -739,7 +791,7 @@ class TestPackBlocks:
def test_each_batch_within_budget(self, session):
budget = 200
blocks = ["a" * 80 for _ in range(12)]
batches = session._pack_blocks(blocks, budget_chars=budget)
batches = session._compaction_engine.pack_blocks(blocks, budget_chars=budget)
for batch in batches:
assert len("\n\n".join(batch)) <= budget
@@ -747,7 +799,7 @@ class TestPackBlocks:
budget = 100
exact = "y" * budget # len == budget: fits a batch, not oversized
blocks = ["short", exact, "tail"]
batches = session._pack_blocks(blocks, budget_chars=budget)
batches = session._compaction_engine.pack_blocks(blocks, budget_chars=budget)
flat = [b for batch in batches for b in batch]
assert flat == blocks # order + presence
assert exact in flat # untouched, not truncated
@@ -758,7 +810,7 @@ class TestPackBlocks:
budget = 100
huge = "z" * 500 # > budget → its own truncated batch
blocks = ["before", huge, "after"]
batches = session._pack_blocks(blocks, budget_chars=budget)
batches = session._compaction_engine.pack_blocks(blocks, budget_chars=budget)
flat = [b for batch in batches for b in batch]
assert flat[0] == "before" and flat[-1] == "after" # neighbours survive
truncated = [b for b in flat if "[truncated" in b]
@@ -776,17 +828,21 @@ class TestSummaryInputBudget:
def test_scales_with_context_window(self, session):
session.compact_max_tokens = 100
session.context_window = 20_000
smaller = session._summary_input_budget_chars()
smaller = session._compaction_engine.summary_input_budget_chars(_summary_runtime(session))
session.context_window = 40_000
larger = session._summary_input_budget_chars()
larger = session._compaction_engine.summary_input_budget_chars(_summary_runtime(session))
assert larger > smaller
def test_subtracts_output_reserve(self, session):
session.context_window = 50_000
session.compact_max_tokens = 100
small_reserve = session._summary_input_budget_chars()
small_reserve = session._compaction_engine.summary_input_budget_chars(
_summary_runtime(session)
)
session.compact_max_tokens = 20_000 # larger output reserve
large_reserve = session._summary_input_budget_chars()
large_reserve = session._compaction_engine.summary_input_budget_chars(
_summary_runtime(session)
)
assert large_reserve < small_reserve # less room left for input
def test_budget_never_exceeds_true_input_capacity(self, session):
@@ -798,14 +854,19 @@ class TestSummaryInputBudget:
session.context_window = 1200
session.compact_max_tokens = 1200
session._system_tokens = 0
budget_chars = session._summary_input_budget_chars()
budget_chars = session._compaction_engine.summary_input_budget_chars(
_summary_runtime(session)
)
prompt_tokens = int(
(len(session._COMPACTOR_SYSTEM_PROMPT) + len(session._COMPACT_USER_PREFIX))
(
len(session._compaction_engine.COMPACTOR_SYSTEM_PROMPT)
+ len(session._compaction_engine.COMPACT_USER_PREFIX)
)
/ session._chars_per_token
)
# The full summary call (output reserve + budgeted input + prompt) fits.
total = (
session._summary_output_tokens()
session._compaction_engine.summary_output_tokens(_summary_runtime(session))
+ budget_chars / session._chars_per_token
+ prompt_tokens
)
@@ -849,7 +910,7 @@ class TestChunkedCompaction:
session.context_window = 5_000
session.compact_max_tokens = 4_000 # squeezes the input budget to the floor
session._system_tokens = 0
budget = session._summary_input_budget_chars()
budget = session._compaction_engine.summary_input_budget_chars(_summary_runtime(session))
session.messages = turns_from_dicts(
[
@@ -866,7 +927,7 @@ class TestChunkedCompaction:
def fake_uc(messages, **_kwargs):
body = messages[1].text
prefix = session._COMPACT_USER_PREFIX
prefix = session._compaction_engine.COMPACT_USER_PREFIX
if body.startswith(prefix):
body = body[len(prefix) :]
recorded.append(len(body))
@@ -895,27 +956,29 @@ class TestChunkedCompaction:
)
session._msg_tokens = [1] * len(session.messages)
pinned_lane = session._primary_lane()
seen_lanes: list[object] = []
seen_runtimes: list[SummaryRuntime] = []
prompts: list[str] = []
def fake_once(system_prompt, _body, _my_generation=0, *, lane=None):
def fake_once(system_prompt, _body, runtime):
# Ancillary post-fold accounting may resolve capabilities again, but
# recursive summarization itself must not re-resolve its lane.
assert primary_lane.call_count == 1
seen_lanes.append(lane)
seen_runtimes.append(runtime)
prompts.append(system_prompt)
return _SummaryResult(text="fold", producer="summary-producer")
return SummaryResult(text="fold", producer="summary-producer")
with (
patch.object(session, "_primary_lane", return_value=pinned_lane) as primary_lane,
patch.object(session, "_summary_input_budget_chars", return_value=450),
patch.object(session, "_summarize_once", side_effect=fake_once),
patch.object(
session._compaction_engine, "summary_input_budget_chars", return_value=450
),
patch.object(session._compaction_engine, "summarize_once", side_effect=fake_once),
):
assert session._compact_messages(auto=False) is True
assert prompts.count(session._COMPACTOR_SYSTEM_PROMPT) >= 2
assert prompts[-1] == session._COMPACTOR_MERGE_SYSTEM_PROMPT
assert seen_lanes and all(lane is pinned_lane for lane in seen_lanes)
assert prompts.count(session._compaction_engine.COMPACTOR_SYSTEM_PROMPT) >= 2
assert prompts[-1] == session._compaction_engine.COMPACTOR_MERGE_SYSTEM_PROMPT
assert seen_runtimes and all(runtime is seen_runtimes[0] for runtime in seen_runtimes)
def test_recursion_depth_ceiling_bails_to_false(self, session):
"""q-3: the ``depth >= _MAX_SUMMARY_DEPTH`` recursion backstop bails to
@@ -929,8 +992,10 @@ class TestChunkedCompaction:
session.context_window = 5_000
session.compact_max_tokens = 4_000 # squeezes the input budget
session._system_tokens = 0
session._MAX_SUMMARY_DEPTH = 1 # positive, so depth 0 runs before the bail
budget = session._summary_input_budget_chars()
session._compaction_engine.MAX_SUMMARY_DEPTH = (
1 # positive, so depth 0 runs before the bail
)
budget = session._compaction_engine.summary_input_budget_chars(_summary_runtime(session))
# ~30 messages, each block bigger than 1/6 of the budget → depth 0 packs
# into several batches and recurses (depth 0 < MAX).
@@ -1010,9 +1075,15 @@ class TestChunkedCompaction:
with patch.object(session, "_get_capabilities", return_value=caps):
assert session._get_capabilities().max_output_tokens == 64000
# Output reserve never claims more than half the window...
assert session._summary_output_tokens() <= session.context_window // 2
assert (
session._compaction_engine.summary_output_tokens(_summary_runtime(session))
<= session.context_window // 2
)
# ...so the input budget is healthy, not floored to 2000.
assert session._summary_input_budget_chars() > 10_000
assert (
session._compaction_engine.summary_input_budget_chars(_summary_runtime(session))
> 10_000
)
session._system_tokens = 0
session.messages = turns_from_dicts(
@@ -1044,7 +1115,10 @@ class TestChunkedCompaction:
assert recorded # at least one summary call happened
out_tokens = recorded[0]
assert out_tokens <= session.context_window // 2
rep_input_tokens = session._summary_input_budget_chars() / session._chars_per_token
rep_input_tokens = (
session._compaction_engine.summary_input_budget_chars(_summary_runtime(session))
/ session._chars_per_token
)
assert out_tokens + rep_input_tokens < session.context_window
def test_empty_summary_keeps_history(self, session):
@@ -1450,18 +1524,20 @@ class TestChunkerOverflowSplit:
blocks = ["A" * 4000, "B" * 4000, "C" * 4000]
bodies: list[int] = []
def fake_once(_system_prompt, body, _my_generation=0, *, lane=None):
assert lane is not None
def fake_once(_system_prompt, body, runtime):
assert isinstance(runtime, SummaryRuntime)
bodies.append(len(body))
if len(body) > 6_000: # a multi-block body overflows the token window
raise RuntimeError("maximum context length is 524288 tokens")
return _SummaryResult(text="S", producer="summary-producer")
return SummaryResult(text="S", producer="summary-producer")
with (
patch.object(session, "_summary_input_budget_chars", return_value=100_000),
patch.object(session, "_summarize_once", side_effect=fake_once),
patch.object(
session._compaction_engine, "summary_input_budget_chars", return_value=100_000
),
patch.object(session._compaction_engine, "summarize_once", side_effect=fake_once),
):
result = session._summarize_blocks(blocks)
result = _summarize_blocks(session, blocks)
assert result.text == "S" # produced a summary, never raised _CompactionIrreducible
assert any(n > 6_000 for n in bodies) # the combined batch overflowed…
@@ -1478,18 +1554,20 @@ class TestChunkerOverflowSplit:
blocks = [f"b{i:02d} " + "z" * 500 for i in range(8)]
calls: list[str] = []
def fake_once(_system_prompt, body, _my_generation=0, *, lane=None):
assert lane is not None
def fake_once(_system_prompt, body, runtime):
assert isinstance(runtime, SummaryRuntime)
calls.append(body)
if body.count("\n\n") >= 4: # a body of 5+ blocks overflows the window
raise RuntimeError("maximum context length is 524288 tokens")
return _SummaryResult(text="S", producer="summary-producer")
return SummaryResult(text="S", producer="summary-producer")
with (
patch.object(session, "_summary_input_budget_chars", return_value=1_000_000),
patch.object(session, "_summarize_once", side_effect=fake_once),
patch.object(
session._compaction_engine, "summary_input_budget_chars", return_value=1_000_000
),
patch.object(session._compaction_engine, "summarize_once", side_effect=fake_once),
):
result = session._summarize_blocks(blocks)
result = _summarize_blocks(session, blocks)
assert result.text == "S"
# Binary subdivision: [8] → two [4] halves that both fit — a handful of calls,
@@ -1502,21 +1580,23 @@ class TestChunkerOverflowSplit:
def test_lone_oversized_block_floored_then_succeeds(self, session):
# A single block that overflows even by itself is head/tail-truncated to
# the floor and retried once — not bailed.
floor = session._MIN_SUMMARY_BUDGET_CHARS
floor = session._compaction_engine.MIN_SUMMARY_BUDGET_CHARS
calls: list[int] = []
def fake_once(_system_prompt, body, _my_generation=0, *, lane=None):
assert lane is not None
def fake_once(_system_prompt, body, runtime):
assert isinstance(runtime, SummaryRuntime)
calls.append(len(body))
if len(body) > floor:
raise RuntimeError("maximum context length is 524288 tokens")
return _SummaryResult(text="S", producer="summary-producer")
return SummaryResult(text="S", producer="summary-producer")
with (
patch.object(session, "_summary_input_budget_chars", return_value=50_000),
patch.object(session, "_summarize_once", side_effect=fake_once),
patch.object(
session._compaction_engine, "summary_input_budget_chars", return_value=50_000
),
patch.object(session._compaction_engine, "summarize_once", side_effect=fake_once),
):
result = session._summarize_blocks(["Z" * 20_000])
result = _summarize_blocks(session, ["Z" * 20_000])
assert result.text == "S" # floored block summarized, not bailed
assert any(n > floor for n in calls) # the over-floor call overflowed…
@@ -1527,21 +1607,23 @@ class TestChunkerOverflowSplit:
NOT slammed straight to the 2 000-char floor so when a mid-size truncation
already fits the window, far more of the message survives than a floor jump
would keep (the single-block analogue of the multi-block binary subdivision)."""
floor = session._MIN_SUMMARY_BUDGET_CHARS
floor = session._compaction_engine.MIN_SUMMARY_BUDGET_CHARS
calls: list[int] = []
def fake_once(_system_prompt, body, _my_generation=0, *, lane=None):
assert lane is not None
def fake_once(_system_prompt, body, runtime):
assert isinstance(runtime, SummaryRuntime)
calls.append(len(body))
if len(body) > 9_000: # only bodies well above the floor overflow
raise RuntimeError("maximum context length is 524288 tokens")
return _SummaryResult(text="S", producer="summary-producer")
return SummaryResult(text="S", producer="summary-producer")
with (
patch.object(session, "_summary_input_budget_chars", return_value=50_000),
patch.object(session, "_summarize_once", side_effect=fake_once),
patch.object(
session._compaction_engine, "summary_input_budget_chars", return_value=50_000
),
patch.object(session._compaction_engine, "summarize_once", side_effect=fake_once),
):
result = session._summarize_blocks(["Z" * 16_000])
result = _summarize_blocks(session, ["Z" * 16_000])
assert result.text == "S"
# First shrink budget is len//2 == 8 000 (< the 9 000 overflow line), so it
@@ -1553,22 +1635,24 @@ class TestChunkerOverflowSplit:
def test_non_shrinking_merge_bails_at_depth_not_recursionerror(self, session):
"""If per-block summaries never compress (the merge keeps overflowing),
recursion is bounded by the depth ceiling and bails to
_CompactionIrreducibleError NOT an unbounded recurse into RecursionError.
CompactionIrreducibleError NOT an unbounded recurse into RecursionError.
Regression for the depth-check-only-on-the-multi-batch-path bug."""
def no_shrink(_system_prompt, body, _my_generation=0, *, lane=None):
assert lane is not None
def no_shrink(_system_prompt, body, runtime):
assert isinstance(runtime, SummaryRuntime)
if "\n\n" in body: # any multi-block body overflows the window
raise RuntimeError("maximum context length is 524288 tokens")
# A single-block 'summary' is the block itself — no shrink.
return _SummaryResult(text=body, producer="summary-producer")
return SummaryResult(text=body, producer="summary-producer")
with (
patch.object(session, "_summary_input_budget_chars", return_value=100_000),
patch.object(session, "_summarize_once", side_effect=no_shrink),
pytest.raises(_CompactionIrreducibleError),
patch.object(
session._compaction_engine, "summary_input_budget_chars", return_value=100_000
),
patch.object(session._compaction_engine, "summarize_once", side_effect=no_shrink),
pytest.raises(CompactionIrreducibleError),
):
session._summarize_blocks(["A" * 4000, "B" * 4000, "C" * 4000])
_summarize_blocks(session, ["A" * 4000, "B" * 4000, "C" * 4000])
def test_later_batch_overflow_keeps_completed_siblings(self, session):
"""A later batch overflowing and splitting does NOT re-summarize earlier
@@ -1578,18 +1662,20 @@ class TestChunkerOverflowSplit:
blocks = ["A" * 2000, "B" * 2000, "C" * 2000, "D" * 2000]
bodies: list[str] = []
def fake_once(_system_prompt, body, _my_generation=0, *, lane=None):
assert lane is not None
def fake_once(_system_prompt, body, runtime):
assert isinstance(runtime, SummaryRuntime)
bodies.append(body)
if "CC" in body and "\n\n" in body: # the multi-block batch holding C
raise RuntimeError("maximum context length is 524288 tokens")
return _SummaryResult(text="S", producer="summary-producer")
return SummaryResult(text="S", producer="summary-producer")
with (
patch.object(session, "_summary_input_budget_chars", return_value=4_500),
patch.object(session, "_summarize_once", side_effect=fake_once),
patch.object(
session._compaction_engine, "summary_input_budget_chars", return_value=4_500
),
patch.object(session._compaction_engine, "summarize_once", side_effect=fake_once),
):
result = session._summarize_blocks(blocks)
result = _summarize_blocks(session, blocks)
assert result.text == "S"
# The first batch (A+B) was summarized exactly once, never recomputed after
@@ -1619,7 +1705,9 @@ class TestChunkerOverflowSplit:
try:
with (
patch.object(session, "_summary_input_budget_chars", return_value=3_500),
patch.object(
session._compaction_engine, "summary_input_budget_chars", return_value=3_500
),
patch.object(session, "_utility_completion", side_effect=cancel_then_summarize),
pytest.raises(GenerationCancelled),
):
@@ -1652,7 +1740,9 @@ class TestChunkerOverflowSplit:
try:
with (
# Huge budget → all blocks pack into ONE batch → exactly one call.
patch.object(session, "_summary_input_budget_chars", return_value=100_000),
patch.object(
session._compaction_engine, "summary_input_budget_chars", return_value=100_000
),
patch.object(session, "_utility_completion", side_effect=cancel_during_call),
pytest.raises(GenerationCancelled),
):
@@ -1679,7 +1769,9 @@ class TestChunkerOverflowSplit:
before = list(session.messages)
try:
with (
patch.object(session, "_summary_input_budget_chars", return_value=100_000),
patch.object(
session._compaction_engine, "summary_input_budget_chars", return_value=100_000
),
patch.object(session, "_utility_completion") as uc,
pytest.raises(GenerationCancelled),
):
@@ -1737,7 +1829,9 @@ class TestChunkerOverflowSplit:
content="SUMMARY", finish_reason="stop", producer="summary-producer"
)
with (
patch.object(session, "_summary_input_budget_chars", return_value=100_000),
patch.object(
session._compaction_engine, "summary_input_budget_chars", return_value=100_000
),
patch.object(session, "_utility_completion", return_value=summary),
pytest.raises(GenerationCancelled),
):
@@ -1775,9 +1869,9 @@ class TestChunkerOverflowSplit:
with (
patch.object(
session,
"_summarize_blocks",
return_value=_SummaryResult(text="stale summary", producer="stale-producer"),
session._compaction_engine,
"summarize_blocks",
return_value=SummaryResult(text="stale summary", producer="stale-producer"),
),
patch(
"turnstone.core.session.require_lane_capabilities", side_effect=block_before_commit
@@ -1939,6 +2033,26 @@ class _ObservedRLock:
class TestCompactionPublicationFence:
def test_repeated_workstream_compactions_use_distinct_attempt_ids(self, session):
_seed_two_messages(session)
summary = SimpleNamespace(
content="dense summary",
finish_reason="stop",
producer="summary-producer",
)
with (
patch.object(session, "_utility_completion", return_value=summary),
patch.object(session.ui, "on_compaction") as on_compaction,
patch("turnstone.core.session.save_message"),
):
assert session._compact_messages() is True
assert session._compact_messages() is True
events = _compaction_events(on_compaction)
assert [event["phase"] for event in events] == ["start", "end", "start", "end"]
assert [event["compaction_id"] for event in events] == [1, 1, 2, 2]
assert {event["target"] for event in events} == {"workstream"}
@pytest.mark.parametrize("phase", ["start", "progress"])
@pytest.mark.parametrize("terminal", ["superseded", "cancelled", "closed"])
def test_non_end_event_is_not_emitted_after_terminal_boundary(
@@ -2063,7 +2177,9 @@ class TestCompactionLifecycleEvents:
def test_summary_error_emits_failed_end_and_returns_false(self, session):
_seed_two_messages(session)
with (
patch.object(session, "_summarize_blocks", side_effect=RuntimeError("boom")),
patch.object(
session._compaction_engine, "summarize_blocks", side_effect=RuntimeError("boom")
),
patch.object(session.ui, "on_compaction") as oc,
):
assert session._compact_messages() is False
@@ -2076,7 +2192,11 @@ class TestCompactionLifecycleEvents:
"""A presentation teardown failure cannot re-enter the END backstop."""
_seed_two_messages(session)
with (
patch.object(session, "_summarize_blocks", side_effect=RuntimeError("summary boom")),
patch.object(
session._compaction_engine,
"summarize_blocks",
side_effect=RuntimeError("summary boom"),
),
patch.object(session.ui, "on_thinking_stop", side_effect=RuntimeError("ui boom")),
patch.object(session.ui, "on_compaction") as on_compaction,
):
@@ -2099,7 +2219,7 @@ class TestCompactionLifecycleEvents:
summary_started.set()
if not release_summary.wait(2):
raise RuntimeError("test release timed out")
return _SummaryResult(text="late summary", producer="summary-producer")
return SummaryResult(text="late summary", producer="summary-producer")
def run_compaction() -> None:
try:
@@ -2108,7 +2228,7 @@ class TestCompactionLifecycleEvents:
outcomes.append(exc)
with (
patch.object(session, "_summarize_blocks", side_effect=summarize),
patch.object(session._compaction_engine, "summarize_blocks", side_effect=summarize),
patch.object(session.ui, "on_thinking_stop") as thinking_stop,
):
worker = threading.Thread(target=run_compaction)
@@ -2128,7 +2248,11 @@ class TestCompactionLifecycleEvents:
def test_irreducible_emits_failed_end(self, session):
_seed_two_messages(session)
with (
patch.object(session, "_summarize_blocks", side_effect=_CompactionIrreducibleError()),
patch.object(
session._compaction_engine,
"summarize_blocks",
side_effect=CompactionIrreducibleError(),
),
patch.object(session.ui, "on_compaction") as oc,
):
assert session._compact_messages() is False
@@ -2151,7 +2275,9 @@ class TestCompactionLifecycleEvents:
retire the in-progress card via a cancelled end event."""
_seed_two_messages(session)
with (
patch.object(session, "_summarize_blocks", side_effect=GenerationCancelled()),
patch.object(
session._compaction_engine, "summarize_blocks", side_effect=GenerationCancelled()
),
patch.object(session.ui, "on_compaction") as oc,
pytest.raises(GenerationCancelled),
):
@@ -2169,7 +2295,9 @@ class TestCompactionLifecycleEvents:
(str(KeyboardInterrupt()) is '')."""
_seed_two_messages(session)
with (
patch.object(session, "_summarize_blocks", side_effect=KeyboardInterrupt()),
patch.object(
session._compaction_engine, "summarize_blocks", side_effect=KeyboardInterrupt()
),
patch.object(session.ui, "on_error") as on_error,
patch.object(session.ui, "on_compaction") as oc,
pytest.raises(KeyboardInterrupt),
@@ -2264,20 +2392,22 @@ class TestCompactionLifecycleEvents:
saved: dict = {}
producers: list[str] = []
def fake_once(system_prompt, _body, _my_generation=0, *, lane=None):
assert lane is not None
is_merge = system_prompt == session._COMPACTOR_MERGE_SYSTEM_PROMPT
def fake_once(system_prompt, _body, runtime):
assert isinstance(runtime, SummaryRuntime)
is_merge = system_prompt == session._compaction_engine.COMPACTOR_MERGE_SYSTEM_PROMPT
producer = "final-merge-producer" if is_merge else "leaf-producer"
producers.append(producer)
return _SummaryResult(text="FINAL" if is_merge else "partial", producer=producer)
return SummaryResult(text="FINAL" if is_merge else "partial", producer=producer)
def fake_save(ws_id, role, content, **kwargs):
saved.update({"ws_id": ws_id, "role": role, "content": content, **kwargs})
return 1
with (
patch.object(session, "_summary_input_budget_chars", return_value=450),
patch.object(session, "_summarize_once", side_effect=fake_once),
patch.object(
session._compaction_engine, "summary_input_budget_chars", return_value=450
),
patch.object(session._compaction_engine, "summarize_once", side_effect=fake_once),
patch.object(get_storage(), "get_compaction_watermark", return_value=17),
patch("turnstone.core.session.save_message", side_effect=fake_save),
):
@@ -2337,7 +2467,9 @@ class TestCompactNow:
raise GenerationCancelled()
with (
patch.object(session, "_summarize_blocks", side_effect=cancel_mid_summary),
patch.object(
session._compaction_engine, "summarize_blocks", side_effect=cancel_mid_summary
),
pytest.raises(GenerationCancelled),
):
session.compact_now()
@@ -2363,10 +2495,10 @@ class TestCompactNow:
# while this compaction is inside its summarize call.
session._generation += 1
session._cancel_event = threading.Event()
return _SummaryResult(text="stale summary", producer="stale-producer")
return SummaryResult(text="stale summary", producer="stale-producer")
with (
patch.object(session, "_summarize_blocks", side_effect=supersede),
patch.object(session._compaction_engine, "summarize_blocks", side_effect=supersede),
pytest.raises(GenerationCancelled),
):
session.compact_now()
@@ -2875,7 +3007,7 @@ class TestOrphanedCompactionRetirement:
patch.object(session, "_utility_completion") as uc,
pytest.raises(GenerationCancelled),
):
session._summarize_blocks(["block-a"], my_generation=4)
_summarize_blocks(session, ["block-a"], my_generation=4)
uc.assert_not_called() # retired BEFORE spending another model call
def test_cancel_during_retry_backoff_aborts_immediately(self, session):
@@ -2888,7 +3020,7 @@ class TestOrphanedCompactionRetirement:
patch.object(session, "_stop_retrying", return_value=False),
pytest.raises(GenerationCancelled),
):
session._summarize_once("sys", "body")
_summarize_once(session, "sys", "body")
def test_summary_call_registers_abortable_stream(self, session):
"""Each summary attempt passes a fresh _CancelRef so cancel() can
@@ -2913,8 +3045,8 @@ class TestOrphanedCompactionRetirement:
)
with patch.object(session, "_utility_completion", side_effect=fake_uc):
assert session._summarize_once("sys", "body").text == "dense"
assert session._summarize_once("sys", "body").text == "dense"
assert _summarize_once(session, "sys", "body").text == "dense"
assert _summarize_once(session, "sys", "body").text == "dense"
assert len(seen) == 2
assert all(isinstance(ref, _CancelRef) for ref in seen)
assert seen[0] is not seen[1] # scoped to its call, never reused
@@ -2980,7 +3112,7 @@ class TestOrphanedCompactionRetirement:
)
with patch.object(session, "_utility_completion", side_effect=fake_uc):
session._summarize_once("sys", "body", my_generation=3)
_summarize_once(session, "sys", "body", my_generation=3)
assert isinstance(seen[0], _CancelRef)
assert seen[0]._my_generation == 3
@@ -2996,7 +3128,7 @@ class TestOrphanedCompactionRetirement:
)
with patch.object(session, "_utility_completion", side_effect=fake_uc):
session._summarize_once("sys", "body", my_generation=3)
_summarize_once(session, "sys", "body", my_generation=3)
assert seen == ["user-a"]
@@ -3013,7 +3145,7 @@ class TestOrphanedCompactionRetirement:
patch.object(session, "_utility_completion", side_effect=fake_uc),
pytest.raises(GenerationCancelled),
):
session._summarize_once("sys", "body")
_summarize_once(session, "sys", "body")
# ---------------------------------------------------------------------------
@@ -3028,7 +3160,9 @@ class TestCompactionErrorChannel:
failures fed before the lifecycle events replaced on_error here."""
_seed_two_messages(session)
with (
patch.object(session, "_summarize_blocks", side_effect=RuntimeError("boom")),
patch.object(
session._compaction_engine, "summarize_blocks", side_effect=RuntimeError("boom")
),
patch.object(session.ui, "on_error") as on_error,
patch.object(session.ui, "on_compaction") as oc,
):
+113
View File
@@ -15,6 +15,7 @@ from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
from tests._js_harness_helpers import extract_braced as _extract_braced
from tests._js_harness_helpers import strip_js_comments as _strip_comments
from turnstone.console.server import coordinator_page
@@ -1246,6 +1247,118 @@ def test_coordinator_chrome_builder_and_thin_page():
assert (base / "coord-chrome.css").exists(), "the migrated chrome stylesheet must exist"
def test_compact_presentation_coordinator_wiring_and_ordering():
"""Coordinator retains its own result lifecycle but shares presentation.
The running-state cleanup must precede settlement, which must precede the
coordinator's existing forced scroll. Replay stages the same fold without
a completion announcement, and only the rail-less page mounts a control.
"""
from pathlib import Path
base = Path(__file__).resolve().parent.parent / "turnstone/console/static/coordinator"
body = (base / "coordinator.js").read_text(encoding="utf-8")
css = (base / "coord-chrome.css").read_text(encoding="utf-8")
for symbol in (
"clearConvVerdictPending",
"getTranscriptPresentation",
"mountTranscriptPresentationToggle",
"preserveTranscriptBottomPin",
"registerTranscriptScroller",
"isConvVerdictCompactBlocker",
"markConvRowResultSettled",
"setReasoningActivity",
"setConvBatchExpanded",
):
assert symbol in body
chrome = _extract_braced(body, "function buildCoordChrome(root, opts) {")
toolbar = chrome.index('id: "coord-standalone-toolbar"')
coord_body = chrome.index('id: "coord-body"')
assert "if (opts.standalone)" in chrome[:toolbar]
assert toolbar < coord_body
assert 'role: "toolbar"' in chrome
assert "mountTranscriptPresentationToggle" not in chrome
assert "#coord-standalone-toolbar" in css
create = _extract_braced(body, "function createCoordinatorPane(root, wsId, opts) {")
mount = create.index("mountTranscriptPresentationToggle(")
assert create.rfind("if (opts && opts.standalone)", 0, mount) >= 0
assert 'querySelector("#coord-status-bar")' in create
status_mount = create.index('querySelector("#coord-status-bar")')
assert mount < status_mount, "the presentation control must not enter the live status region"
assert "registerTranscriptScroller(messagesEl)" in create
live = _extract_braced(
body,
" function appendToolResult(name, callId, output, isError, opts) {",
)
append = live.index("_appendResultToRow(")
running_cleanup = live.index("_unsetBatchRunningIfAllResults(", append)
settlement = live.index("markConvRowResultSettled(", running_cleanup)
forced_scroll = live.index("_scheduleScroll()", settlement)
assert append < running_cleanup < settlement < forced_scroll
assert "settlement.autoFolded" in live
assert 'getTranscriptPresentation() === "compact"' in live
assert '_announcePolite("Completed: "' in live
replay = _extract_braced(body, " async function refetchHistory(seedCursor = false) {")
occurrence = replay.index("if (occurrence && occurrence.row)")
replay_append = replay.index("_appendResultToRow(", occurrence)
replay_cleanup = replay.index("_unsetBatchRunningIfAllResults(", replay_append)
replay_settlement = replay.index("markConvRowResultSettled(", replay_cleanup)
assert replay_append < replay_cleanup < replay_settlement
assert '_announcePolite("Completed: "' not in replay[occurrence:replay_settlement]
tier = _extract_braced(body, " function _refreshBatchTierImmediate(batch) {")
assert 'querySelector(".conv-batch-disclosure")' in tier
assert "head.insertBefore(tierEl, disclosure || null)" in tier
result = _extract_braced(
body,
" function _appendResultToRow(row, output, isError, opts) {",
)
assert result.index("clearConvVerdictPending(row)") < result.index("row.dataset.effectStatus")
assert result.index("setToolOutputReviewState(row, output)") < result.index(
"row.dataset.effectStatus"
)
assert result.index("setConvBatchExpanded(") < result.index(
'batch.classList.add("conv-batch--error")'
)
warning = _extract_braced(body, " function _attachOutputWarningChip(row, oa) {")
assert warning.index("setConvBatchExpanded(") < warning.index("buildConvWarning(")
pending = _extract_braced(body, " function _appendJudgePendingLineTo(row) {")
assert pending.index("setConvBatchExpanded(") < pending.index("buildConvVerdict(null")
verdict = _extract_braced(body, " function _appendVerdictLineTo(row, verdict) {")
pin = verdict.index("preserveTranscriptBottomPin(")
assert "row.isConnected" in verdict[:pin]
assert verdict.index("setConvBatchExpanded(") < verdict.index("buildConvVerdict(verdict)") < pin
assert "? preserveTranscriptBottomPin(messagesEl, renderVerdict)" in verdict
assert ": renderVerdict();" in verdict
destroy = _extract_braced(body, " function destroy() {")
assert "unregisterTranscriptScroller();" in destroy
assert "unmountTranscriptPresentation();" in destroy
reasoning = _extract_braced(body, " function appendReasoningToken(text) {")
assert "setReasoningActivity(currentReasoningEl, true)" in reasoning
content = _extract_braced(body, " function appendContentToken(text) {")
assert "setReasoningActivity(currentReasoningEl, false)" in content
finish = _extract_braced(body, " function finishAssistantStream() {")
assert "setReasoningActivity(currentReasoningEl, false)" in finish
busy = _extract_braced(body, " function setBusy(b, source) {")
assert "if (!next) {" in busy
assert "setReasoningActivity(currentReasoningEl, false);" in busy
assert "currentReasoningEl = null;" in busy
assert 'currentReasoningBuf = "";' in busy
cancelled = body[body.index('case "cancelled"') : body.index('case "clear_ui"')]
assert cancelled.index("setReasoningActivity(currentReasoningEl, false)") < cancelled.index(
"if (!busy) break"
)
def test_coordinator_close_409_uses_plain_retry_copy():
from pathlib import Path
+32 -2
View File
@@ -87,6 +87,7 @@ def test_interactive_stale_backstop_waits_for_replay_tail_runtime(
script = """
function resetCompactionHolder() {}
function setReasoningActivity() {}
function streamingRender(body, text) { body.textContent = text; }
function streamingRenderFinalize(body, text) { body.textContent = text; }
"""
@@ -117,6 +118,7 @@ const pane = {
_forceTimeout: null,
_compaction: {},
_agentContexts: new Map(),
_agentCompactions: new Map(),
_streamHealth: { renderThrows: 0 },
_actingUserId: null,
pendingApproval: false,
@@ -451,6 +453,13 @@ function makeRow(callId) {
return { batch, row };
}
function _tryMcpErrorBlock() { return null; }
function clearConvVerdictPending() { return false; }
function setToolOutputReviewState(row, output) {
const incomplete = String(output || "").includes("Output review did not complete");
if (incomplete) row.dataset.outputReviewIncomplete = "true";
else delete row.dataset.outputReviewIncomplete;
return incomplete;
}
function buildConvResult(output, opts) {
const node = makeNode("output");
node.output = output;
@@ -511,6 +520,12 @@ function _appendJudgePendingLineTo() {}
function _announceAssertive() {}
function _announcePolite() {}
function _toolAnnounceText() { return "tool"; }
function setConvBatchExpanded() { return false; }
function markConvRowResultSettled() {
return { becameSettled: false, autoFolded: false };
}
function getTranscriptPresentation() { return "default"; }
function convBatchSummaryText() { return "tool batch"; }
const toolRows = new Map();
const latestToolRowElements = new Map();
@@ -800,8 +815,22 @@ function buildToolDiv(item) {
}
function indexLabel() { return ""; }
function buildConvVerdict() { return makeNode("verdict"); }
function clearConvVerdictPending() { return false; }
function setToolOutputReviewState(row, output) {
const incomplete = String(output || "").includes("Output review did not complete");
if (incomplete) row.dataset.outputReviewIncomplete = "true";
else delete row.dataset.outputReviewIncomplete;
return incomplete;
}
function toolAnnounce() {}
function _toolAnnounceText() { return "tool"; }
function setConvBatchExpanded() { return false; }
function markConvRowResultSettled() {
return { becameSettled: false, autoFolded: false };
}
function getTranscriptPresentation() { return "default"; }
function canAutoFoldTranscriptBatch() { return false; }
function convBatchSummaryText() { return "tool batch"; }
const handleEvent = %(handle)s;
const appendToolOutput = %(append)s;
@@ -830,8 +859,9 @@ const pane = {
indexLatestToolRow(latestRows, this._toolResultNodes, row.dataset.callId, row);
});
},
_relinkAgentCards() {},
_streamEl() { return null; },
_relinkAgentCards() {},
_markAgentStepExceptional() { return false; },
_streamEl() { return null; },
isNearBottom() { return false; },
scrollToBottom() {},
appendToolOutput,
+28
View File
@@ -189,6 +189,34 @@ class TestProjectHistoryMessages:
projected wire shape both UIs consume.
"""
def test_tool_effect_status_matches_accepted_sse_projection(self) -> None:
"""Reload must retain the accepted event's fail-open disposition.
Compact presentation keeps ``unknown``/``partial``/``rolled_back``
rows expanded. Dropping this side channel from /history made the same
row collapse after a reload even though it stayed open live.
"""
raw = [
{
"role": "tool",
"tool_call_id": "c1",
"content": "stopped",
"_effect_status": "unknown",
},
{"role": "tool", "tool_call_id": "c2", "content": "ordinary"},
{
"role": "assistant",
"content": "not a tool result",
"_effect_status": "partial",
},
]
history = project_history_messages(raw)
assert history[0]["effect_status"] == "unknown"
assert "effect_status" not in history[1]
assert "effect_status" not in history[2]
def test_projects_storage_shape_to_wire_shape(self) -> None:
raw: list[dict[str, Any]] = [
{
+7 -6
View File
@@ -29,6 +29,7 @@ from turnstone.core import session as session_module
from turnstone.core import session_worker
from turnstone.core.attachment_buffer import get_attachment_buffer
from turnstone.core.attachments import Attachment, resolve_staged_attachments
from turnstone.core.compaction import SummaryResult
from turnstone.core.storage._registry import get_storage
from turnstone.core.trajectory import turns_from_dicts
@@ -378,9 +379,9 @@ def test_compaction_end_crossing_is_visible_to_fresh_history_handoff(tmp_db: Any
registration: Any = None
with (
patch.object(
session,
"_summarize_blocks",
return_value=session_module._SummaryResult(
session._compaction_engine,
"summarize_blocks",
return_value=SummaryResult(
text=summary,
producer="openai-compatible",
),
@@ -735,9 +736,9 @@ def test_compaction_marker_lost_ack_has_one_success_end_and_one_row(tmp_db: Any)
with (
patch.object(
session,
"_summarize_blocks",
return_value=session_module._SummaryResult(text=summary, producer="kernel"),
session._compaction_engine,
"summarize_blocks",
return_value=SummaryResult(text=summary, producer="kernel"),
),
patch.object(session.ui, "on_compaction", side_effect=[41, 42]) as compaction_events,
patch.object(get_storage(), "get_compaction_watermark", return_value=2),
+349 -2
View File
@@ -1437,6 +1437,109 @@ def test_accepted_tool_event_recorded_only_when_painted() -> None:
assert gate < record
def test_compact_presentation_live_replay_and_lifecycle_wiring() -> None:
"""The interactive renderer owns settlement timing and late blockers.
Shared DOM tests cover the fold state machine itself; this pins the pane's
load-bearing call order so effect/error truth is present before settlement,
replay stays silent, and teardown releases the registered scroller.
"""
body = _INTERACTIVE.read_text(encoding="utf-8")
for symbol in (
"canAutoFoldTranscriptBatch",
"clearConvVerdictPending",
"getTranscriptPresentation",
"preserveTranscriptBottomPin",
"registerTranscriptScroller",
"markConvRowResultSettled",
"setReasoningActivity",
"setConvBatchExpanded",
):
assert symbol in body
live = _extract_braced(
body,
" appendToolOutput(callId, name, output, isError, preview, opts = {}) {",
)
settle = live.index("markConvRowResultSettled(target")
assert live.index("clearConvVerdictPending(target)") < settle
assert live.index("setToolOutputReviewState(target, output)") < settle
assert live.index("target.dataset.effectStatus") < settle
assert live.index("this._markAgentStepExceptional(") < settle
assert live.index('parentBlock.classList.add("conv-batch--error")') < settle
assert "const allowAutoFold" in live
assert "canAutoFoldTranscriptBatch(" in live
assert "_batchLiesBelowViewport" not in body
assert settle < live.index('toolAnnounce("Completed: "')
assert "settlement.autoFolded" in live
replay = _extract_braced(body, " replayHistory(messages) {")
replay_settle = replay.index("markConvRowResultSettled(resultTarget")
assert replay.index("setToolOutputReviewState(resultTarget, msg.content)") < replay_settle
assert replay.index("resultTarget.dataset.effectStatus") < replay_settle
assert replay.index('lastToolBlock.classList.add("conv-batch--error")') < replay_settle
assert replay.index("_buildOutputWarningEl(assessment.assessment)") < replay_settle
assert 'toolAnnounce("Completed: "' not in replay
upgrade = _extract_braced(
body,
" showInlineToolBlock(items, autoApproved, judgePending, cycleId) {",
)
reset = upgrade.index("announced.replaceChildren()")
assert reset < upgrade.index("delete announced.dataset.resultsSettled")
assert reset < upgrade.index("delete announced.dataset.compactFolded")
warning = _extract_braced(body, " showOutputWarning(evt) {")
assert warning.index("setConvBatchExpanded(") < warning.index("_buildOutputWarningEl(")
verdict = _extract_braced(body, " updateVerdictBadge(verdict) {")
pin = verdict.index("preserveTranscriptBottomPin(")
assert pin < verdict.index("setConvBatchExpanded(") < verdict.index("badge.replaceWith(")
nested = _extract_braced(
body,
" _routeAgentItems(items, mode, judgePending, cycleId) {",
)
assert nested.index("setConvBatchExpanded(") < nested.index("row.appendChild(actions)")
assert "this._markAgentStepExceptional(row" in nested
replay_agent = _extract_braced(body, " _replayAgentCard(row, steps) {")
assert "const stepRow = buildToolDiv(" in replay_agent
assert "this._markAgentStepExceptional(stepRow" in replay_agent
assert "effectStatus: step.effect_status" in replay_agent
assert "exceptional: !!step.contains_exceptional" in replay_agent
marker = _extract_braced(body, " _markAgentStepExceptional(row, details) {")
assert "!details.isError" in marker
assert "!details.denied" in marker
assert "!details.exceptional" in marker
assert 'effectStatus === "committed"' in marker
assert 'card.dataset.agentStepExceptional = "true";' in marker
assert "issue.hidden = false;" in marker
assert marker.index("setConvBatchExpanded(") < marker.index(
'card.dataset.agentStepExceptional = "true";'
)
resolution = _extract_braced(
body,
" resolveApproval(approved, always, feedback, skipPost, cycleId) {",
)
marker_call = resolution.index("this._markAgentStepExceptional(")
assert marker_call < resolution.index("buildConvStatus(")
assert "this._unregisterTranscriptScroller = registerTranscriptScroller(" in body
cleanup = body.index("if (pane._unregisterTranscriptScroller) {")
assert cleanup < body.index("pane._unregisterTranscriptScroller();", cleanup)
reasoning_case = body[body.index('case "reasoning"') : body.index('case "content"')]
assert "setReasoningActivity(this.currentReasoningEl, true)" in reasoning_case
assert 'reasoningBody.className = "msg-body"' in reasoning_case
assert "reasoningBody.textContent += evt.text" in reasoning_case
assert "this.currentReasoningEl.textContent += evt.text" not in reasoning_case
content_case = body[body.index('case "content"') : body.index('case "stream_end"')]
assert "setReasoningActivity(this.currentReasoningEl, false)" in content_case
stream_case = body[body.index('case "stream_end"') : body.index('case "in_progress_snapshot"')]
assert "setReasoningActivity(this.currentReasoningEl, false)" in stream_case
def test_task_agent_context_badge_is_keyed_idempotent_and_terminal_safe() -> None:
"""Live and synthetic readings share one keyed reducer.
@@ -1454,9 +1557,18 @@ def test_task_agent_context_badge_is_keyed_idempotent_and_terminal_safe() -> Non
)
]
assert "this._agentContexts.set(parentId, { promptTokens, contextWindow });" in update
assert "terminal.row === parentRow" in update
assert "this._agentTransientRoute(parentId)" in update
assert "this._ensureAgentCard(parentId, true)" in update
route = body[
body.index("_agentTransientRoute(parentId) {") : body.index(
"_handleAgentCompaction(evt) {", body.index("_agentTransientRoute(parentId) {")
)
]
assert "terminal.row !== parentRow" in route
assert 'state: "blocked"' in route
assert 'state: "drop"' in route
relink = body[
body.index("_relinkAgentCards(items) {") : body.index(
"_updateAgentLabel(", body.index("_relinkAgentCards(items) {")
@@ -1485,9 +1597,10 @@ def test_task_agent_context_badge_is_keyed_idempotent_and_terminal_safe() -> Non
assert '"Warning: " + context.title' in accessible
assert 'label.textContent || "0 steps"' in accessible
assert "blockedByTerminalRow: parentRow" in update
assert "blockedByTerminalRow: route.row" in update
state_case = body[body.index('case "state_change":') : body.index('case "tool_pending":')]
assert "reading.blockedByTerminalRow" in state_case
assert "holder.blockedByTerminalRow" in state_case
result = body[
body.index("appendToolOutput(callId") : body.index(
@@ -1508,6 +1621,238 @@ def test_task_agent_context_badge_is_keyed_idempotent_and_terminal_safe() -> Non
assert "card.wrap.hidden = false;" in route
def test_task_agent_compaction_is_buffered_nested_and_terminal_safe() -> None:
"""Targeted compaction follows the parent task-card lifecycle."""
body = _INTERACTIVE.read_text(encoding="utf-8")
handler = body[
body.index("_handleAgentCompaction(evt) {") : body.index(
"_routeAgentItems(items", body.index("_handleAgentCompaction(evt) {")
)
]
assert "this._agentCompactions.set(parentId, holder);" in handler
assert 'if (evt.phase === "end") return;' in handler
assert "incomingCid !== activeCid" in handler
assert "holder.pending = evt;" in handler
assert "this._agentTransientRoute(parentId)" in handler
assert "holder.blockedByTerminalRow = route.row" in handler
assert "renderResult: false" in handler
assert "card.wrap.insertBefore(node, card.body)" in handler
assert 'notice.className = "conv-agent-compaction-notice"' in handler
assert "this._agentCompactions.delete(parentId);" in handler
relink = body[
body.index("_relinkAgentCards(items) {") : body.index(
"_updateAgentLabel(", body.index("_relinkAgentCards(items) {")
)
]
assert "this._agentCompactions.has(it.call_id)" in relink
result = body[
body.index("appendToolOutput(callId") : body.index(
"sendMessage() {", body.index("appendToolOutput(callId")
)
]
assert "this._agentCompactions.delete(callId);" in result
assert "resetCompactionHolder(this._agentCompactions.get(callId))" in result
clear = body[
body.index("_clearAgentTracking() {") : body.index(
"_recordTruncatedGap()", body.index("_clearAgentTracking() {")
)
]
assert "for (const holder of this._agentCompactions.values())" in clear
assert "this._agentCompactions.clear();" in clear
def test_task_agent_compaction_handler_preserves_newer_attempt(tmp_path: Path) -> None:
"""Execute the pane wrapper, not only the shared lifecycle reducer.
A stale end must neither clear a newer holder nor create an empty task card
when no local attempt exists.
"""
import shutil
import subprocess
if shutil.which("node") is None:
pytest.skip("node binary not available on PATH")
body = _INTERACTIVE.read_text(encoding="utf-8")
handler = _extract_braced(body, " _handleAgentCompaction(evt) {").strip()
script = tmp_path / "agent_compaction_handler_harness.mjs"
script.write_text(
"const handleAgentCompaction = function "
+ handler
+ r""";
let ensureCalls = 0;
let resets = 0;
function resetCompactionHolder(holder) {
resets += 1;
holder.card = null;
holder.cid = null;
}
const pane = {
_agentCompactions: new Map(),
_agentTransientRoute() { return { state: "current", row: null }; },
_ensureAgentCard() {
ensureCalls += 1;
return {};
},
_syncAgentCompaction(parentId) {
const holder = this._agentCompactions.get(parentId);
const evt = holder && holder.pending;
if (!evt) return;
holder.pending = null;
if (evt.phase === "start") {
holder.card = {};
holder.cid = String(evt.compaction_id);
} else if (
evt.phase === "end" &&
(holder.cid == null || String(evt.compaction_id) === holder.cid)
) {
resetCompactionHolder(holder);
}
},
_handleAgentCompaction: handleAgentCompaction,
};
pane._handleAgentCompaction({
phase: "end", parent_call_id: "task-A", compaction_id: 9, ok: true,
});
if (pane._agentCompactions.size !== 0 || ensureCalls !== 0)
throw new Error("orphan end manufactured task state");
pane._handleAgentCompaction({
phase: "start", parent_call_id: "task-A", compaction_id: 12,
});
const live = pane._agentCompactions.get("task-A");
if (!live || live.cid !== "12" || !live.card)
throw new Error("start did not establish attempt 12");
pane._handleAgentCompaction({
phase: "end", parent_call_id: "task-A", compaction_id: 11, ok: true,
});
if (pane._agentCompactions.get("task-A") !== live || live.cid !== "12" || !live.card)
throw new Error("stale end retired attempt 12");
pane._handleAgentCompaction({
phase: "end", parent_call_id: "task-A", compaction_id: 12, ok: true,
});
if (pane._agentCompactions.size !== 0 || live.card !== null || live.cid !== null)
throw new Error("owning end did not retire attempt 12");
if (resets !== 2) throw new Error("unexpected reset count: " + resets);
""",
encoding="utf-8",
)
proc = subprocess.run(
["node", str(script)],
capture_output=True,
text=True,
timeout=15,
)
assert proc.returncode == 0, f"agent compaction harness failed:\n{proc.stderr}\n{proc.stdout}"
def test_recycled_parent_compaction_waits_for_successor_occurrence(tmp_path: Path) -> None:
"""Compaction and context share one terminal-occurrence gate."""
import shutil
import subprocess
if shutil.which("node") is None:
pytest.skip("node binary not available on PATH")
body = _INTERACTIVE.read_text(encoding="utf-8")
route = _extract_braced(body, " _agentTransientRoute(parentId) {").strip()
handler = _extract_braced(body, " _handleAgentCompaction(evt) {").strip()
relink = _extract_braced(body, " _relinkAgentCards(items) {").strip()
script = tmp_path / "agent_compaction_occurrence_harness.mjs"
script.write_text(
"const agentTransientRoute = function "
+ route
+ ";\nconst handleAgentCompaction = function "
+ handler
+ ";\nconst relinkAgentCards = function "
+ relink
+ r""";
function resetCompactionHolder(holder) {
holder.card = null;
holder.cid = null;
}
const oldRow = { name: "old" };
const successorRow = { name: "successor" };
const uniqueRow = { name: "unique" };
let parentRow = oldRow;
const attached = [];
const pane = {
busy: true,
_agentCards: new Map(),
_agentContexts: new Map(),
_agentCompactions: new Map(),
_toolResultNodes: new Map([["task-A", { row: oldRow }]]),
_toolRow() { return parentRow; },
_agentTransientRoute: agentTransientRoute,
_handleAgentCompaction: handleAgentCompaction,
_relinkAgentCards: relinkAgentCards,
_ensureAgentCard(parentId) {
if (!parentRow) return null;
attached.push(parentRow);
const card = { wrap: { dataset: { state: "running" } } };
this._syncAgentCompaction(parentId, card);
return card;
},
_syncAgentCompaction(parentId) {
const holder = this._agentCompactions.get(parentId);
if (!holder || !holder.pending || holder.blockedByTerminalRow) return;
const evt = holder.pending;
holder.pending = null;
if (evt.phase === "start") {
holder.card = {};
holder.cid = String(evt.compaction_id);
}
},
_flushAgentOrphans() {},
};
pane._handleAgentCompaction({
phase: "start", parent_call_id: "task-A", compaction_id: 1,
});
const blocked = pane._agentCompactions.get("task-A");
if (!blocked || blocked.blockedByTerminalRow !== oldRow || attached.length !== 0)
throw new Error("recycled id attached compaction to the terminal occurrence");
parentRow = successorRow;
pane._relinkAgentCards([{ call_id: "task-A" }]);
if (attached.length !== 1 || attached[0] !== successorRow)
throw new Error("successor occurrence did not receive buffered compaction");
if (blocked.blockedByTerminalRow || blocked.cid !== "1" || !blocked.card)
throw new Error("buffered compaction did not activate on successor row");
pane._agentCompactions.clear();
pane._toolResultNodes.clear();
parentRow = null;
pane._handleAgentCompaction({
phase: "start", parent_call_id: "task-B", compaction_id: 2,
});
if (attached.length !== 1)
throw new Error("unique pre-parent event attached without a row");
parentRow = uniqueRow;
pane._relinkAgentCards([{ call_id: "task-B" }]);
if (attached.length !== 2 || attached[1] !== uniqueRow)
throw new Error("unique id control did not attach when its row painted");
""",
encoding="utf-8",
)
proc = subprocess.run(
["node", str(script)],
capture_output=True,
text=True,
timeout=15,
)
assert proc.returncode == 0, f"occurrence harness failed:\n{proc.stderr}\n{proc.stdout}"
def test_idless_agent_context_snapshot_relinks_after_parent_runtime(tmp_path: Path) -> None:
"""An id-less fresh/truncated snapshot may precede its parent row.
@@ -1537,8 +1882,10 @@ let attached = 0;
const pane = {
busy: true,
_agentContexts: new Map(),
_agentCompactions: new Map(),
_toolResultNodes: new Map(),
_toolRow() { return parentRow; },
_agentTransientRoute() { return { state: "current", row: parentRow }; },
_ensureAgentCard(parentId, contextOnly) {
if (!parentRow) return null;
const reading = this._agentContexts.get(parentId);
+5 -4
View File
@@ -21,8 +21,9 @@ from unittest.mock import MagicMock, patch
from tests._session_helpers import make_session
from turnstone.core import fence
from turnstone.core.compaction import SummaryResult
from turnstone.core.providers._anthropic import AnthropicProvider
from turnstone.core.session import _prefix_sender_label, _SummaryResult
from turnstone.core.session import _prefix_sender_label
from turnstone.core.storage._utils import reconstruct_turns
from turnstone.core.trajectory import Role, turn_from_dict, turn_to_dict
@@ -598,9 +599,9 @@ def test_resume_recovers_compacted_out_sender_end_to_end(tmp_db, mock_openai_cli
sess.messages = turns_from_dicts(history)
sess._msg_tokens = [1] * len(history)
with _patch.object(
sess,
"_summarize_blocks",
return_value=_SummaryResult(text="owner and alice spoke", producer="summary-producer"),
sess._compaction_engine,
"summarize_blocks",
return_value=SummaryResult(text="owner and alice spoke", producer="summary-producer"),
):
assert sess._compact_messages(auto=False) is True # summarizes BOTH away
+27
View File
@@ -11,6 +11,7 @@ from turnstone.sdk.events import (
ClusterWsClosedEvent,
ClusterWsCreatedEvent,
ClusterWsRenameEvent,
CompactionEvent,
ConnectedEvent,
ContentEvent,
ErrorEvent,
@@ -484,6 +485,32 @@ def test_agent_context_event_round_trip():
assert e.context_window == 128_000
def test_task_agent_compaction_event_round_trip():
e = ServerEvent.from_dict(
{
"type": "compaction",
"phase": "progress",
"target": "task_agent",
"parent_call_id": "task-A",
"compaction_id": 17,
"part": 2,
"total": 4,
}
)
assert isinstance(e, CompactionEvent)
assert e.target == "task_agent"
assert e.parent_call_id == "task-A"
assert e.compaction_id == 17
assert e.part == 2
def test_compaction_event_without_target_means_workstream():
e = ServerEvent.from_dict({"type": "compaction", "phase": "start", "compaction_id": 3})
assert isinstance(e, CompactionEvent)
assert e.target == "workstream"
assert e.parent_call_id == ""
def test_state_change_event_round_trip():
from turnstone.sdk.events import StateChangeEvent
+136 -29
View File
@@ -35,9 +35,11 @@ from turnstone.core.session import (
_IMAGE_SIZE_CAP,
_MEMORY_MIXED_BATCH_ERROR,
ChatSession,
_TaskExecutionJournal,
)
from turnstone.core.storage import get_storage
from turnstone.core.trajectory import (
EffectStatus,
Role,
Turn,
dicts_from_turns,
@@ -3899,7 +3901,7 @@ class TestAgentContextReporting:
assert ui.context_calls == []
def test_unparented_agent_does_not_compute_or_emit_badge_usage(self) -> None:
def test_unparented_agent_resolves_compaction_window_but_emits_no_badge(self) -> None:
from turnstone.core.providers import UsageInfo
ui = self._ContextUI()
@@ -3910,12 +3912,12 @@ class TestAgentContextReporting:
)
with (
patch.object(session, "_context_window_for_lane") as window,
patch.object(session, "_context_window_for_lane", return_value=128_000) as window,
patch("turnstone.core.session.model_turn", return_value=result),
):
session._run_agent([Turn.user("test")], tools=[], auto_tools=set())
window.assert_not_called()
window.assert_called_once()
assert ui.context_calls == []
def test_superseded_generation_cannot_publish_context(self) -> None:
@@ -4062,13 +4064,13 @@ class TestAgentChildRegistration:
("task-1::r1s2::call_0", "task-1"),
]
# Recall projection: two steps, each paired to its OWN result.
steps = ChatSession._project_agent_steps(agent_turns)
journal = _TaskExecutionJournal(agent_turns)
steps = journal.project_steps()
assert [s["id"] for s in steps] == ["task-1::r1s1::call_0", "task-1::r1s2::call_0"]
assert [s["output"] for s in steps] == ["contents-1", "contents-2"]
# Cancel ledger agrees: both calls answered, no in-flight gap.
issued, first_gap = ChatSession._cancel_ledger(agent_turns)
assert issued == [("read_file", True), ("read_file", True)]
assert first_gap is None
# Cancellation truth agrees: both calls answered, so the incomplete
# overall task is PARTIAL rather than an in-flight UNKNOWN.
assert journal.cancelled_status() is EffectStatus.PARTIAL
@staticmethod
def _reusing_provider(session, tool_turns: int = 1):
@@ -4581,10 +4583,8 @@ class TestRunAgentDenialMessage:
assert text == "Blocked by tool policy ('notify')"
class TestProjectAgentSteps:
"""``_project_agent_steps`` projects a finished sub-agent's trajectory into
recall step items for the task card one per tool call, matched to its
result by call_id, landmine-safe on a multimodal result."""
class TestTaskExecutionJournalProjection:
"""The live journal projects bounded task-card recall step items."""
def test_calls_matched_to_results_in_order(self):
from turnstone.core.trajectory import ToolCall, Turn
@@ -4601,7 +4601,7 @@ class TestProjectAgentSteps:
),
Turn.tool("c2", "boom", is_error=True),
]
steps = ChatSession._project_agent_steps(turns)
steps = _TaskExecutionJournal(turns).project_steps()
assert [s["id"] for s in steps] == ["c1", "c2"]
assert steps[0] == {
"id": "c1",
@@ -4613,6 +4613,31 @@ class TestProjectAgentSteps:
assert steps[1]["is_error"] is True
assert steps[1]["output"] == "boom"
def test_exceptional_effect_status_preserved_for_compact_recall_safety(self):
from turnstone.core.trajectory import ToolCall, Turn
turns = [
Turn.assistant(
tool_calls=(
ToolCall(id="c1", name="notify", arguments="{}"),
ToolCall(id="c2", name="read_file", arguments="{}"),
)
),
Turn.tool(
"c1",
"Denied by user",
effect_status=EffectStatus.NONE,
),
Turn.tool(
"c2",
"read complete",
effect_status=EffectStatus.COMMITTED,
),
]
steps = _TaskExecutionJournal(turns).project_steps()
assert steps[0]["effect_status"] == "none"
assert "effect_status" not in steps[1]
def test_multimodal_result_placeholdered_not_crashed(self):
# A vision tool result is a list[dict] mis-stored as TextBlock.text; the
# projection must NOT call Turn.text (would TypeError) — it reads the
@@ -4625,7 +4650,7 @@ class TestProjectAgentSteps:
),
Turn.tool("c1", [{"type": "image_url"}]),
]
steps = ChatSession._project_agent_steps(turns)
steps = _TaskExecutionJournal(turns).project_steps()
assert steps[0]["output"] == "[non-text result]"
def test_output_capped(self):
@@ -4637,7 +4662,7 @@ class TestProjectAgentSteps:
Turn.assistant(tool_calls=(ToolCall(id="c1", name="bash", arguments="{}"),)),
Turn.tool("c1", big),
]
steps = ChatSession._project_agent_steps(turns)
steps = _TaskExecutionJournal(turns).project_steps()
assert len(steps[0]["output"]) < len(big)
assert "truncated from 2500 chars" in steps[0]["output"]
@@ -4647,11 +4672,34 @@ class TestProjectAgentSteps:
from turnstone.core.trajectory import ToolCall, Turn
turns = [Turn.assistant(tool_calls=(ToolCall(id="c1", name="bash", arguments="{}"),))]
steps = ChatSession._project_agent_steps(turns)
steps = _TaskExecutionJournal(turns).project_steps()
assert steps == [
{"id": "c1", "name": "bash", "arguments": "{}", "output": "", "is_error": False}
]
def test_cancelled_siblings_remain_in_issue_order(self):
"""Materializing a later never-started call must not precede the
earlier call that was in flight when cancellation won."""
from turnstone.core.trajectory import ToolCall, Turn
turns = [
Turn.assistant(
tool_calls=(
ToolCall(id="c1", name="bash", arguments='{"command":"deploy"}'),
ToolCall(id="c2", name="search", arguments='{"query":"status"}'),
)
)
]
journal = _TaskExecutionJournal(turns)
journal.mark_started("c1")
journal.materialize_unstarted(turns)
steps = journal.project_steps()
assert [step["id"] for step in steps] == ["c1", "c2"]
assert steps[0]["output"] == ""
assert steps[1]["output"] == "(cancelled before execution; no side effects)"
assert journal.cancelled_status() is EffectStatus.UNKNOWN
def test_colliding_ids_paired_fifo_not_last_wins(self):
# A local provider reuses id "call_0" across turns; FIFO pairing gives
# each call its OWN result, not last-wins (which would show out-B twice).
@@ -4670,7 +4718,7 @@ class TestProjectAgentSteps:
),
Turn.tool("call_0", "out-B"),
]
steps = ChatSession._project_agent_steps(turns)
steps = _TaskExecutionJournal(turns).project_steps()
assert [s["output"] for s in steps] == ["out-A", "out-B"]
def test_step_count_capped_with_honest_marker(self):
@@ -4683,19 +4731,74 @@ class TestProjectAgentSteps:
Turn.assistant(tool_calls=(ToolCall(id=f"c{i}", name="bash", arguments="{}"),))
)
turns.append(Turn.tool(f"c{i}", f"out{i}"))
steps = ChatSession._project_agent_steps(turns)
steps = _TaskExecutionJournal(turns).project_steps()
# Capped + one honest LEADING marker, keeping the most RECENT steps (the
# tail) — not the earliest — and naming how many earlier ones fell out.
assert len(steps) == _AGENT_STEP_COUNT_CAP + 1
assert steps[0]["name"] == ""
assert "5 earlier steps not retained" in steps[0]["output"]
assert "contains_exceptional" not in steps[0]
# c0..c4 dropped; c5 is the first retained, the newest call is last.
assert steps[1]["id"] == "c5"
assert steps[-1]["id"] == f"c{_AGENT_STEP_COUNT_CAP + 4}"
def test_evicted_noncommitted_step_marks_summary_exceptional(self):
from turnstone.core.session import _AGENT_STEP_COUNT_CAP
from turnstone.core.trajectory import ToolCall, Turn
journal = _TaskExecutionJournal([])
for i in range(_AGENT_STEP_COUNT_CAP + 1):
call_id = f"c{i}"
journal.record_assistant(
Turn.assistant(tool_calls=(ToolCall(id=call_id, name="bash", arguments="{}"),))
)
journal.mark_started(call_id)
journal.record_result(
call_id,
"Denied by user" if i == 0 else "ok",
is_error=False,
effect_status=(EffectStatus.NONE if i == 0 else EffectStatus.COMMITTED),
)
steps = journal.project_steps()
assert steps[0]["contains_exceptional"] is True
assert not any(step.get("effect_status") for step in steps[1:])
def test_pending_step_that_fails_after_eviction_marks_summary_exceptional(self):
from turnstone.core.session import _AGENT_STEP_COUNT_CAP
from turnstone.core.trajectory import ToolCall, Turn
journal = _TaskExecutionJournal([])
journal.record_assistant(
Turn.assistant(tool_calls=(ToolCall(id="c0", name="bash", arguments="{}"),))
)
journal.mark_started("c0")
for i in range(1, _AGENT_STEP_COUNT_CAP + 1):
call_id = f"c{i}"
journal.record_assistant(
Turn.assistant(tool_calls=(ToolCall(id=call_id, name="bash", arguments="{}"),))
)
journal.mark_started(call_id)
journal.record_result(
call_id,
"ok",
is_error=False,
effect_status=EffectStatus.COMMITTED,
)
journal.record_result(
"c0",
"late failure",
is_error=True,
effect_status=EffectStatus.COMMITTED,
)
steps = journal.project_steps()
assert steps[0]["contains_exceptional"] is True
assert not any(step.get("is_error") for step in steps[1:])
class TestAgentTrajectoryStashWiring:
"""``_stash_agent_trajectory`` projects + forwards to the UI, getattr-guarded."""
"""The bounded recall projection is forwarded through one guarded seam."""
def test_projects_and_forwards(self):
from turnstone.core.trajectory import ToolCall, Turn
@@ -4706,7 +4809,7 @@ class TestAgentTrajectoryStashWiring:
Turn.assistant(tool_calls=(ToolCall(id="c1", name="bash", arguments="{}"),)),
Turn.tool("c1", "ok"),
]
session._stash_agent_trajectory("task1", turns)
session._stash_agent_steps("task1", _TaskExecutionJournal(turns).project_steps())
session.ui.stash_agent_trajectory.assert_called_once()
cid, steps = session.ui.stash_agent_trajectory.call_args[0]
assert cid == "task1"
@@ -4717,12 +4820,12 @@ class TestAgentTrajectoryStashWiring:
def test_noop_without_call_id(self):
session = _make_session()
session.ui = MagicMock()
session._stash_agent_trajectory(None, [])
session._stash_agent_steps(None, [])
session.ui.stash_agent_trajectory.assert_not_called()
def test_noop_on_ui_without_support(self):
# NullUI has no stash_agent_trajectory → getattr None → no-op, no raise.
_make_session()._stash_agent_trajectory("task1", [])
_make_session()._stash_agent_steps("task1", [])
class TestReadFilesIsolation:
@@ -4839,7 +4942,7 @@ class TestSubAgentErrorRecall:
assert tool_turns, "expected a tool result turn"
assert tool_turns[-1].is_error is True
# And it carries through the projection to the recalled step.
assert ChatSession._project_agent_steps(turns)[-1]["is_error"] is True
assert _TaskExecutionJournal(turns).project_steps()[-1]["is_error"] is True
class TestExecTaskReporting:
@@ -4855,7 +4958,7 @@ class TestExecTaskReporting:
def test_success_reports_result(self):
session = self._bare_session()
session.ui.clear_agent_context = MagicMock()
session.ui.clear_agent_transients = MagicMock()
with (
patch.object(session, "_run_agent", return_value="the synthesis"),
patch.object(session, "_report_tool_result") as rpt,
@@ -4863,7 +4966,7 @@ class TestExecTaskReporting:
cid, out = session._exec_task({"call_id": "t1", "prompt": "go"})
assert (cid, out) == ("t1", "the synthesis")
rpt.assert_called_once_with("t1", "task_agent", "the synthesis")
assert session.ui.clear_agent_context.call_args_list == [
assert session.ui.clear_agent_transients.call_args_list == [
call("t1", generation=0),
call("t1", generation=0),
]
@@ -4916,7 +5019,7 @@ class TestExecTaskReporting:
session = self._bare_session()
generation = session._claim_generation()
session.ui.clear_agent_context = MagicMock()
session.ui.clear_agent_transients = MagicMock()
def supersede(*_args, **_kwargs):
session._claim_generation()
@@ -4931,7 +5034,7 @@ class TestExecTaskReporting:
}
)
session.ui.clear_agent_context.assert_called_once_with("t1", generation=generation)
session.ui.clear_agent_transients.assert_called_once_with("t1", generation=generation)
def _install_output_guard_judge(session: ChatSession, judge: MagicMock) -> None:
@@ -10575,7 +10678,9 @@ class TestReminderSidechannelIsolation:
)
)
session.messages.append(turn_from_dict({"role": "assistant", "content": "ok"}))
summary = session._format_messages_for_summary(dicts_from_turns(session.messages))
summary = session._compaction_engine.format_messages_for_summary(
dicts_from_turns(session.messages)
)
assert "SECRET_NUDGE_TEXT" not in summary
assert "[start system-reminder]" not in summary
assert "user said this" in summary
@@ -10600,7 +10705,9 @@ class TestReminderSidechannelIsolation:
}
)
)
summary = session._format_messages_for_summary(dicts_from_turns(session.messages))
summary = session._compaction_engine.format_messages_for_summary(
dicts_from_turns(session.messages)
)
assert "[image]" in summary
assert "screenshot:" in summary
+131 -4
View File
@@ -1641,7 +1641,13 @@ def test_register_listener_with_in_progress_snapshot_empty() -> None:
lq, snap = ui.register_listener_with_in_progress_snapshot()
assert isinstance(lq, queue.Queue)
assert lq in ui._listeners
assert snap == {"content": "", "reasoning": "", "seq": 0, "agent_contexts": []}
assert snap == {
"content": "",
"reasoning": "",
"seq": 0,
"agent_contexts": [],
"agent_compactions": [],
}
def test_register_listener_with_in_progress_snapshot_populated() -> None:
@@ -2602,10 +2608,10 @@ class TestAgentContextSnapshots:
# The retiring predecessor can neither overwrite nor clear generation 5.
ui.on_agent_context("reused", 99, 100, generation=4)
ui.clear_agent_context("reused", generation=4)
ui.clear_agent_transients("reused", generation=4)
assert ui._snapshot_agent_contexts()[0]["prompt_tokens"] == 20
ui.clear_agent_context("reused", generation=5)
ui.clear_agent_transients("reused", generation=5)
assert ui._snapshot_agent_contexts() == []
def test_force_cutoff_clears_only_retired_generations(self) -> None:
@@ -2614,7 +2620,7 @@ class TestAgentContextSnapshots:
ui.on_agent_context("old-B", 20, 100, generation=4)
ui.on_agent_context("successor", 30, 100, generation=5)
ui.clear_agent_contexts_before_generation(5)
ui.clear_agent_transients_before_generation(5)
readings = ui._snapshot_agent_contexts()
assert [event["parent_call_id"] for event in readings] == ["successor"]
@@ -2664,6 +2670,127 @@ class TestAgentContextSnapshots:
assert listener.get_nowait()["type"] == "agent_context"
class TestAgentCompactionSnapshots:
"""Task compaction is nested transient state, never foreground activity."""
def test_live_progress_snapshots_and_end_clears_without_activity_latch(self) -> None:
ui = _make_ui()
listener = ui._register_listener()
ui._ws_current_activity = "Running tool: task_agent"
ui._ws_activity_state = "tool"
ui.on_compaction(
{
"phase": "start",
"trigger": "auto",
"target": "task_agent",
"parent_call_id": "task-A",
"compaction_id": 11,
"_generation": 7,
}
)
start = listener.get_nowait()
assert start["target"] == "task_agent"
assert start["parent_call_id"] == "task-A"
assert "_generation" not in start
assert ui._ws_current_activity == "Running tool: task_agent"
assert ui._ws_activity_state == "tool"
assert ui._compaction_activity_live is False
ui.on_compaction(
{
"phase": "progress",
"part": 2,
"total": 3,
"depth": 0,
"target": "task_agent",
"parent_call_id": "task-A",
"compaction_id": 11,
"_generation": 7,
}
)
listener.get_nowait()
_, snapshot = ui.register_listener_with_in_progress_snapshot()
assert snapshot["agent_compactions"] == [
{
"type": "compaction",
"phase": "progress",
"part": 2,
"total": 3,
"depth": 0,
"target": "task_agent",
"parent_call_id": "task-A",
"compaction_id": 11,
"ws_id": "ws-1",
}
]
ui.on_compaction(
{
"phase": "end",
"ok": True,
"trigger": "auto",
"target": "task_agent",
"parent_call_id": "task-A",
"compaction_id": 11,
"_generation": 7,
}
)
end = listener.get_nowait()
assert end["superseded"] is False
assert ui._snapshot_agent_compactions() == []
def test_agent_scope_allows_targeted_event_but_still_drops_workstream_event(self) -> None:
ui = _make_ui()
listener = ui._register_listener()
ui.begin_agent_scope()
try:
ui.on_compaction(
{
"phase": "start",
"trigger": "auto",
"target": "task_agent",
"parent_call_id": "task-A",
"compaction_id": 1,
"_generation": 2,
}
)
ui.on_compaction(
{
"phase": "start",
"trigger": "auto",
"target": "workstream",
"compaction_id": 2,
}
)
finally:
ui.end_agent_scope()
assert listener.get_nowait()["target"] == "task_agent"
assert listener.empty()
def test_generation_cleanup_cannot_remove_successor_snapshot(self) -> None:
ui = _make_ui()
for generation, compaction_id in ((4, 1), (5, 2)):
ui.on_compaction(
{
"phase": "start",
"trigger": "auto",
"target": "task_agent",
"parent_call_id": "reused",
"compaction_id": compaction_id,
"_generation": generation,
}
)
ui.clear_agent_transients("reused", generation=4)
assert ui._snapshot_agent_compactions()[0]["compaction_id"] == 2
ui.clear_agent_transients_before_generation(5)
assert ui._snapshot_agent_compactions()[0]["compaction_id"] == 2
ui.clear_agent_transients("reused", generation=5)
assert ui._snapshot_agent_compactions() == []
class TestAgentScopeInfoSuppression:
"""While a task agent runs, its ``on_info`` progress chatter ("[task done] N
chars", a tool's "fetched N chars") carries no call_id, so it can't nest
+2
View File
@@ -52,6 +52,7 @@ _ESM_BUNDLES = [
_SHARED / "composer_queue.js",
_SHARED / "interactive.js",
_SHARED / "conversation.js",
_SHARED / "transcript_presentation.js",
_SHARED / "preview.js",
_SHARED / "redact_credentials.js",
_SHARED / "mcp_error.js",
@@ -77,6 +78,7 @@ _ESM_NO_VAR_BUNDLES = [
_SHARED / "composer_paste_text.js",
_SHARED / "interactive.js",
_SHARED / "conversation.js",
_SHARED / "transcript_presentation.js",
_SHARED / "preview.js",
_SHARED / "redact_credentials.js",
_SHARED / "mcp_error.js",
+64
View File
@@ -857,6 +857,70 @@ def test_handler_replay_ok_uses_buffered_agent_context_only() -> None:
assert "id: 1" in blob
def _start_task_compaction(ui: Any) -> None:
ui.on_compaction(
{
"phase": "start",
"trigger": "auto",
"target": "task_agent",
"parent_call_id": "task-A",
"compaction_id": 17,
"_generation": 2,
}
)
def test_handler_fresh_connect_replays_active_task_compaction() -> None:
"""Task compaction progress is recoverable without becoming history."""
ui = _make_ui()
_start_task_compaction(ui)
_, blob = _drain_handler_yields(ui, max_yields=4)
assert blob.count('"type": "compaction"') == 1
assert '"target": "task_agent"' in blob
assert '"parent_call_id": "task-A"' in blob
assert '"compaction_id": 17' in blob
assert "_generation" not in blob
def test_handler_truncated_replays_active_task_compaction(monkeypatch: Any) -> None:
"""An evicted task-compaction start is restored from transient state."""
import collections
monkeypatch.setattr("turnstone.core.session_ui_base._TOKEN_BATCH_WINDOW_SECS", 0.0)
ui = _make_ui()
ui._event_buffer = collections.deque(maxlen=3)
_start_task_compaction(ui)
for i in range(8):
ui.on_content_token(str(i))
_, blob = _drain_handler_yields(
ui,
headers={"Last-Event-ID": "1"},
max_yields=6,
)
assert "replay_truncated" in blob
assert blob.count('"type": "compaction"') == 1
assert '"compaction_id": 17' in blob
def test_handler_replay_ok_uses_buffered_task_compaction_only() -> None:
"""Covered reconnects must not add a synthetic compaction duplicate."""
ui = _make_ui()
_start_task_compaction(ui)
_, blob = _drain_handler_yields(
ui,
headers={"Last-Event-ID": "0"},
max_yields=3,
)
assert blob.count('"type": "compaction"') == 1
assert "id: 1" in blob
def test_handler_tokenless_fresh_path_forces_old_client_history_repair() -> None:
"""A pre-handoff browser repairs in place without a reconnect loop."""
ui = _make_ui()
+831
View File
@@ -0,0 +1,831 @@
"""Task-agent context compaction keeps execution truth outside the summary.
The task loop owns a bounded execution journal for cancellation and recall
beside a bounded model context that may be replaced. These tests pin the
cooperative soft warning, hard and reactive compaction paths, prefix
preservation, and the task-local cancellation/read seams.
"""
from __future__ import annotations
import gc
import weakref
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock, patch
import pytest
if TYPE_CHECKING:
from collections.abc import Callable
from tests._session_helpers import make_result, make_session
from turnstone.core.compaction import CompactionEngine, SummaryResult
from turnstone.core.metacognition import (
NUDGE_TASK_COMPACTION_RESUME,
format_nudge,
)
from turnstone.core.providers import UsageInfo
from turnstone.core.session import (
GenerationCancelled,
_active_read_files,
_TaskExecutionJournal,
)
from turnstone.core.trajectory import EffectStatus, Role, ToolCall, Turn
TOOL_NAME = "search"
TOOL_CALL = {
"id": "call-search",
"type": "function",
"function": {"name": TOOL_NAME, "arguments": '{"query":"needle"}'},
}
TOOLS = [
{
"type": "function",
"function": {
"name": TOOL_NAME,
"description": "Search files",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
}
]
def _usage(prompt_tokens: int, completion_tokens: int = 1) -> UsageInfo:
return UsageInfo(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=prompt_tokens + completion_tokens,
)
def _tool_result(*, prompt_tokens: int, content: str = "working"):
return make_result(
content,
tool_calls=[TOOL_CALL],
finish_reason="tool_calls",
usage=_usage(prompt_tokens),
)
def _prepared_tool(tool_call: dict[str, Any], _principal: str):
call_id = tool_call["id"]
return {
"call_id": call_id,
"func_name": TOOL_NAME,
"needs_approval": False,
"execute": lambda _item: (call_id, "x"),
}
def _run_script(
session,
ledger: list[Turn],
responses: list[Any],
*,
window: int = 100,
tools: list[dict[str, Any]] = TOOLS,
execution_journal: _TaskExecutionJournal | None = None,
on_call: Callable[[int, list[Turn]], None] | None = None,
):
queue = list(responses)
contexts: list[list[Turn]] = []
def plant(_lane, turns, **_kwargs):
contexts.append(list(turns))
if on_call is not None:
on_call(len(contexts), list(turns))
response = queue.pop(0)
if isinstance(response, BaseException):
raise response
if callable(response):
response = response()
return response
with (
patch.object(session, "_context_window_for_lane", return_value=window),
patch.object(session, "_prepare_tool_for_principal", side_effect=_prepared_tool),
patch("turnstone.core.session.model_turn", side_effect=plant),
):
output = session._run_agent(
ledger,
label="task",
tools=tools,
auto_tools={TOOL_NAME},
parent_call_id="task-parent",
principal_id="user-a",
execution_journal=execution_journal,
)
assert queue == []
return output, contexts
def _texts(turns: list[Turn]) -> list[str]:
return [turn.text for turn in turns]
def test_soft_crossing_warns_then_compacts_after_wind_down():
session = make_session(auto_compact_pct=0.8)
ledger = [Turn.system("immutable task identity"), Turn.user("delegated contract")]
journal = _TaskExecutionJournal(ledger)
summary = SummaryResult(text="dense task summary", producer="summary-kernel")
with patch.object(
session._compaction_engine,
"summarize_blocks",
return_value=summary,
) as summarize:
output, contexts = _run_script(
session,
ledger,
[
_tool_result(prompt_tokens=82),
make_result(
"Goal recorded; resume by checking the parser.",
usage=_usage(86),
),
make_result("implemented and verified", usage=_usage(30)),
],
execution_journal=journal,
)
assert output == "implemented and verified"
assert len(contexts) == 3
assert format_nudge("compaction_pending") in _texts(contexts[1])
assert _texts(contexts[2])[:2] == ["immutable task identity", "delegated contract"]
assert _texts(contexts[2])[-3] == "[Conversation summary]"
assert _texts(contexts[2])[-2].startswith("dense task summary")
assert "## Wind-down (verbatim)" in _texts(contexts[2])[-2]
assert "Goal recorded; resume by checking the parser." in _texts(contexts[2])[-2]
assert _texts(contexts[2])[-1] == NUDGE_TASK_COMPACTION_RESUME
# The immutable delegation prefix is reattached, not summarized again.
summarized_blocks = summarize.call_args.args[0]
assert not any("immutable task identity" in block for block in summarized_blocks)
assert not any("delegated contract" in block for block in summarized_blocks)
# Raw pre-compaction payloads are released. Cancellation/recall truth lives
# in the bounded journal and never contains synthetic summary/warning turns.
assert [turn.role for turn in ledger] == [Role.SYSTEM, Role.USER, Role.ASSISTANT]
assert not any(turn.source == "compaction" for turn in ledger)
assert journal.project_steps() == [
{
"id": "call-search",
"name": TOOL_NAME,
"arguments": '{"query":"needle"}',
"output": "x",
"is_error": False,
}
]
def test_wind_down_compaction_releases_replaced_native_payload_before_resume():
session = make_session(auto_compact_pct=0.8)
ledger = [Turn.system("immutable task identity"), Turn.user("delegated contract")]
payload_refs: list[weakref.ReferenceType[Any]] = []
class Payload:
pass
def wind_down_result():
payload = Payload()
payload_refs.append(weakref.ref(payload))
return make_result(
"wind-down recorded",
usage=_usage(86),
native_blocks=[{"type": "opaque", "payload": payload}],
)
def inspect_resume_call(call_number: int, _turns: list[Turn]) -> None:
if call_number == 3:
gc.collect()
assert len(payload_refs) == 1
assert payload_refs[0]() is None
with patch.object(
session._compaction_engine,
"summarize_blocks",
return_value=SummaryResult(text="dense task summary", producer="summary-kernel"),
):
output, _contexts = _run_script(
session,
ledger,
[
_tool_result(prompt_tokens=82),
wind_down_result,
make_result("implemented and verified", usage=_usage(30)),
],
on_call=inspect_resume_call,
)
assert output == "implemented and verified"
def test_soft_compaction_rearms_only_after_tool_progress():
"""An irreducible soft-only floor cannot create a summary/model-call loop.
The 32K immutable prefix leaves every successful replacement above soft but
below hard. The post-summary no-tool response must therefore terminate the
task; another advisory would repeat the same compaction forever without
advancing the configured tool-turn bound.
"""
session = make_session(auto_compact_pct=0.8, agent_max_turns=2)
ledger = [Turn.system("p" * 32_000), Turn.user("delegated contract")]
with patch.object(
session._compaction_engine,
"summarize_blocks",
return_value=SummaryResult(text="short summary", producer="summary-kernel"),
) as summarize:
output, contexts = _run_script(
session,
ledger,
[
_tool_result(prompt_tokens=8_200),
make_result("wind-down recorded", usage=_usage(8_300)),
make_result("finished after resume", usage=_usage(8_300)),
],
window=10_000,
)
assert output == "finished after resume"
assert len(contexts) == 3
summarize.assert_called_once()
assert format_nudge("compaction_pending") in _texts(contexts[1])
assert format_nudge("compaction_pending") not in _texts(contexts[2])
def test_execution_journal_bounds_completed_raw_payloads():
journal = _TaskExecutionJournal([])
for index in range(5_000):
call_id = f"call-{index}"
journal.record_assistant(
Turn.assistant(
tool_calls=(
ToolCall(
id=call_id,
name="read_file",
arguments="a" * 16_000,
),
)
)
)
journal.mark_started(call_id)
journal.record_result(
call_id,
"x" * 16_000,
is_error=False,
effect_status=EffectStatus.COMMITTED,
)
steps = journal.project_steps()
assert journal.retained_step_count == 100
assert journal.retained_step_chars < 410_000
assert len(steps) == 101
assert steps[0]["output"] == "(+4900 earlier steps not retained)"
assert steps[-1]["id"] == "call-4999"
assert journal.cancelled_status() is EffectStatus.PARTIAL
def test_execution_journal_bounds_adversarial_ids_and_tool_name_cardinality():
journal = _TaskExecutionJournal([])
for index in range(5_000):
call_id = f"call-{index}-" + "i" * 2_000
name = f"invented-tool-{index}-" + "n" * 2_000
journal.record_assistant(
Turn.assistant(tool_calls=(ToolCall(id=call_id, name=name, arguments="{}"),))
)
journal.mark_started(call_id)
journal.record_result(
call_id,
"done",
is_error=True,
effect_status=EffectStatus.NONE,
)
steps = journal.project_steps()
disposition = journal.cancelled_disposition("task")
assert journal.retained_step_count == 100
assert journal.retained_effect_name_count <= 65 # 64 named keys + overflow bucket
assert all(len(str(step["id"])) <= 256 for step in steps)
assert all(len(str(step["name"])) <= 128 for step in steps)
assert len(disposition) < 12_000
assert "<other tool names>" in disposition
def test_successful_compaction_publishes_post_swap_context_before_next_call():
session = make_session(auto_compact_pct=0.8, agent_max_turns=2)
ledger = [Turn.system("p" * 32_000), Turn.user("delegated contract")]
observed: dict[str, int] = {}
def inspect_third_call(call_number: int, _turns: list[Turn]) -> None:
if call_number != 3:
return
ends = [
call.args[0]
for call in lifecycle.call_args_list
if call.args[0].get("phase") == "end" and call.args[0].get("ok")
]
snapshot = session.ui._snapshot_agent_contexts()
assert len(ends) == 1
assert len(snapshot) == 1
observed["event"] = ends[0]["after_tokens"]
observed["snapshot"] = snapshot[0]["prompt_tokens"]
with (
patch.object(
session._compaction_engine,
"summarize_blocks",
return_value=SummaryResult(text="short summary", producer="summary-kernel"),
),
patch.object(session.ui, "on_compaction", wraps=session.ui.on_compaction) as lifecycle,
):
output, _contexts = _run_script(
session,
ledger,
[
_tool_result(prompt_tokens=8_200),
make_result("wind-down recorded", usage=_usage(8_300)),
make_result("finished after resume", usage=_usage(8_300)),
],
window=10_000,
on_call=inspect_third_call,
)
assert output == "finished after resume"
assert observed["snapshot"] == observed["event"]
def test_raising_context_hook_cannot_reclassify_committed_compaction():
session = make_session(auto_compact_pct=0.8)
session.ui.on_agent_context = MagicMock(side_effect=RuntimeError("broken custom UI"))
ledger = [Turn.user("delegated contract")]
with (
patch.object(
session._compaction_engine,
"summarize_blocks",
return_value=SummaryResult(text="private summary", producer="summary-kernel"),
),
patch.object(session.ui, "on_compaction", wraps=session.ui.on_compaction) as lifecycle,
):
output, contexts = _run_script(
session,
ledger,
[
_tool_result(prompt_tokens=92),
make_result("done after compaction", usage=_usage(30)),
],
)
assert output == "done after compaction"
assert "private summary" in _texts(contexts[1])
events = [call.args[0] for call in lifecycle.call_args_list]
assert [event["phase"] for event in events] == ["start", "end"]
assert events[-1]["ok"] is True
def test_hard_crossing_compacts_before_another_agent_call():
session = make_session(auto_compact_pct=0.8)
ledger = [Turn.user("delegated contract")]
with patch.object(
session._compaction_engine,
"summarize_blocks",
return_value=SummaryResult(text="hard-limit summary", producer="summary-kernel"),
) as summarize:
output, contexts = _run_script(
session,
ledger,
[
_tool_result(prompt_tokens=92),
make_result("done after hard compaction", usage=_usage(30)),
],
)
assert output == "done after hard compaction"
summarize.assert_called_once()
assert len(contexts) == 2
assert format_nudge("compaction_pending") not in _texts(contexts[1])
assert _texts(contexts[1])[-3:] == [
"[Conversation summary]",
"hard-limit summary",
NUDGE_TASK_COMPACTION_RESUME,
]
def test_repeated_task_compactions_have_distinct_targeted_attempt_ids():
session = make_session(auto_compact_pct=0.8)
ledger = [Turn.user("delegated contract")]
with (
patch.object(
session._compaction_engine,
"summarize_blocks",
side_effect=[
SummaryResult(text="first summary", producer="summary-kernel"),
SummaryResult(text="second summary", producer="summary-kernel"),
],
),
patch.object(
session.ui,
"on_compaction",
wraps=session.ui.on_compaction,
) as on_compaction,
):
output, _contexts = _run_script(
session,
ledger,
[
_tool_result(prompt_tokens=92),
_tool_result(prompt_tokens=92),
make_result("done", usage=_usage(30)),
],
)
assert output == "done"
events = [call.args[0] for call in on_compaction.call_args_list]
assert [event["phase"] for event in events] == ["start", "end", "start", "end"]
assert [event["compaction_id"] for event in events] == [1, 1, 2, 2]
assert {event["target"] for event in events} == {"task_agent"}
assert {event["parent_call_id"] for event in events} == {"task-parent"}
assert all("summary" not in event for event in events if event["phase"] == "end")
def test_task_summary_stays_private_and_uses_the_shared_compactor_contract():
session = make_session(auto_compact_pct=0.8)
ledger = [Turn.user("delegated contract")]
journal = _TaskExecutionJournal(ledger)
secret = "TASK_PRIVATE_SUMMARY_SENTINEL_7f0c4e"
summary_requests: list[list[Turn]] = []
def summarize(turns: list[Turn], **_kwargs: Any):
summary_requests.append(list(turns))
return make_result(secret)
with (
patch.object(session, "_utility_completion", side_effect=summarize),
patch.object(session.ui, "on_compaction", wraps=session.ui.on_compaction) as lifecycle,
patch("turnstone.core.session.save_message") as save,
):
output, contexts = _run_script(
session,
ledger,
[
_tool_result(prompt_tokens=40),
RuntimeError("maximum context length exceeded"),
make_result("done after private summary", usage=_usage(30)),
],
window=10_000,
execution_journal=journal,
)
assert output == "done after private summary"
assert len(summary_requests) == 1
assert summary_requests[0][0].role is Role.SYSTEM
assert summary_requests[0][0].text == CompactionEngine.COMPACTOR_SYSTEM_PROMPT
assert secret in _texts(contexts[2])
# The summary is a provider-facing context replacement, not workstream or
# task-recall data. Its only observable trace is token/lifecycle metadata.
assert secret not in repr([call.args[0] for call in lifecycle.call_args_list])
assert secret not in repr(journal.project_steps())
assert secret not in _texts(ledger)
assert secret not in _texts(session.messages)
save.assert_not_called()
def test_provider_overflow_compacts_once_and_retries_same_step():
session = make_session(auto_compact_pct=0.8)
ledger = [Turn.user("delegated contract")]
overflow = RuntimeError("maximum context length exceeded")
with patch.object(
session._compaction_engine,
"summarize_blocks",
return_value=SummaryResult(text="overflow summary", producer="summary-kernel"),
) as summarize:
output, contexts = _run_script(
session,
ledger,
[
_tool_result(prompt_tokens=40),
overflow,
make_result("done after retry", usage=_usage(30)),
],
)
assert output == "done after retry"
summarize.assert_called_once()
assert len(contexts) == 3
assert _texts(contexts[2])[-3:] == [
"[Conversation summary]",
"overflow summary",
NUDGE_TASK_COMPACTION_RESUME,
]
def test_second_overflow_returns_partial_execution_without_retry_storm():
session = make_session(auto_compact_pct=0.8)
ledger = [Turn.user("delegated contract")]
overflow = RuntimeError("maximum context length exceeded")
with patch.object(
session._compaction_engine,
"summarize_blocks",
return_value=SummaryResult(text="overflow summary", producer="summary-kernel"),
) as summarize:
output, contexts = _run_script(
session,
ledger,
[
_tool_result(prompt_tokens=40, content="useful partial analysis"),
overflow,
overflow,
],
)
assert output == "useful partial analysis"
summarize.assert_called_once()
assert len(contexts) == 3
def test_turn_limit_compacts_before_forced_synthesis():
session = make_session(auto_compact_pct=0.8, agent_max_turns=1)
# Keep enough message content in the immutable task prefix that the exact
# tool-free synthesis request is still over soft. This pins the general
# turn-limit compaction policy without relying on a schema the next request
# explicitly discards.
ledger = [Turn.user("delegated contract " * 50)]
with patch.object(
session._compaction_engine,
"summarize_blocks",
return_value=SummaryResult(text="turn-limit summary", producer="summary-kernel"),
) as summarize:
output, contexts = _run_script(
session,
ledger,
[
_tool_result(prompt_tokens=82),
make_result("forced synthesis", usage=_usage(30)),
],
)
assert output == "forced synthesis"
summarize.assert_called_once()
assert len(contexts) == 2
assert "turn-limit summary" in _texts(contexts[1])
assert "You have reached the tool call limit" in contexts[1][-1].text
def test_turn_limit_sizes_the_actual_tool_free_synthesis_request():
"""A discarded tool schema alone must not trigger lossy final compaction."""
session = make_session(auto_compact_pct=0.8, agent_max_turns=1)
ledger = [Turn.user("delegated contract")]
large_tools = [
{
"type": "function",
"function": {
"name": TOOL_NAME,
"description": "d" * 3_558,
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
},
},
}
]
with patch.object(session._compaction_engine, "summarize_blocks") as summarize:
output, contexts = _run_script(
session,
ledger,
[
_tool_result(prompt_tokens=900),
make_result("forced synthesis", usage=_usage(58)),
],
window=1_000,
tools=large_tools,
)
assert output == "forced synthesis"
summarize.assert_not_called()
assert len(contexts) == 2
assert contexts[1][-2].role is Role.TOOL
assert contexts[1][-2].text == "x"
assert "You have reached the tool call limit" in contexts[1][-1].text
assert not any(turn.source == "compaction" for turn in contexts[1])
def test_summary_uses_task_cancel_ref_and_clears_only_task_reads():
session = make_session(auto_compact_pct=0.8, reasoning_effort="low")
parent_reads = session._read_files
parent_reads.add("/parent/read.py")
task_reads = {"/parent/read.py", "/task/exact.py"}
token = _active_read_files.set(task_reads)
seen_cancel_refs: list[object] = []
seen_efforts: list[str | None] = []
def summary_completion(_turns, **kwargs):
seen_cancel_refs.append(kwargs["cancel_ref"])
seen_efforts.append(kwargs["reasoning_effort"])
return make_result("task-local summary")
def summarize(_blocks, runtime):
result = runtime.complete("summary system", "summary body", 100)
return SummaryResult(text=result.content, producer=result.producer)
ledger = [Turn.user("delegated contract")]
try:
with (
patch.object(session._compaction_engine, "summarize_blocks", side_effect=summarize),
patch.object(session, "_utility_completion", side_effect=summary_completion),
):
output, _contexts = _run_script(
session,
ledger,
[
_tool_result(prompt_tokens=92),
make_result("done", usage=_usage(30)),
],
)
finally:
_active_read_files.reset(token)
assert output == "done"
assert len(seen_cancel_refs) == 1
cancel_ref = seen_cancel_refs[0]
assert cancel_ref.__class__.__name__ == "StreamAbortRef"
assert seen_efforts == ["low"]
assert task_reads == set()
assert parent_reads == {"/parent/read.py"}
def test_cancelled_summary_retires_event_without_mutating_effect_ledger():
session = make_session(auto_compact_pct=0.8)
ledger = [Turn.user("delegated contract")]
with (
patch.object(
session._compaction_engine,
"summarize_blocks",
side_effect=GenerationCancelled(),
),
patch.object(
session.ui,
"on_compaction",
wraps=session.ui.on_compaction,
) as on_compaction,
pytest.raises(GenerationCancelled),
):
_run_script(
session,
ledger,
[_tool_result(prompt_tokens=92)],
)
events = [call.args[0] for call in on_compaction.call_args_list]
assert [event["phase"] for event in events] == ["start", "end"]
assert events[1]["reason"] == "cancelled"
assert events[1]["notice"] is False
assert {event["target"] for event in events} == {"task_agent"}
assert [turn.role for turn in ledger] == [Role.USER, Role.ASSISTANT, Role.TOOL]
assert not any(turn.source == "compaction" for turn in ledger)
def test_closed_summary_stream_uses_task_scope_cancellation_without_retrying():
session = make_session(auto_compact_pct=0.8)
ledger = [Turn.user("delegated contract")]
def cancelled_summary(_turns, **kwargs):
kwargs["cancel_ref"].abort()
raise RuntimeError("summary stream closed by Stop")
with (
patch.object(session, "_utility_completion", side_effect=cancelled_summary) as complete,
patch.object(session, "_stop_retrying", return_value=True),
patch.object(
session.ui,
"on_compaction",
wraps=session.ui.on_compaction,
) as on_compaction,
pytest.raises(GenerationCancelled),
):
_run_script(
session,
ledger,
[_tool_result(prompt_tokens=92)],
)
complete.assert_called_once()
events = [call.args[0] for call in on_compaction.call_args_list]
assert events[0]["phase"] == "start"
assert events[-1]["phase"] == "end"
assert not any("retry_in" in event for event in events)
assert events[-1]["reason"] == "cancelled"
assert events[-1]["notice"] is False
assert [turn.role for turn in ledger] == [Role.USER, Role.ASSISTANT, Role.TOOL]
def test_policy_boundaries_are_strict_and_shared():
policy = make_session(auto_compact_pct=0.8)._compaction_policy(100)
assert policy.over_soft(80) is False
assert policy.over_soft(81) is True
assert policy.over_hard(90) is False
assert policy.over_hard(91) is True
assert policy.owed(81, advised=False) is False
assert policy.owed(81, advised=True) is True
assert policy.owed(91, advised=False) is True
def test_failed_compaction_is_not_retried_without_new_context():
session = make_session(auto_compact_pct=0.8)
ledger = [Turn.user("delegated contract")]
overflow = RuntimeError("maximum context length exceeded")
failure = RuntimeError("summary backend unavailable")
with (
patch.object(
session._compaction_engine,
"summarize_blocks",
side_effect=failure,
) as summarize,
patch.object(session, "_stop_retrying", return_value=True),
patch.object(session.ui, "on_compaction", wraps=session.ui.on_compaction) as lifecycle,
patch.object(session.ui, "on_error", wraps=session.ui.on_error) as on_error,
):
output, contexts = _run_script(
session,
ledger,
[
_tool_result(prompt_tokens=92, content="partial before failure"),
overflow,
],
)
assert output == "partial before failure"
summarize.assert_called_once()
assert len(contexts) == 2
events = [call.args[0] for call in lifecycle.call_args_list]
assert [event["phase"] for event in events] == ["start", "end"]
assert events[-1]["reason"] == "error"
assert events[-1]["notice"] is True
on_error.assert_not_called()
def test_post_summary_exception_emits_one_terminal_end_without_swapping_context():
session = make_session(auto_compact_pct=0.8)
ledger = [Turn.user("delegated contract")]
with (
patch.object(
session._compaction_engine,
"summarize_blocks",
return_value=SummaryResult(text="valid summary", producer="summary-kernel"),
),
patch(
"turnstone.core.session.PromptTokenEstimator.invalidate",
side_effect=RuntimeError("injected estimator failure"),
),
patch.object(session.ui, "on_compaction", wraps=session.ui.on_compaction) as lifecycle,
):
output, contexts = _run_script(
session,
ledger,
[
_tool_result(prompt_tokens=92),
make_result("continued on original context", usage=_usage(40)),
],
)
assert output == "continued on original context"
events = [call.args[0] for call in lifecycle.call_args_list]
assert [event["phase"] for event in events] == ["start", "end"]
assert events[-1]["reason"] == "error"
assert events[-1]["ok"] is False
assert _texts(contexts[1])[-2:] == ["working", "x"]
assert not any(turn.source == "compaction" for turn in contexts[1])
@pytest.mark.parametrize("prompt_tokens", [0, 1])
def test_tiny_provider_usage_does_not_break_estimation(prompt_tokens: int):
session = make_session(auto_compact_pct=0.8)
ledger = [Turn.user("delegated contract")]
output, contexts = _run_script(
session,
ledger,
[make_result("done", usage=_usage(prompt_tokens))],
)
assert output == "done"
assert len(contexts) == 1
+20 -7
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import contextlib
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
@@ -225,7 +226,11 @@ class TestContextOverflowRecovery:
patch.object(session, "_stream_response", side_effect=mock_stream_response),
patch.object(session, "_compact_messages", compact_mock),
patch.object(session, "_full_messages", return_value=[]),
patch.object(session, "_over_soft", return_value=False),
patch.object(
session,
"_compaction_policy",
return_value=_policy(over_soft=False),
),
patch.object(session, "_update_token_table"),
patch.object(session, "_print_status_line"),
patch.object(session, "_emit_state"),
@@ -334,6 +339,14 @@ def _tool_turn_texts(session):
return [m.text for m in session.messages if m.role is Role.TOOL]
def _policy(*, owed: bool = False, over_soft: bool = False, over_hard: bool = False):
return SimpleNamespace(
owed=lambda *_args, **_kwargs: owed,
over_soft=lambda *_args, **_kwargs: over_soft,
over_hard=lambda *_args, **_kwargs: over_hard,
)
_SPAWN_CALL = [
{
"id": "tc_spawn",
@@ -461,7 +474,7 @@ class TestZeroBudgetDrain:
[("tc_f", "page " * 2000)],
_remaining_token_budget=budget,
_compact_messages=compact,
_compaction_owed=MagicMock(return_value=False),
_compaction_policy=MagicMock(return_value=_policy(owed=False)),
):
session.send("go")
@@ -489,7 +502,7 @@ class TestZeroBudgetDrain:
[("tc_spawn", _SPAWN_RESULT)],
_remaining_token_budget=MagicMock(return_value=0),
_compact_messages=compact,
_compaction_owed=MagicMock(return_value=True),
_compaction_policy=MagicMock(return_value=_policy(owed=True)),
_do_auto_compact=owed_compact,
):
session.send("go")
@@ -514,7 +527,7 @@ class TestZeroBudgetDrain:
[("tc_spawn", _SPAWN_RESULT), ("tc_f", "page " * 2000)],
_remaining_token_budget=MagicMock(return_value=0),
_compact_messages=compact,
_compaction_owed=MagicMock(return_value=False),
_compaction_policy=MagicMock(return_value=_policy(owed=False)),
):
session.send("go")
@@ -545,7 +558,7 @@ class TestZeroBudgetDrain:
batches,
_remaining_token_budget=MagicMock(return_value=0),
_compact_messages=compact,
_compaction_owed=MagicMock(return_value=False),
_compaction_policy=MagicMock(return_value=_policy(owed=False)),
):
session.send("go")
@@ -578,7 +591,7 @@ class TestZeroBudgetDrain:
batches,
_remaining_token_budget=budget,
_compact_messages=compact,
_compaction_owed=MagicMock(return_value=False),
_compaction_policy=MagicMock(return_value=_policy(owed=False)),
):
session.send("go")
@@ -677,7 +690,7 @@ class TestZeroBudgetDrain:
batches,
_remaining_token_budget=budget,
_compact_messages=compact,
_compaction_owed=MagicMock(return_value=False),
_compaction_policy=MagicMock(return_value=_policy(owed=False)),
):
session.send("go")
+317
View File
@@ -0,0 +1,317 @@
"""Behavior guards for the shared transcript-presentation preference."""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
from tests._js_harness_helpers import FAKE_DOM, node_skip
_ROOT = Path(__file__).resolve().parent.parent
_MODULE = _ROOT / "turnstone/shared_static/transcript_presentation.js"
def test_module_exposes_only_the_shared_presentation_seams() -> None:
body = _MODULE.read_text(encoding="utf-8")
for name in (
"getTranscriptPresentation",
"setTranscriptPresentation",
"canAutoFoldTranscriptBatch",
"mountTranscriptPresentationToggle",
"preserveTranscriptBottomPin",
"registerTranscriptScroller",
):
assert f"export function {name}" in body
assert "fetch(" not in body
assert "innerHTML" not in body
assert "turnstone_interface.transcript_presentation" in body
def _run_node(body: str) -> None:
proc = subprocess.run(
["node", "--input-type=module", "-e", body],
capture_output=True,
text=True,
timeout=20,
)
assert proc.returncode == 0, proc.stderr
@pytest.mark.parametrize(
("stored", "expected", "root_value"),
[
("compact", "compact", "compact"),
("default", "default", None),
("unknown", "default", None),
],
)
@node_skip
def test_initial_stored_value_is_normalized(
stored: str, expected: str, root_value: str | None
) -> None:
script = (
FAKE_DOM
+ f"""
const key = "turnstone_interface.transcript_presentation";
storage.set(key, {json.dumps(stored)});
const mod = await import({json.dumps(_MODULE.as_uri() + "?stored=" + stored)});
if (mod.getTranscriptPresentation() !== {json.dumps(expected)})
throw new Error("stored mode did not normalize");
if (html.getAttribute("data-transcript-presentation") !== {json.dumps(root_value)})
throw new Error("stored mode stamped the wrong root state");
"""
)
_run_node(script)
@node_skip
def test_preference_toggle_storage_lifecycle_and_viewport_behavior() -> None:
script = (
FAKE_DOM
+ f"""
const mod = await import({json.dumps(_MODULE.as_uri())});
const key = "turnstone_interface.transcript_presentation";
const assert = (condition, message) => {{ if (!condition) throw new Error(message); }};
assert(mod.getTranscriptPresentation() === "default", "missing value was not Default");
assert(!html.hasAttribute("data-transcript-presentation"), "Default stamped root mode");
const controls = new FakeElement("div");
html.appendChild(controls);
const unmount = mod.mountTranscriptPresentationToggle(controls);
const button = controls.children[0];
assert(button.getAttribute("aria-label") === "Compact ledger presentation", "unstable name");
assert(button.getAttribute("aria-pressed") === "false", "initial pressed state");
assert(button.title === "Switch to compact ledger presentation", "default title drifted");
assert(button.getAttribute("aria-description").includes("hides model reasoning"), "description missing omission");
button.click();
assert(mod.getTranscriptPresentation() === "compact", "toggle did not compact");
assert(html.getAttribute("data-transcript-presentation") === "compact", "root not compact");
assert(storage.get(key) === "compact", "compact was not persisted");
assert(button.getAttribute("aria-pressed") === "true", "control did not synchronize");
assert(button.title === "Switch to default ledger presentation", "compact title drifted");
button.click();
assert(mod.getTranscriptPresentation() === "default", "toggle did not restore Default");
assert(!html.hasAttribute("data-transcript-presentation"), "Default root override remained");
assert(storage.get(key) === "default", "explicit Default was not persisted");
mod.setTranscriptPresentation("not-a-mode");
assert(mod.getTranscriptPresentation() === "default", "invalid direct value did not normalize");
const onStorage = windowListeners.get("storage");
onStorage({{ key: "unrelated", newValue: "compact" }});
assert(mod.getTranscriptPresentation() === "default", "unrelated storage event applied");
onStorage({{ key, newValue: "compact" }});
assert(mod.getTranscriptPresentation() === "compact", "valid storage event did not apply");
onStorage({{ key, newValue: "invalid" }});
assert(mod.getTranscriptPresentation() === "default", "invalid storage value did not default");
onStorage({{ key: null, newValue: null }});
assert(mod.getTranscriptPresentation() === "default", "storage clear did not default");
failSet = true;
mod.setTranscriptPresentation("compact");
assert(mod.getTranscriptPresentation() === "compact", "storage failure blocked page state");
failSet = false;
const scroller = new FakeElement("div");
scroller.scrollHeight = 500;
scroller.clientHeight = 100;
scroller.scrollTop = 400;
html.appendChild(scroller);
const unregister = mod.registerTranscriptScroller(scroller);
assert(scroller.hasAttribute("data-transcript-root"), "root marker missing");
mod.preserveTranscriptBottomPin(scroller, () => {{
scroller.scrollHeight = 700;
}});
assert(scroller.scrollTop === 700, "late reflow lost a captured bottom pin");
scroller.scrollTop = 100;
scroller.dispatch("scroll");
mod.preserveTranscriptBottomPin(scroller, () => {{
scroller.scrollHeight = 800;
}});
assert(scroller.scrollTop === 100, "late reflow moved a scrolled-away viewport");
scroller.scrollHeight = 500;
scroller.scrollTop = 400;
scroller.dispatch("scroll");
mod.setTranscriptPresentation("default", {{ persist: false }});
assert(scroller.scrollTop === 500, "bottom-following scroller was not repinned");
scroller.scrollTop = 100;
mod.setTranscriptPresentation("compact", {{ persist: false }});
assert(scroller.scrollTop === 100, "scrolled-away viewport was moved");
// A background pane has no measurable rect while the global mode change
// reflows it. Retain its last bottom-follow state and restore only when the
// pane becomes visible again.
scroller.scrollHeight = 600;
scroller.scrollTop = 500;
scroller.dispatch("scroll");
scroller._visible = false;
scroller.scrollHeight = 900;
mod.setTranscriptPresentation("default", {{ persist: false }});
assert(scroller.scrollTop === 500, "hidden scroller was measured or moved early");
scroller._visible = true;
triggerResize(scroller);
assert(scroller.scrollTop === 900, "background bottom pin was not restored on activation");
scroller.scrollTop = 100;
scroller.dispatch("scroll");
scroller._visible = false;
mod.setTranscriptPresentation("compact", {{ persist: false }});
scroller._visible = true;
triggerResize(scroller);
assert(scroller.scrollTop === 100, "background scrolled-away viewport was moved");
const batch = new FakeElement("div");
batch.className = "conv-batch conv-batch--approved";
batch.dataset.resultsSettled = "true";
batch.dataset.compactFolded = "true";
const head = new FakeElement("div");
head.className = "conv-batch-head";
const disclosure = new FakeElement("button");
disclosure.className = "conv-batch-disclosure";
head.appendChild(disclosure);
const detail = new FakeElement("button");
batch.append(head, detail);
scroller.appendChild(batch);
detail.focus();
mod.setTranscriptPresentation("default", {{ persist: false }});
mod.setTranscriptPresentation("compact", {{ persist: false }});
assert(!Object.hasOwn(batch.dataset, "compactFolded"), "focused detail was hidden");
batch.dataset.compactFolded = "true";
const media = new FakeElement("audio");
media.paused = false;
batch.appendChild(media);
document.activeElement = null;
mod.setTranscriptPresentation("default", {{ persist: false }});
mod.setTranscriptPresentation("compact", {{ persist: false }});
assert(!Object.hasOwn(batch.dataset, "compactFolded"), "playing media was hidden");
disclosure.focus();
mod.setTranscriptPresentation("default", {{ persist: false }});
assert(document.activeElement === head, "Default switch stranded disclosure focus");
assert(head.getAttribute("tabindex") === "-1", "focus target was not temporary-focusable");
head.dispatch("blur");
assert(!head.hasAttribute("tabindex"), "temporary tabindex was retained");
unregister();
assert(!scroller.hasAttribute("data-transcript-root"), "root marker survived teardown");
unmount();
assert(controls.children.length === 0, "control survived teardown");
"""
)
_run_node(script)
@node_skip
def test_hidden_auto_fold_and_deferred_restore_use_current_follow_state() -> None:
queued_dom = FAKE_DOM.replace(
"globalThis.requestAnimationFrame = (fn) => { fn(); return 1; };",
"""
const animationFrames = [];
globalThis.requestAnimationFrame = (fn) => {
animationFrames.push(fn);
return animationFrames.length;
};
globalThis.flushAnimationFrames = () => {
const pending = animationFrames.splice(0);
pending.forEach((fn) => fn());
};
""",
)
script = (
queued_dom
+ f"""
const mod = await import({json.dumps(_MODULE.as_uri() + "?deferred-races=1")});
const assert = (condition, message) => {{ if (!condition) throw new Error(message); }};
const scroller = new FakeElement("div");
scroller.scrollHeight = 500;
scroller.clientHeight = 100;
scroller.scrollTop = 400;
scroller.getBoundingClientRect = () => ({{ top: 0, bottom: 100 }});
const batch = new FakeElement("div");
batch.className = "conv-batch";
batch.getBoundingClientRect = () => ({{ top: 150, bottom: 170 }});
scroller.appendChild(batch);
html.appendChild(scroller);
mod.registerTranscriptScroller(scroller);
scroller.scrollTop = 100;
scroller.dispatch("scroll");
assert(
mod.canAutoFoldTranscriptBatch(scroller, batch, {{ atBottom: false }}),
"visible below-viewport batch could not fold",
);
batch.getBoundingClientRect = () => ({{ top: 50, bottom: 70 }});
assert(
!mod.canAutoFoldTranscriptBatch(scroller, batch, {{ atBottom: false }}),
"visible intersecting batch folded for a scrolled-away user",
);
scroller.scrollTop = 400;
scroller.dispatch("scroll");
scroller._visible = false;
batch._visible = false;
scroller.getBoundingClientRect = () => ({{ top: 0, bottom: 0 }});
batch.getBoundingClientRect = () => ({{ top: 0, bottom: 0 }});
assert(
mod.canAutoFoldTranscriptBatch(scroller, batch, {{ atBottom: false }}),
"hidden bottom-following pane lost its cached fold state",
);
scroller._visible = true;
batch._visible = true;
scroller.scrollTop = 100;
scroller.dispatch("scroll");
scroller._visible = false;
batch._visible = false;
assert(
!mod.canAutoFoldTranscriptBatch(scroller, batch, {{ atBottom: true }}),
"hidden scrolled-away pane trusted zero geometry or a stale caller cache",
);
scroller._visible = true;
batch._visible = true;
scroller.scrollHeight = 500;
scroller.scrollTop = 400;
scroller.dispatch("scroll");
mod.preserveTranscriptBottomPin(scroller, () => {{
scroller.scrollHeight = 700;
}});
scroller.scrollTop = 100;
scroller.dispatch("scroll");
flushAnimationFrames();
assert(scroller.scrollTop === 100, "newer user scroll was overwritten by rAF restore");
scroller.scrollHeight = 500;
scroller.scrollTop = 400;
scroller.dispatch("scroll");
scroller._visible = false;
mod.preserveTranscriptBottomPin(scroller, () => {{
scroller.scrollHeight = 900;
}});
flushAnimationFrames();
scroller._visible = true;
scroller.scrollTop = 100;
scroller.dispatch("scroll");
triggerResize(scroller);
assert(scroller.scrollTop === 100, "newer scroll was overwritten by hidden restore");
"""
)
_run_node(script)
@node_skip
def test_unreadable_initial_storage_fails_to_default() -> None:
script = (
FAKE_DOM
+ f"""
failGet = true;
const mod = await import({json.dumps(_MODULE.as_uri() + "?unreadable=1")});
if (mod.getTranscriptPresentation() !== "default") throw new Error("read failure was not Default");
if (html.hasAttribute("data-transcript-presentation")) throw new Error("read failure stamped root");
"""
)
_run_node(script)
+10 -1
View File
@@ -1514,13 +1514,22 @@ class TestHistoryAgentStepsOverlay:
ws_id = "ws-recall-warm"
self._save_task_agent_turn(_inject_storage, ws_id)
steps = [
{
"id": "",
"name": "",
"arguments": "{}",
"output": "(+1 earlier step not retained)",
"is_error": False,
"contains_exceptional": True,
},
{
"id": "task1::c1",
"name": "search",
"arguments": "{}",
"output": "12 matches",
"is_error": False,
}
"effect_status": "committed",
},
]
mock_ws = _live_history_workstream(ws_id)
mock_ws.ui.get_agent_trajectory = lambda cid: steps if cid == "task1" else None
@@ -26,6 +26,53 @@
overflow: hidden;
}
/* The L-shell owns the shared presentation control for pane-hosted
coordinators. The rail-less standalone fallback gets this small neutral
toolbar instead; keeping it outside the live status region prevents a mode
toggle from being announced as coordinator state. */
#coord-standalone-toolbar {
flex: 0 0 auto;
min-height: 36px;
padding: 4px 8px;
border-bottom: 1px solid var(--hair);
background: var(--panel);
display: flex;
align-items: center;
justify-content: flex-end;
}
#coord-standalone-toolbar .transcript-presentation-toggle {
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--hair);
border-radius: var(--r-sm);
background: var(--panel-2);
color: var(--ink-3);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
#coord-standalone-toolbar
.transcript-presentation-toggle[aria-pressed="true"] {
border-color: var(--accent-dim);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, var(--panel-2));
}
#coord-standalone-toolbar .transcript-presentation-toggle:hover {
border-color: var(--accent-dim);
color: var(--ink);
}
#coord-standalone-toolbar .transcript-presentation-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
#coord-standalone-toolbar .transcript-presentation-glyph {
font-size: 16px;
line-height: 1;
transform: scaleX(1.1);
}
/* Main layout chat pane (2fr) + sidebar (1fr) with shared
#coord-main and #coord-body flex wiring. */
#coord-body {
@@ -42,8 +42,21 @@ import {
buildConvResult,
buildPreviewChip,
batchKicker,
clearConvVerdictPending,
convBatchSummaryText,
indexLabel,
isConvVerdictCompactBlocker,
markConvRowResultSettled,
setReasoningActivity,
setConvBatchExpanded,
setToolOutputReviewState,
} from "/shared/conversation.js";
import {
getTranscriptPresentation,
mountTranscriptPresentationToggle,
preserveTranscriptBottomPin,
registerTranscriptScroller,
} from "/shared/transcript_presentation.js";
import { redactCredentials } from "/shared/redact_credentials.js";
import { tryParseMcpError, buildMcpErrorEmbed } from "/shared/mcp_error.js";
import {
@@ -378,6 +391,15 @@ function buildCoordChrome(root, opts) {
],
);
if (opts.standalone) {
root.append(
el("div", {
id: "coord-standalone-toolbar",
role: "toolbar",
"aria-label": "Viewer controls",
}),
);
}
root.append(el("div", { id: "coord-body" }, [main, sidebar]));
if (opts.standalone) {
root.append(
@@ -397,6 +419,17 @@ function createCoordinatorPane(root, wsId, opts) {
}
buildCoordChrome(root, opts);
let unmountTranscriptPresentation = null;
if (opts && opts.standalone) {
const toolbar = root.querySelector("#coord-standalone-toolbar");
if (toolbar) {
unmountTranscriptPresentation = mountTranscriptPresentationToggle(
toolbar,
{ className: "ghost" },
);
}
}
if (opts && opts.standalone) {
// Rail-less page: mount the pending-consent chip in the status bar —
// the persistent signal the L-shell gets from the rail badge (#874).
@@ -415,6 +448,7 @@ function createCoordinatorPane(root, wsId, opts) {
}
const messagesEl = root.querySelector("#coord-messages");
const unregisterTranscriptScroller = registerTranscriptScroller(messagesEl);
const coordMain = root.querySelector("#coord-main");
const composerMount = root.querySelector("#coord-composer-mount");
const composer = new Composer(composerMount, {
@@ -1601,6 +1635,17 @@ function createCoordinatorPane(root, wsId, opts) {
// state. Per-row check (not a counter) keeps the logic
// resilient to out-of-order replay + late SSE deliveries.
_unsetBatchRunningIfAllResults(entry.batch);
const settlement =
opts && opts.accepted
? markConvRowResultSettled(entry.row, { autoFold: true })
: null;
if (
getTranscriptPresentation() === "compact" &&
settlement &&
settlement.autoFolded
) {
_announcePolite("Completed: " + convBatchSummaryText(entry.batch));
}
// Result blocks grow scrollHeight; without this the user pinned
// at the bottom loses their pin when the row inflates. appendMsg
// already routes through _scheduleScroll on the legacy path; this
@@ -1748,7 +1793,8 @@ function createCoordinatorPane(root, wsId, opts) {
if (!tierEl) {
tierEl = document.createElement("span");
tierEl.className = "conv-batch-tier";
head.appendChild(tierEl);
const disclosure = head.querySelector(".conv-batch-disclosure");
head.insertBefore(tierEl, disclosure || null);
}
if (tierEl.textContent !== label) tierEl.textContent = label;
} else if (tierEl) {
@@ -1770,6 +1816,10 @@ function createCoordinatorPane(root, wsId, opts) {
// reflect execution outcome, not the static item payload.
function _refreshRowStatus(row, item) {
if (!row || !item) return;
if (item.error && !item.needs_approval) {
const batch = row.closest(".conv-batch");
if (batch) setConvBatchExpanded(batch, true, { blocker: true });
}
const callLine = row.querySelector(".conv-row-call");
if (callLine) {
callLine.querySelectorAll(".conv-row-status").forEach((p) => p.remove());
@@ -1839,6 +1889,8 @@ function createCoordinatorPane(root, wsId, opts) {
// The chip's risk / flags / redaction / tier / reasoning are all built by
// the shared buildConvWarning (reasoning is now inline, not a <details>).
if (!row || !oa) return;
const batch = row.closest(".conv-batch");
if (batch) setConvBatchExpanded(batch, true, { blocker: true });
const existing = row.querySelector(".conv-warning");
const chip = buildConvWarning(oa);
if (existing) existing.replaceWith(chip);
@@ -1880,49 +1932,63 @@ function createCoordinatorPane(root, wsId, opts) {
if (verdict && row.dataset.verdictSig === sig) {
return row.querySelector(".conv-verdict");
}
row.dataset.verdictSig = sig;
const renderVerdict = () => {
if (verdict && isConvVerdictCompactBlocker(verdict)) {
const batch = row.closest(".conv-batch");
if (batch) setConvBatchExpanded(batch, true, { blocker: true });
}
row.dataset.verdictSig = sig;
// Drop any prior verdict badge + its detail sibling before rebuilding.
const prevBadge = row.querySelector(".conv-verdict");
if (prevBadge) {
const prevDetail = prevBadge.nextElementSibling;
if (prevDetail && prevDetail.classList.contains("conv-verdict-detail")) {
prevDetail.remove();
// Drop any prior verdict badge + its detail sibling before rebuilding.
const prevBadge = row.querySelector(".conv-verdict");
if (prevBadge) {
const prevDetail = prevBadge.nextElementSibling;
if (prevDetail && prevDetail.classList.contains("conv-verdict-detail")) {
prevDetail.remove();
}
prevBadge.remove();
}
prevBadge.remove();
}
if (verdict) {
const frag = buildConvVerdict(verdict);
const callEl = row.querySelector(".conv-row-call");
if (callEl && callEl.nextSibling) {
row.insertBefore(frag, callEl.nextSibling);
} else {
row.appendChild(frag);
if (verdict) {
const frag = buildConvVerdict(verdict);
const callEl = row.querySelector(".conv-row-call");
if (callEl && callEl.nextSibling) {
row.insertBefore(frag, callEl.nextSibling);
} else {
row.appendChild(frag);
}
}
}
// Persist the verdict's tier on the row so the batch's header
// tier badge can escalate from ⚙ heuristic → ⚖ llm when a later
// intent_verdict lands an LLM verdict. Default to "heuristic"
// when tier is absent — heuristic verdicts ship without an
// explicit tier marker on every server emitter.
if (verdict) {
row.dataset.verdictTier = verdict.tier || "heuristic";
if (verdict.judge_model) {
row.dataset.verdictModel = verdict.judge_model;
// Persist the verdict's tier on the row so the batch's header
// tier badge can escalate from ⚙ heuristic → ⚖ llm when a later
// intent_verdict lands an LLM verdict. Default to "heuristic"
// when tier is absent — heuristic verdicts ship without an
// explicit tier marker on every server emitter.
if (verdict) {
row.dataset.verdictTier = verdict.tier || "heuristic";
if (verdict.judge_model) {
row.dataset.verdictModel = verdict.judge_model;
} else {
delete row.dataset.verdictModel;
}
} else {
delete row.dataset.verdictTier;
delete row.dataset.verdictModel;
}
} else {
delete row.dataset.verdictTier;
delete row.dataset.verdictModel;
}
_refreshBatchTier(row.closest(".conv-batch"));
return row.querySelector(".conv-verdict");
_refreshBatchTier(row.closest(".conv-batch"));
return row.querySelector(".conv-verdict");
};
// History and initial batch construction render detached rows. Their
// eventual append already participates in _scheduleScroll(); measuring the
// live scroller here would force layout and enqueue one rAF per verdict.
return row.isConnected
? preserveTranscriptBottomPin(messagesEl, renderVerdict)
: renderVerdict();
}
function _appendJudgePendingLineTo(row) {
// Clear any prior verdict, then render the spinner-only badge (neutral
// stripe -- risk isn't known until the judge lands).
const batch = row.closest(".conv-batch");
if (batch) setConvBatchExpanded(batch, true, { blocker: true });
_appendVerdictLineTo(row, null);
const frag = buildConvVerdict(null, { judgePending: true });
const callEl = row.querySelector(".conv-row-call");
@@ -1935,10 +2001,21 @@ function createCoordinatorPane(root, wsId, opts) {
function _appendResultToRow(row, output, isError, opts) {
if (!row) return null;
const batch = row.closest(".conv-batch");
const exceptionalEffect =
opts &&
opts.accepted &&
opts.effectStatus &&
String(opts.effectStatus) !== "committed";
if ((isError || exceptionalEffect) && batch) {
setConvBatchExpanded(batch, true, { blocker: true });
}
row
.querySelectorAll(".conv-row-result")
.forEach((existing) => existing.remove());
if (opts && opts.accepted) {
clearConvVerdictPending(row);
setToolOutputReviewState(row, output);
if (opts.effectStatus) {
row.dataset.effectStatus = String(opts.effectStatus);
} else {
@@ -1950,7 +2027,6 @@ function createCoordinatorPane(root, wsId, opts) {
// Lift the row's error onto the enclosing batch so the left
// stripe + status pill (--error) cue the operator at the batch
// level too. Idempotent — re-fires don't stack.
const batch = row.closest(".conv-batch");
if (batch) batch.classList.add("conv-batch--error");
}
// Structured MCP error envelope (consent / re-consent / forbidden /
@@ -2110,6 +2186,7 @@ function createCoordinatorPane(root, wsId, opts) {
function _setBatchRunning(batch) {
if (!batch) return;
setConvBatchExpanded(batch, true, { blocker: true });
batch.classList.add("conv-batch--running");
const kicker = batch.querySelector(".conv-batch-kicker");
if (kicker) {
@@ -2144,6 +2221,9 @@ function createCoordinatorPane(root, wsId, opts) {
function _morphBatchResolved(batch, opts) {
if (!batch) return;
if (!opts.approved) {
setConvBatchExpanded(batch, true, { blocker: true });
}
batch.classList.remove("conv-batch--pending");
batch.classList.add(
opts.approved ? "conv-batch--approved" : "conv-batch--denied",
@@ -2251,6 +2331,7 @@ function createCoordinatorPane(root, wsId, opts) {
// turn that was actually auto-
// approved + in-flight at reload)
if (opts.pending && !existing.classList.contains("conv-batch--pending")) {
setConvBatchExpanded(existing, true, { blocker: true });
existing.classList.remove(
"conv-batch--approved",
"conv-batch--denied",
@@ -2439,6 +2520,7 @@ function createCoordinatorPane(root, wsId, opts) {
let currentReasoningBuf = "";
function appendContentToken(text) {
setReasoningActivity(currentReasoningEl, false);
if (!currentAssistantEl) {
currentAssistantEl = appendMsg("assistant", "", { label: "assistant" });
currentAssistantBuf = "";
@@ -2477,6 +2559,7 @@ function createCoordinatorPane(root, wsId, opts) {
currentReasoningBuf = "";
messagesEl.setAttribute("aria-live", "off");
}
setReasoningActivity(currentReasoningEl, true);
currentReasoningBuf += text;
const body = currentReasoningEl.querySelector(".msg-body");
if (body) body.textContent = currentReasoningBuf;
@@ -2501,6 +2584,7 @@ function createCoordinatorPane(root, wsId, opts) {
}
currentAssistantEl = null;
currentAssistantBuf = "";
setReasoningActivity(currentReasoningEl, false);
currentReasoningEl = null;
currentReasoningBuf = "";
messagesEl.setAttribute("aria-live", "polite");
@@ -2572,6 +2656,11 @@ function createCoordinatorPane(root, wsId, opts) {
// plus the cancel-timer cleanup wired via the onIdle hook above).
function setBusy(b, source) {
const next = !!b;
if (!next) {
setReasoningActivity(currentReasoningEl, false);
currentReasoningEl = null;
currentReasoningBuf = "";
}
// Who asserted busy: "server" (default — state events and every
// existing/future writer) or "optimistic" (ONLY coordSend's pre-POST
// flip). The deferred/queue_full settle arms may clear busy solely
@@ -3528,6 +3617,7 @@ function createCoordinatorPane(root, wsId, opts) {
suspendStream();
currentAssistantEl = null;
currentAssistantBuf = "";
setReasoningActivity(currentReasoningEl, false);
currentReasoningEl = null;
currentReasoningBuf = "";
// Drop the live cursor for the reconnect: the full render below
@@ -3712,12 +3802,14 @@ function createCoordinatorPane(root, wsId, opts) {
});
messagesEl.setAttribute("aria-live", "off");
}
setReasoningActivity(currentReasoningEl, true);
currentReasoningBuf = ev.reasoning;
var rbody = currentReasoningEl.querySelector(".msg-body");
if (rbody) rbody.textContent = currentReasoningBuf;
_scheduleScroll();
}
if (ev.content && ev.content.length > currentAssistantBuf.length) {
setReasoningActivity(currentReasoningEl, false);
if (!currentAssistantEl) {
currentAssistantEl = appendMsg("assistant", "", {
label: "assistant",
@@ -3954,6 +4046,10 @@ function createCoordinatorPane(root, wsId, opts) {
break;
}
case "compaction":
// The standalone coordinator viewer does not project task-agent
// sub-trajectories into nested cards. Never mis-render their transient
// model-context work as a durable coordinator transcript compaction.
if ((ev.target || "workstream") === "task_agent") break;
// Context-compaction lifecycle — the shared reducer
// (conversation.applyCompactionEvent) is the one state machine for
// this viewer and the interactive pane, so the two can't drift.
@@ -4000,6 +4096,7 @@ function createCoordinatorPane(root, wsId, opts) {
// transitions we didn't initiate (cross-tab cancel, judge
// reset, idle-after-error). Mirrors the interactive pane.
if (ev.state === "idle" || ev.state === "error") {
setReasoningActivity(currentReasoningEl, false);
setBusy(false);
// Deferred replay_truncated re-sync: the truncation arrived while a
// turn was mid-stream (refetching then would have detached the live
@@ -4149,6 +4246,7 @@ function createCoordinatorPane(root, wsId, opts) {
// Force Stop after 2s. state_change → idle is what actually
// clears busy; the 10s safety timer covers the connection-drop
// case.
setReasoningActivity(currentReasoningEl, false);
if (!busy) break;
clearTimeout(cancelTimeoutId);
clearTimeout(forceTimeoutId);
@@ -6792,6 +6890,7 @@ function createCoordinatorPane(root, wsId, opts) {
});
}
_unsetBatchRunningIfAllResults(occurrence.row.closest(".conv-batch"));
markConvRowResultSettled(occurrence.row, { autoFold: true });
} else {
appendToolResult(
toolName,
@@ -6985,6 +7084,11 @@ function createCoordinatorPane(root, wsId, opts) {
removeVisibilityHandler();
if (_childObserver && _childObserver.disconnect)
_childObserver.disconnect();
unregisterTranscriptScroller();
if (unmountTranscriptPresentation) {
unmountTranscriptPresentation();
unmountTranscriptPresentation = null;
}
}
// Reconnect a DEAD stream NOW (reset backoff), leaving a live one alone —
+578
View File
@@ -0,0 +1,578 @@
"""Lifecycle-agnostic policy, sizing, and recursive summarization.
The two compaction lifecycle owners live in :mod:`turnstone.core.session`:
foreground conversation compaction owns durability/UI/generation commits, while
task-agent compaction owns an ephemeral model context beside a bounded execution
journal. This module contains only the mechanics both lifecycles share.
It deliberately knows nothing about ``ChatSession``, storage, UI protocols, or
which trajectory will receive the resulting summary. Both owners deliberately
use the same conversation-summary contract; lifecycle independence does not
imply owner-specific prompt semantics.
"""
from __future__ import annotations
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from typing import Any
from turnstone.core.model_turn import ModelTurnResult
from turnstone.core.trajectory import Turn, TurnProvenance
class CompactionIrreducibleError(Exception):
"""The recursive summarizer could not shrink an over-window input."""
@dataclass(frozen=True, slots=True)
class SummaryResult:
"""Compacted text plus the identity of its final model turn."""
text: str
producer: str | None
provenance: TurnProvenance = field(default_factory=TurnProvenance)
@dataclass(frozen=True, slots=True)
class CompactionPolicy:
"""Pure soft/hard compaction threshold policy.
``owed`` is the cooperative mid-turn decision: compact immediately above
the hard ceiling, or above the soft threshold after the model has already
received its one wind-down advisory. End-of-turn callers intentionally use
``over_soft`` directly because there is no further cooperative turn to wait
for.
"""
context_window: int
auto_compact_pct: float
def over_soft(self, used: int) -> bool:
return used > self.context_window * self.auto_compact_pct
def over_hard(self, used: int) -> bool:
return used > self.context_window * min(0.95, self.auto_compact_pct + 0.10)
def owed(self, used: int, *, advised: bool) -> bool:
return self.over_hard(used) or (self.over_soft(used) and advised)
MessageMeasure = Callable[[dict[str, Any] | Turn], tuple[int, int, int]]
def calibrated_chars_per_token(
*,
prompt_tokens: int,
messages: Sequence[dict[str, Any] | Turn],
tool_def_chars: int,
measure: MessageMeasure,
fallback: float,
image_tokens: int = 1000,
) -> float:
"""Return a provider-anchored text chars/token ratio.
Images receive a fixed token charge and documents contribute to budgeting
without polluting the text ratio, matching the foreground estimator's
established accounting. If the provider count cannot yield a positive text
denominator, retain ``fallback``.
"""
text_chars = tool_def_chars
image_count = 0
for message in messages:
message_text, images, _document_chars = measure(message)
text_chars += message_text
image_count += images
text_prompt_tokens = prompt_tokens - image_count * image_tokens
if text_prompt_tokens <= 0 or text_chars <= 0:
return fallback
return text_chars / text_prompt_tokens
@dataclass(slots=True)
class PromptTokenEstimator:
"""One immutable-lane trajectory's provider-anchored prompt estimate.
A successful provider call anchors the exact object-identity prefix it saw.
Locally appended turns are estimated with the calibrated ratio. Structural
replacement (compaction) invalidates only the anchor; the learned ratio and
most recently served tool-definition size remain useful.
"""
measure: MessageMeasure
tool_def_chars: int
chars_per_token: float = 4.0
image_tokens: int = 1000
_prompt_tokens: int | None = None
_prefix_ids: tuple[int, ...] = ()
def _message_tokens(self, message: dict[str, Any] | Turn) -> int:
text_chars, images, document_chars = self.measure(message)
text_tokens = int((text_chars + document_chars) / self.chars_per_token)
return max(1, text_tokens + images * self.image_tokens)
def estimate(self, messages: Sequence[dict[str, Any] | Turn]) -> int:
prefix_len = len(self._prefix_ids)
if (
self._prompt_tokens is not None
and prefix_len <= len(messages)
and self._prefix_ids == tuple(id(message) for message in messages[:prefix_len])
):
return self._prompt_tokens + sum(
self._message_tokens(message) for message in messages[prefix_len:]
)
tool_tokens = int(self.tool_def_chars / self.chars_per_token)
return sum(self._message_tokens(message) for message in messages) + tool_tokens
def estimate_with_tool_defs(
self,
messages: Sequence[dict[str, Any] | Turn],
*,
tool_def_chars: int,
) -> int:
"""Estimate an exact request shape with a different tool envelope.
The live provider anchor includes the tool definitions served on that
request. A caller preparing a differently shaped next request (task
turn-limit synthesis uses no tools) must therefore rebase the anchor,
not merely estimate the same messages and ignore the discarded schema.
The calibrated ratio is the only provider-specific conversion available
before that next request is served, so apply the tool-character delta to
either the anchored or standalone estimate.
"""
estimate = self.estimate(messages)
tool_delta = int((tool_def_chars - self.tool_def_chars) / self.chars_per_token)
return max(0, estimate + tool_delta)
def observe(
self,
*,
prompt_tokens: int,
messages: Sequence[dict[str, Any] | Turn],
wire_messages: Sequence[dict[str, Any]] | None = None,
tool_def_chars: int | None = None,
) -> None:
"""Anchor to one successful call before its assistant turn is appended."""
if tool_def_chars is not None:
self.tool_def_chars = tool_def_chars
measured_messages: Sequence[dict[str, Any] | Turn] = (
wire_messages if wire_messages is not None else messages
)
self.chars_per_token = calibrated_chars_per_token(
prompt_tokens=prompt_tokens,
messages=measured_messages,
tool_def_chars=self.tool_def_chars,
measure=self.measure,
fallback=self.chars_per_token,
image_tokens=self.image_tokens,
)
self._prompt_tokens = prompt_tokens
self._prefix_ids = tuple(id(message) for message in messages)
def append_exact(self, message: dict[str, Any] | Turn, tokens: int) -> None:
"""Extend a live provider anchor with one accepted completion turn."""
if self._prompt_tokens is None:
return
self._prompt_tokens += max(1, tokens)
self._prefix_ids += (id(message),)
def invalidate(self) -> None:
self._prompt_tokens = None
self._prefix_ids = ()
def tokens_for(self, messages: Sequence[dict[str, Any] | Turn]) -> int:
"""Estimate a standalone sequence without tool definitions or anchoring."""
return sum(self._message_tokens(message) for message in messages)
SummaryCompletion = Callable[[str, str, int], ModelTurnResult]
def _ignore_progress(_payload: dict[str, Any]) -> None:
return None
@dataclass(frozen=True, slots=True)
class SummaryRuntime:
"""All mutable-world dependencies required by one summary transaction."""
context_window: int
chars_per_token: float
compact_max_tokens: int
lane_max_output_tokens: int | None
continuation_overhead_tokens: int
complete: SummaryCompletion
stop_retrying: Callable[[BaseException, int], bool]
is_context_overflow: Callable[[BaseException], bool]
check_cancelled: Callable[[], None]
backoff_or_cancelled: Callable[[float], None]
on_progress: Callable[[dict[str, Any]], None] = _ignore_progress
max_retries: int = 3
retry_base_delay: float = 1.0
class CompactionEngine:
"""Stateless recursive conversation summarizer."""
SUMMARY_SAFETY_MARGIN = 0.05
SUMMARY_BUDGET_FRACTION = 0.75
MAX_SUMMARY_DEPTH = 5
MIN_SUMMARY_BUDGET_CHARS = 2000
MIN_SUMMARY_OUTPUT_TOKENS = 512
MIN_CARRY_BUDGET_CHARS = 2000
COMPACT_OUTPUT_FORMAT = (
"1. **Output format** — use these exact sections, omit any that are empty:\n"
" - **## Decisions**: Choices made (architecture, libraries, approaches).\n"
" - **## Files**: Files read, created, or modified, with brief notes.\n"
" - **## Key code**: Exact function names, class names, variable names, "
"and short code snippets the assistant will need. "
"Preserve identifiers verbatim — do NOT paraphrase.\n"
" - **## Tool results**: Important tool outputs (errors, search matches, "
"file contents) that inform ongoing work.\n"
" - **## Open tasks**: What the user asked for that is not yet done, "
"with enough context to continue.\n"
" - **## User preferences**: Workflow preferences, constraints, or "
"instructions the user stated.\n"
" - **## Memories to save**: Corrections, preferences, or learnings "
"the user expressed that should be persisted across sessions. "
"Format each as: `name: description — content`. "
"Only include items the user explicitly stated, not inferences.\n\n"
)
COMPACTOR_SYSTEM_PROMPT = (
"# Conversation Compactor\n\n"
"Your output REPLACES the conversation history — the assistant "
"will continue from your summary with no access to the original messages.\n\n"
+ COMPACT_OUTPUT_FORMAT
+ "2. **Density rules:**\n"
" - Every token should carry information.\n"
" - Preserve exact paths, identifiers, and numbers — never paraphrase these.\n"
" - Omit pleasantries, acknowledgments, and reasoning that led to dead ends.\n"
" - If a tool call's result was an error that was later resolved, "
"keep only the resolution.\n\n"
"3. **Common mistakes to avoid:**\n"
" - Paraphrasing file paths, function names, or variable names\n"
" - Including dead-end explorations or superseded decisions\n"
" - Omitting the open tasks section when work remains\n"
" - Being verbose — this is a summary, not a transcript"
)
COMPACTOR_MERGE_SYSTEM_PROMPT = (
"# Summary Merger\n\n"
"You are given several partial summaries of ONE conversation, produced by "
"compacting consecutive slices in order. Merge these partial summaries into a "
"single summary that REPLACES the conversation history — the assistant will "
"continue from your merged summary with no access to the originals.\n\n"
+ COMPACT_OUTPUT_FORMAT
+ "2. **Merge rules:**\n"
" - Preserve every distinct decision, file, identifier, and open task across "
"all partials; later partials reflect more recent state, so on conflict prefer "
"the later one.\n"
" - Deduplicate: fold repeated items into one, keeping the most specific.\n"
" - Preserve exact paths, identifiers, and numbers — never paraphrase these.\n"
" - Be dense; this is a summary, not a transcript."
)
COMPACT_USER_PREFIX = "Compact the following conversation:\n\n"
@staticmethod
def summary_tool_names(messages: Sequence[dict[str, Any]]) -> dict[str, str]:
"""Map tool-call IDs over the full selection, not one packed batch."""
names: dict[str, str] = {}
for message in messages:
for tool_call in message.get("tool_calls", []):
call_id = tool_call.get("id", "")
name = tool_call.get("function", {}).get("name", "unknown")
if call_id:
names[call_id] = name
return names
@staticmethod
def format_message_for_summary(
message: dict[str, Any], tool_names: dict[str, str]
) -> str | None:
role = message["role"].upper()
content = message.get("content") or ""
if isinstance(content, list):
text_parts: list[str] = []
for part in content:
if part.get("type") == "text":
text_parts.append(part["text"])
elif part.get("type") in ("image_url", "image"):
text_parts.append("[image]")
content = " ".join(text_parts)
if message.get("tool_calls"):
calls = []
for tool_call in message["tool_calls"]:
name = tool_call.get("function", {}).get("name", "?")
arguments = tool_call.get("function", {}).get("arguments", "")
calls.append(f"{name}({arguments})")
content += "\n[Called: " + ", ".join(calls) + "]"
if role == "TOOL":
call_id = message.get("tool_call_id", "")
role = f"TOOL[{tool_names.get(call_id, 'tool')}]"
if not content:
return None
if len(content) > 2000:
content = content[:1000] + "\n...[truncated]...\n" + content[-500:]
return f"{role}: {content}"
def summary_blocks(self, messages: Sequence[dict[str, Any]]) -> list[str]:
tool_names = self.summary_tool_names(messages)
return [
line
for message in messages
if (line := self.format_message_for_summary(message, tool_names)) is not None
]
def format_messages_for_summary(self, messages: Sequence[dict[str, Any]]) -> str:
return "\n\n".join(self.summary_blocks(messages))
def summary_output_tokens(self, runtime: SummaryRuntime) -> int:
"""Reserve summary output while leaving at least half the window for input.
``compact_max_tokens`` may itself equal a small model's entire window.
Applying only that setting and the lane output cap would leave no room
for the history being summarized.
"""
hard_cap = (
min(runtime.compact_max_tokens, runtime.lane_max_output_tokens)
if runtime.lane_max_output_tokens
else runtime.compact_max_tokens
)
window_cap = max(self.MIN_SUMMARY_OUTPUT_TOKENS, runtime.context_window // 2)
return min(hard_cap, window_cap)
def carry_budget_chars(self, runtime: SummaryRuntime, carries: int = 1) -> int:
"""Return one verbatim carry's budget after accounting for its siblings.
Foreground compaction can carry a wind-down, the last user request, and
coordinator handles at once; task compaction may carry its wind-down.
Dividing the common spare prevents independently sized carries from
stacking past the post-compaction window. The floor deliberately wins
on pathological tiny windows so some exact state survives and the
provider-overflow backstop can make the final ruling.
"""
reserve = self.summary_output_tokens(runtime)
margin = int(runtime.context_window * self.SUMMARY_SAFETY_MARGIN)
spare = max(
0,
runtime.context_window - reserve - margin - runtime.continuation_overhead_tokens,
)
budget_tokens = min(runtime.context_window // 4, spare // max(1, carries))
return max(self.MIN_CARRY_BUDGET_CHARS, int(budget_tokens * runtime.chars_per_token))
def summary_input_budget_chars(self, runtime: SummaryRuntime) -> int:
"""Bound one summary call's formatted input in calibrated characters.
Reserve output, the fixed compactor prompt, and a safety margin, then
derate the remainder because a reactive path may still have the default
optimistic chars/token ratio. The ordinary minimum is capped at the
true remaining capacity; flooring beyond it would manufacture the very
summary-call overflow this budget prevents.
"""
output_reserve = self.summary_output_tokens(runtime)
prompt_chars = len(self.COMPACTOR_SYSTEM_PROMPT) + len(self.COMPACT_USER_PREFIX)
prompt_tokens = int(prompt_chars / runtime.chars_per_token)
safety = int(runtime.context_window * self.SUMMARY_SAFETY_MARGIN)
input_tokens = runtime.context_window - output_reserve - prompt_tokens - safety
budget_tokens = max(0, int(input_tokens * self.SUMMARY_BUDGET_FRACTION))
budget_chars = max(
self.MIN_SUMMARY_BUDGET_CHARS,
int(budget_tokens * runtime.chars_per_token),
)
return min(budget_chars, max(0, int(input_tokens * runtime.chars_per_token)))
@staticmethod
def truncate_block(block: str, budget: int) -> str:
"""Fit one oversized block head+tail around an honest size marker."""
if len(block) <= budget:
return block
marker = f"\n…[truncated — {len(block):,} chars total]…\n"
if budget <= len(marker):
return block[:budget]
keep = budget - len(marker)
head = (keep * 2) // 3
tail = keep - head
return block[:head] + marker + block[-tail:] if tail else block[:head] + marker
def pack_blocks(self, blocks: Sequence[str], budget_chars: int) -> list[list[str]]:
"""Greedily pack ordered blocks without drops or oversized batches.
``current_len`` exactly tracks the joined size including separators.
A block that cannot fit alone is the sole lossy case and is truncated
explicitly before being placed in its own batch.
"""
budget = max(1, budget_chars)
separator_len = len("\n\n")
batches: list[list[str]] = []
current: list[str] = []
current_len = 0
for block in blocks:
if len(block) > budget:
if current:
batches.append(current)
current = []
current_len = 0
batches.append([self.truncate_block(block, budget)])
continue
added = len(block) + (separator_len if current else 0)
if current and current_len + added > budget:
batches.append(current)
current = [block]
current_len = len(block)
else:
current.append(block)
current_len += added
if current:
batches.append(current)
return batches
def summarize_messages(
self,
messages: Sequence[dict[str, Any]],
runtime: SummaryRuntime,
) -> SummaryResult:
blocks = self.summary_blocks(messages)
if not blocks:
raise CompactionIrreducibleError
return self.summarize_blocks(blocks, runtime)
def summarize_once(
self,
system_prompt: str,
body: str,
runtime: SummaryRuntime,
) -> SummaryResult:
"""Run one complete-or-error summary call with cancellable retries.
Deterministic context overflow escapes immediately for subdivision by
:meth:`summarize_batch`; other retryable failures report their backoff.
The lifecycle owner's cancellation check runs before classification so
a transport closed by Stop is never mislabeled as a summary failure.
"""
result: ModelTurnResult | None = None
for attempt in range(runtime.max_retries + 1):
try:
result = runtime.complete(
system_prompt,
self.COMPACT_USER_PREFIX + body,
self.summary_output_tokens(runtime),
)
break
except Exception as error:
runtime.check_cancelled()
if runtime.stop_retrying(error, attempt):
raise
delay = runtime.retry_base_delay * (2**attempt)
runtime.on_progress(
{
"phase": "progress",
"retry_in": delay,
"error": type(error).__name__,
}
)
runtime.backoff_or_cancelled(delay)
if result is None:
raise RuntimeError("summary retry ladder exhausted without a result")
summary = (result.content or "").strip()
if result.finish_reason == "length":
runtime.on_progress({"phase": "progress", "warning": "summary_truncated"})
return SummaryResult(
text=summary,
producer=result.producer,
provenance=getattr(result, "provenance", TurnProvenance()),
)
def summarize_blocks(
self,
blocks: Sequence[str],
runtime: SummaryRuntime,
*,
depth: int = 0,
) -> SummaryResult:
"""Summarize ordered blocks through packed leaves and recursive merges.
The recursion ceiling is checked before the single-batch base case so
it also bounds overflow-driven split/merge recursion. A block-count
progress guard would be incorrect: binary subdivision can legitimately
leave one summary per input block before the next merge shrinks them.
"""
system_prompt = (
self.COMPACTOR_SYSTEM_PROMPT if depth == 0 else self.COMPACTOR_MERGE_SYSTEM_PROMPT
)
if depth >= self.MAX_SUMMARY_DEPTH:
raise CompactionIrreducibleError
batches = self.pack_blocks(blocks, self.summary_input_budget_chars(runtime))
if len(batches) == 1:
return self.summarize_batch(system_prompt, batches[0], depth, runtime)
total = len(batches)
summaries: list[str] = []
for part, batch in enumerate(batches, start=1):
runtime.on_progress(
{
"phase": "progress",
"part": part,
"total": total,
"depth": depth,
}
)
partial = self.summarize_batch(system_prompt, batch, depth, runtime)
summaries.append(partial.text)
return self.summarize_blocks(summaries, runtime, depth=depth + 1)
def summarize_batch(
self,
system_prompt: str,
batch: Sequence[str],
depth: int,
runtime: SummaryRuntime,
) -> SummaryResult:
"""Summarize one batch, recursively recovering from real overflows.
A multi-block overflow splits in half, summarizes both halves, and
merges them. A lone block progressively halves its head+tail input down
to the configured floor; if even that does not fit, the operation is
irreducible rather than silently dropping the block or fabricating a
summary.
"""
runtime.check_cancelled()
try:
return self.summarize_once(system_prompt, "\n\n".join(batch), runtime)
except Exception as error:
if not runtime.is_context_overflow(error):
raise
if len(batch) > 1:
midpoint = len(batch) // 2
left = self.summarize_batch(system_prompt, batch[:midpoint], depth, runtime)
right = self.summarize_batch(system_prompt, batch[midpoint:], depth, runtime)
return self.summarize_blocks(
[left.text, right.text],
runtime,
depth=depth + 1,
)
budget = max(self.MIN_SUMMARY_BUDGET_CHARS, len(batch[0]) // 2)
while True:
try:
return self.summarize_once(
system_prompt,
self.truncate_block(batch[0], budget),
runtime,
)
except Exception as retry_error:
if not runtime.is_context_overflow(retry_error):
raise
if budget <= self.MIN_SUMMARY_BUDGET_CHARS:
raise CompactionIrreducibleError from retry_error
budget = max(self.MIN_SUMMARY_BUDGET_CHARS, budget // 2)
+10 -2
View File
@@ -474,8 +474,9 @@ def project_history_messages(
:func:`decorate_tool_call`;
- ``reasoning`` passes through (already stamped upstream this
projection NEVER reads ``_provider_content``, which is gone by now);
- tool results: surface ``advisories``, coerce list content to a
string, derive ``denied`` / ``is_error`` from the content prefix;
- tool results: surface ``advisories`` and the typed effect disposition,
coerce list content to a string, derive ``denied`` / ``is_error`` from
the content prefix;
- ``denied`` propagates from a tool result to its parent assistant
turn; ``pending`` marks the last assistant tool-call turn ONLY when
the workstream is genuinely awaiting approval for it
@@ -655,6 +656,13 @@ def project_history_messages(
result_call_id = msg.get("tool_call_id")
if result_call_id:
entry["tool_call_id"] = str(result_call_id)
# Typed effect disposition -> the same top-level field carried by
# accepted tool_result SSE events. Compact presentation uses it to
# keep interrupted/partial/rolled-back outcomes expanded; dropping
# it here made those rows fold only after a page reload.
effect_status = msg.get("_effect_status")
if effect_status:
entry["effect_status"] = str(effect_status)
# Preview-pane descriptor → top-level ``preview``, mirroring the
# live ``tool_result`` SSE event's field so replay renders the
# same reopen chip the live path did.
+11
View File
@@ -138,6 +138,17 @@ NUDGE_COMPACTION_RESUME_NO_RECALL = (
"final answer."
)
# Task-agent trajectories are ephemeral: unlike foreground compaction there is
# no durable transcript or recall surface to promise. The agent keeps its
# immutable delegation prefix plus the compacted summary and must re-read any
# file whose exact contents it needs before editing.
NUDGE_TASK_COMPACTION_RESUME = (
"Your task-agent context was just compacted to free space. Continue from "
"the summary above without waiting for further instructions. Re-read files "
"before editing when you need exact current contents. If the delegated task "
"is complete, provide your final response now."
)
_NUDGE_MAP: dict[str, str] = {
"correction": NUDGE_CORRECTION,
"denial": NUDGE_DENIAL,
+1017 -968
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -2762,6 +2762,19 @@ def make_events_handler(cfg: SessionEndpointConfig) -> Handler:
}
)
}
# A task compaction is transient parent-card state, just
# like its context meter. Re-emit the latest lifecycle edge
# so a refresh during a long recursive summary recreates
# the nested progress card without inventing history.
for agent_compaction in in_progress_snap.get("agent_compactions", []):
yield {
"data": json.dumps(
{
**agent_compaction,
"ws_id": ws_id,
}
)
}
# Surface the persisted ``last_error`` so a fresh
# connect to a workstream sitting in the error state
# shows WHY it failed (the ``error`` text bubble), not
+115 -16
View File
@@ -574,6 +574,11 @@ class SessionUIBase:
# when a provider reuses the public parent call id, and so force-abandon
# can purge every reading owned by the retired generation at once.
self._agent_contexts: dict[str, tuple[int, dict[str, Any]]] = {}
# Latest in-flight compaction lifecycle event for each running task
# agent. Stored beside context usage because both are transient,
# parent-keyed reconnect state and share the same generation cleanup.
# Value: (owner generation, compaction id, event payload).
self._agent_compactions: dict[str, tuple[int, int, dict[str, Any]]] = {}
self._agent_contexts_lock = threading.Lock()
# Recall store: a finished task agent's projected sub-trajectory (step
# items: id/name/arguments/output/is_error), keyed by its (parent)
@@ -1276,8 +1281,8 @@ class SessionUIBase:
self._agent_contexts[parent_call_id] = (generation, event)
self._enqueue(event)
def clear_agent_context(self, parent_call_id: str, *, generation: int = 0) -> None:
"""Drop one completed task agent's reconnect snapshot.
def clear_agent_transients(self, parent_call_id: str, *, generation: int = 0) -> None:
"""Drop one completed task agent's transient reconnect state.
Cleanup is exact-generation: a retiring predecessor whose public call
id was reused cannot remove the successor's active reading.
@@ -1288,9 +1293,12 @@ class SessionUIBase:
current = self._agent_contexts.get(parent_call_id)
if current is not None and current[0] == generation:
del self._agent_contexts[parent_call_id]
compaction = self._agent_compactions.get(parent_call_id)
if compaction is not None and compaction[0] == generation:
del self._agent_compactions[parent_call_id]
def clear_agent_contexts_before_generation(self, generation: int) -> None:
"""Drop every reading owned by a force-abandoned generation.
def clear_agent_transients_before_generation(self, generation: int) -> None:
"""Drop every agent transient owned by a force-abandoned generation.
A force successor may be claimed specifically because the retiring
worker is wedged and will never reach its per-agent ``finally`` block.
@@ -1305,11 +1313,40 @@ class SessionUIBase:
]
for parent_call_id in stale:
del self._agent_contexts[parent_call_id]
stale_compactions = [
parent_call_id
for parent_call_id, (owner_generation, _cid, _event) in (
self._agent_compactions.items()
)
if owner_generation < generation
]
for parent_call_id in stale_compactions:
del self._agent_compactions[parent_call_id]
def _snapshot_agent_transients(
self,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Atomically copy task-agent context and compaction reconnect state."""
with self._agent_contexts_lock:
contexts = [dict(event) for _generation, event in self._agent_contexts.values()]
compactions = [
dict(event)
for _generation, _compaction_id, event in self._agent_compactions.values()
]
return contexts, compactions
def _snapshot_agent_contexts(self) -> list[dict[str, Any]]:
"""Copy active task-agent readings for fresh/truncated SSE replay."""
with self._agent_contexts_lock:
return [dict(event) for _generation, event in self._agent_contexts.values()]
"""Copy active task-agent readings for focused callers/tests."""
contexts, _compactions = self._snapshot_agent_transients()
return contexts
def _snapshot_agent_compactions(self) -> list[dict[str, Any]]:
"""Copy active task-agent compactions for fresh/truncated replay."""
_contexts, compactions = self._snapshot_agent_transients()
return compactions
def on_agent_step(self, parent_call_id: str, item: dict[str, Any]) -> None:
"""Paint a sub-agent's auto-executed tool step as pending under its
@@ -1416,7 +1453,8 @@ class SessionUIBase:
Returns ``(client_queue, snapshot_dict)`` where ``snapshot_dict``
has keys ``content`` (str), ``reasoning`` (str), ``seq`` (int), and
``agent_contexts`` (the active task-agent context events).
``agent_contexts`` (active task-agent context events) and
``agent_compactions`` (their active targeted compaction events).
Caller checks for non-empty content / reasoning to decide
whether to yield the event at all (empty snapshots are common
between turns and on freshly-opened workstreams).
@@ -1434,11 +1472,13 @@ class SessionUIBase:
with self._listeners_lock:
self._register_or_close_listener_locked(client_queue)
snap_seq = self._event_id
agent_contexts, agent_compactions = self._snapshot_agent_transients()
snapshot = {
"content": "".join(captured_content),
"reasoning": "".join(captured_reasoning),
"seq": snap_seq,
"agent_contexts": self._snapshot_agent_contexts(),
"agent_contexts": agent_contexts,
"agent_compactions": agent_compactions,
}
return client_queue, snapshot
@@ -1565,11 +1605,13 @@ class SessionUIBase:
buffered = list(self._event_buffer)
self._register_or_close_listener_locked(client_queue)
snap_seq = self._event_id
agent_contexts, agent_compactions = self._snapshot_agent_transients()
snapshot: dict[str, Any] = {
"content": "".join(captured_content),
"reasoning": "".join(captured_reasoning),
"seq": snap_seq,
"agent_contexts": self._snapshot_agent_contexts(),
"agent_contexts": agent_contexts,
"agent_compactions": agent_compactions,
}
if last_event_id < 0 or last_event_id > snap_seq:
# Event ids start at 1, with cursor 0 reserved for the initial
@@ -4380,10 +4422,13 @@ class SessionUIBase:
keeps a ``/history`` repaint and an SSE replay from double-rendering
the result card.
Inside a task agent the events are dropped (same rule as
:meth:`on_info`): a sub-agent's compaction is progress chatter that
carries no ``call_id``, so it cannot nest under the task card and
must not paint a top-level compaction card on the pane.
``target="workstream"`` owns the transcript card, activity latch, and
durable marker described above. ``target="task_agent"`` instead
carries ``parent_call_id`` and is retained as transient reconnect state
for that task card; it never touches the foreground activity pill or
becomes conversation history. A missing target is interpreted as
``workstream`` so an event from an older node keeps its established
meaning.
``superseded`` (stamped by ``ChatSession._compaction_event``) marks
events from a force-abandoned compaction whose generation a
@@ -4399,10 +4444,18 @@ class SessionUIBase:
writes resume) without restoring its saved pair predates the
successor turn and would overwrite the live pill.
"""
payload = dict(payload)
generation = int(payload.pop("_generation", 0) or 0)
target = str(payload.get("target") or "workstream")
superseded = bool(payload.pop("superseded", False))
if target == "task_agent":
return self._on_task_agent_compaction(
payload,
generation=generation,
superseded=superseded,
)
if _agent_scope_var.get() > 0:
return None
payload = dict(payload)
superseded = bool(payload.pop("superseded", False))
cid = int(payload.get("compaction_id") or 0)
phase = payload.get("phase")
if phase == "end":
@@ -4453,6 +4506,52 @@ class SessionUIBase:
return None
return self._enqueue({"type": "compaction", **payload})
def _on_task_agent_compaction(
self,
payload: dict[str, Any],
*,
generation: int,
superseded: bool,
) -> int | None:
"""Reduce one parent-keyed task compaction and retain its live edge."""
parent_call_id = str(payload.get("parent_call_id") or "")
phase = str(payload.get("phase") or "")
raw_cid = payload.get("compaction_id")
if (
not parent_call_id
or phase not in {"start", "progress", "end"}
or not isinstance(raw_cid, int)
or isinstance(raw_cid, bool)
):
return None
compaction_id = raw_cid
if superseded and phase != "end":
return None
if phase == "end":
payload["superseded"] = superseded
event = {"type": "compaction", **payload}
snapshot_event = {**event, "ws_id": self.ws_id}
with self._agent_contexts_lock:
current = self._agent_compactions.get(parent_call_id)
if phase in {"start", "progress"}:
if current is not None and (
current[0] > generation
or (current[0] == generation and current[1] > compaction_id)
):
return None
# Store before enqueue: a subscriber registering on the next
# instruction sees either this snapshot or this live event.
self._agent_compactions[parent_call_id] = (
generation,
compaction_id,
snapshot_event,
)
elif current is not None and current[:2] == (generation, compaction_id):
del self._agent_compactions[parent_call_id]
return self._enqueue(event)
def _release_compaction_latch_locked(self, *, restore: bool) -> bool:
"""Unlatch the compaction pill window; optionally restore the pair.
+6 -5
View File
@@ -176,8 +176,8 @@ def _build_registry() -> dict[str, SettingDef]:
"Max tokens for compaction summary",
"session",
min_value=0,
help="When conversation history is compacted (summarized to save space), this limits "
"how long the summary can be.",
help="When conversation or task-agent context is compacted (summarized to save "
"space), this limits how long the summary can be.",
),
SettingDef(
"session.auto_compact_pct",
@@ -187,9 +187,10 @@ def _build_registry() -> dict[str, SettingDef]:
"session",
min_value=0.1,
max_value=1.0,
help="Automatically summarize older messages when the conversation fills this percentage "
"of the context window. For example, 0.8 means compact when 80% full. This prevents "
"conversations from hitting the context limit and losing information.",
help="Automatically summarize older messages when a conversation or task-agent context "
"fills this percentage of its model's context window. For example, 0.8 means compact "
"when 80% full. This prevents model calls from hitting the context limit and losing "
"information.",
),
# -- tools ----------------------------------------------------------
SettingDef(
+16 -8
View File
@@ -363,21 +363,29 @@ class CompactionEvent(ServerEvent):
wait); ``end`` carries ``ok`` plus either the result
(``before_tokens``/``after_tokens``/``summary``) or the failure
``reason``/``message`` failure ends carry ``trigger`` too. The
successful end's summary is also persisted as a compaction marker
row and replays from ``/history`` as a ``role="system"``,
``source="compaction"`` entry. ``compaction_id`` correlates every
event of one compaction run (0 from internal/legacy emitters). End
``target`` is ``"workstream"`` when the lifecycle owns the transcript
and durable marker. ``"task_agent"`` events instead carry
``parent_call_id`` and are transient progress for that nested task; their
successful end omits ``summary``. Events without a target are workstream
events because that was the only compaction scope before targeted events
existed. The workstream successful end's summary is also
persisted as a compaction marker row and replays from ``/history`` as a
``role="system"``, ``source="compaction"`` entry. ``compaction_id``
correlates every event of one attempt (0 from internal emitters). End
events additionally carry ``superseded``: True marks a force-abandoned
compaction retiring after a successor generation took over clients
should skip failure notices for those (an OK end's result still
stands; the history swap happened). Failed ends carry ``notice``:
the emitter-computed display verdict show ``message`` only when it
is True, instead of re-deriving suppression from
reason/trigger/superseded client-side.
stands; the history swap happened). Failed ends carry ``notice``: the
emitter-computed display verdict. Workstream errors use their paired typed
error and set it false; task-agent errors have no unscoped error twin and
set it true for the nested card. Clients show ``message`` only when notice
is true instead of re-deriving policy from reason/trigger/superseded.
"""
type: str = "compaction"
phase: str = ""
target: str = "workstream"
parent_call_id: str = ""
compaction_id: int = 0
superseded: bool = False
notice: bool = False
+87
View File
@@ -1208,6 +1208,93 @@
color: var(--ink-3);
font-style: italic;
}
.reasoning-activity-status {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* Viewer-local compact transcript presentation. Scope beneath the registered
transcript root: shell retry/status cards also use .msg and must not inherit
transcript density. */
:root[data-transcript-presentation="compact"] [data-transcript-root] .msg {
padding: 6px 10px;
margin-bottom: 2px;
}
:root[data-transcript-presentation="compact"]
[data-transcript-root]
.msg.reasoning {
display: none;
}
:root[data-transcript-presentation="compact"]
[data-transcript-root]
.msg.reasoning[data-reasoning-active="true"] {
display: inline-flex;
align-items: center;
align-self: flex-start;
width: fit-content;
color: var(--ink-3);
font-style: normal;
}
:root[data-transcript-presentation="compact"]
[data-transcript-root]
.msg.reasoning[data-reasoning-active="true"]
> .msg-body {
display: none;
}
:root[data-transcript-presentation="compact"]
[data-transcript-root]
.msg.reasoning[data-reasoning-active="true"]
> .reasoning-activity-status {
position: static;
display: inline-flex;
align-items: center;
gap: 7px;
width: auto;
height: auto;
padding: 0;
margin: 0;
overflow: visible;
clip: auto;
clip-path: none;
white-space: normal;
border: 0;
font-size: 11px;
}
:root[data-transcript-presentation="compact"]
[data-transcript-root]
.msg.reasoning[data-reasoning-active="true"]
> .reasoning-activity-status::after {
content: "";
width: 10px;
height: 10px;
border: 2px solid var(--hair-2);
border-top-color: var(--accent);
border-radius: 50%;
animation: transcript-reasoning-spin 0.8s linear infinite;
}
@keyframes transcript-reasoning-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
:root[data-transcript-presentation="compact"]
[data-transcript-root]
.msg.reasoning[data-reasoning-active="true"]
> .reasoning-activity-status::after {
animation: none;
}
}
.msg.tool {
background: var(--panel);
/* Cyan for tool-role surfaces shared with .ts-approval borders so
+143
View File
@@ -145,6 +145,119 @@
font-size: 10px;
}
/* Compact transcript presentation. Folding is explicit: structural
eligibility alone only reveals the disclosure; content hides only when JS
has also stamped data-compact-folded after an accepted terminal result or a
manual action. Every ambiguous/actionable/exceptional descendant is a
fail-open exclusion. */
.conv-batch-disclosure {
display: none;
min-height: 28px;
margin: -4px 0 -4px auto;
padding: 0 8px;
border: 1px solid var(--hair);
border-radius: var(--r-sm);
background: var(--panel);
color: var(--ink-2);
font: inherit;
font-size: 10px;
letter-spacing: 0;
text-transform: none;
cursor: pointer;
}
.conv-batch-tier + .conv-batch-disclosure {
margin-left: 0;
}
.conv-batch-disclosure:hover {
border-color: var(--accent-dim);
color: var(--ink);
}
.conv-batch-disclosure:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
:root[data-transcript-presentation="compact"]
[data-transcript-root]
.conv-batch:is(.conv-batch--approved, .conv-batch--auto)[data-results-settled="true"]:not(.conv-batch--pending):not(.conv-batch--running):not(.conv-batch--denied):not(.conv-batch--error):not([aria-busy="true"]):not(
:has(
.conv-actions,
.conv-verdict-spinner,
.conv-warning,
.conv-row.error,
.conv-row-result--error,
.conv-row-status--error,
.conv-status--error,
.conv-batch--pending,
.conv-batch--running,
.conv-row[data-tool-name="task_agent"],
.conv-agent[data-state="running"],
[data-agent-step-exceptional="true"],
.compaction-running,
.conv-agent-compaction-notice,
[data-output-review-incomplete="true"],
[aria-busy="true"],
.conv-verdict--high,
.conv-verdict--critical,
.conv-verdict-rec--deny,
.conv-verdict-rec--review,
> .conv-row[data-effect-status]:not([data-effect-status="committed"])
)
)
> .conv-batch-head
> .conv-batch-disclosure {
display: inline-flex;
align-items: center;
justify-content: center;
}
:root[data-transcript-presentation="compact"]
[data-transcript-root]
.conv-batch:is(.conv-batch--approved, .conv-batch--auto)[data-results-settled="true"][data-compact-folded="true"]:not(.conv-batch--pending):not(.conv-batch--running):not(.conv-batch--denied):not(.conv-batch--error):not([aria-busy="true"]):not(
:has(
.conv-actions,
.conv-verdict-spinner,
.conv-warning,
.conv-row.error,
.conv-row-result--error,
.conv-row-status--error,
.conv-status--error,
.conv-batch--pending,
.conv-batch--running,
.conv-row[data-tool-name="task_agent"],
.conv-agent[data-state="running"],
[data-agent-step-exceptional="true"],
.compaction-running,
.conv-agent-compaction-notice,
[data-output-review-incomplete="true"],
[aria-busy="true"],
.conv-verdict--high,
.conv-verdict--critical,
.conv-verdict-rec--deny,
.conv-verdict-rec--review,
> .conv-row[data-effect-status]:not([data-effect-status="committed"])
)
)
> :not(.conv-batch-head) {
display: none;
}
:root[data-transcript-presentation="compact"]
[data-transcript-root]
.conv-batch {
margin-block: 2px;
}
:root[data-transcript-presentation="compact"]
[data-transcript-root]
.conv-batch-head {
padding: 4px 8px;
}
:root[data-transcript-presentation="compact"]
[data-transcript-root]
.conv-row {
padding-block: 6px;
}
/* Row container. Parallel batches frame rows with a left rail so they
read as siblings of one decision; solo batches suppress it to keep
visual weight low. */
@@ -798,6 +911,21 @@
color: var(--warn);
font-weight: 800;
}
.conv-agent-step-issue {
margin-left: auto;
padding: 1px 6px;
border: 1px solid color-mix(in srgb, var(--warn) 55%, var(--hair-2));
border-radius: 999px;
color: var(--warn);
font-weight: 800;
letter-spacing: 0;
line-height: 1.4;
text-transform: none;
white-space: nowrap;
}
.conv-agent-step-issue[hidden] {
display: none;
}
/* Card-level liveness as a NON-colour cue (WCAG 1.4.1): a text suffix, not just
a stripe in a parallel batch the parent rail belongs to the whole group,
not to this one task agent. */
@@ -816,6 +944,21 @@
.conv-agent-body {
border-top: 1px solid var(--hair);
}
/* Task-agent compaction is transient controller progress, visible even while
* the (potentially huge) step body is collapsed. Reuse the shared progress
* card vocabulary but fit it to the nested card instead of a transcript row. */
.conv-agent > .compaction-card {
margin: 0.35rem 0.55rem 0.5rem;
max-width: none;
}
.conv-agent-compaction-notice {
margin: 0.35rem 0.55rem 0.5rem;
color: var(--warn);
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.45;
}
.conv-agent-body:empty {
border-top: 0; /* no orphan hairline before the first step paints */
}
+364 -19
View File
@@ -127,15 +127,21 @@ export function buildCompactionCard(meta, summary) {
// part-k-of-N progress event flips it determinate via
// updateCompactionProgress. The end event replaces the card with
// buildCompactionCard (or a failure notice).
export function buildCompactionProgressCard(isAuto) {
export function buildCompactionProgressCard(isAuto, target) {
const taskTarget = target === "task_agent";
const el = document.createElement("div");
el.className = "msg compaction-card compaction-running";
el.setAttribute("role", "status");
el.setAttribute("data-ts-role", "compaction");
el.setAttribute("aria-label", "compacting context");
el.setAttribute(
"aria-label",
taskTarget ? "compacting task-agent context" : "compacting context",
);
const header = document.createElement("div");
header.className = "msg-compaction-header";
header.textContent = "compacting context…" + (isAuto ? " · auto" : "");
header.textContent =
(taskTarget ? "compacting task context…" : "compacting context…") +
(isAuto ? " · auto" : "");
el.appendChild(header);
const bar = document.createElement("div");
bar.className = "msg-compaction-bar indeterminate";
@@ -145,7 +151,9 @@ export function buildCompactionProgressCard(isAuto) {
el.appendChild(bar);
const note = document.createElement("div");
note.className = "msg-compaction-note";
note.textContent = "summarizing conversation…";
note.textContent = taskTarget
? "summarizing task-agent context…"
: "summarizing conversation…";
el.appendChild(note);
return el;
}
@@ -205,13 +213,17 @@ export function updateCompactionProgress(el, evt) {
// container — the transcript element to append into
// renderedIds — the pane's rendered-event-id Set (dedups the ok-end
// card against the /history-projected marker row)
// onNotice(msg) — render a non-error failure notice (info styling)
// onNotice(msg) — render a lifecycle failure notice
// scroll(force) — the pane's scroll-to-bottom
// reason="error" ends render NO notice here: the backend pairs them with a
// typed `error` event, which each pane's existing error handler styles red
// (and which feeds the node's error metrics) — emitting here too would show
// the message twice.
// append(node) — optional placement override (task cards insert the
// progress node outside their collapsible step body)
// renderResult — false for transient task-agent compaction; its summary
// is model context, not a transcript result card
// Workstream reason="error" ends carry notice=false because the backend pairs
// them with a typed `error` event. Task-agent errors have no unscoped twin and
// carry notice=true; their pane hook renders the message inside the task card.
export function applyCompactionEvent(holder, evt, hooks) {
const append = hooks.append || ((node) => hooks.container.appendChild(node));
// Lifecycle ownership: events carry the backend's compaction_id and the
// holder remembers which compaction painted the live card, so a stale
// event — a force-abandoned compaction retiring after a successor
@@ -226,9 +238,12 @@ export function applyCompactionEvent(holder, evt, hooks) {
String(evt.compaction_id) === holder.cid;
if (evt.phase === "start") {
if (holder.card) holder.card.remove();
holder.card = buildCompactionProgressCard(evt.trigger === "auto");
holder.card = buildCompactionProgressCard(
evt.trigger === "auto",
evt.target,
);
holder.cid = evt.compaction_id != null ? String(evt.compaction_id) : null;
hooks.container.appendChild(holder.card);
append(holder.card);
hooks.scroll(true);
return;
}
@@ -241,9 +256,9 @@ export function applyCompactionEvent(holder, evt, hooks) {
// cosmetic, and threading trigger through the summarize stack for it
// is disproportionate.
if (!holder.card) {
holder.card = buildCompactionProgressCard(false);
holder.card = buildCompactionProgressCard(false, evt.target);
holder.cid = evt.compaction_id != null ? String(evt.compaction_id) : null;
hooks.container.appendChild(holder.card);
append(holder.card);
}
updateCompactionProgress(holder.card, evt);
hooks.scroll(false);
@@ -251,7 +266,7 @@ export function applyCompactionEvent(holder, evt, hooks) {
}
if (evt.phase === "end") {
if (owns) resetCompactionHolder(holder);
if (evt.ok) {
if (evt.ok && hooks.renderResult !== false) {
// The persisted marker row is stamped with THIS event's id, so
// whichever of /history repaint or live/replayed event renders
// first wins. Rendered even for a non-owning end: a completed
@@ -274,9 +289,10 @@ export function applyCompactionEvent(holder, evt, hooks) {
// cancelled / not_enough_messages / irreducible / empty_summary —
// informational, not an error state. Whether the message is shown
// is the emitter's call: the backend stamps `notice` on failed ends
// (suppressing error-reason / superseded / cancelled-auto ends —
// see ChatSession._compaction_event, the single policy site), so
// this arm stays mechanical. `owns` is the one pane-local clause
// (workstream errors use their typed-error twin; task errors use this
// nested notice; superseded and cancelled-auto ends stay silent — see
// ChatSession._compaction_event, the single policy site), so this arm
// stays mechanical. `owns` is the one pane-local clause
// the emitter cannot compute — card ownership via compaction_id vs
// holder.cid — and it guards a reachable divergence: a /resume
// swaps sessions and restarts generation counters, so an abandoned
@@ -399,6 +415,329 @@ export function indexLabel(idx, n) {
// left indent (a real bug if a caller passes pre-indented text).
// ===========================================================================
const activeReasoningState = new WeakMap();
const OUTPUT_REVIEW_INCOMPLETE_TEXT = "Output review did not complete";
function _reasoningTrace(row) {
let trace = row.querySelector(".msg-body");
if (trace) return trace;
// Normalize the legacy/direct-text shape defensively. Live callers create
// .msg-body themselves, but keeping the shared seam self-contained prevents
// a future renderer from putting token mutations back inside the status.
const text = row.textContent || "";
row.textContent = "";
trace = document.createElement("div");
trace.className = "msg-body";
trace.textContent = text;
row.appendChild(trace);
return trace;
}
// The streamed trace and the model-activity announcement must be separate
// accessibility subtrees. Tokens mutate only the aria-hidden trace while a
// static sibling status announces once; settlement restores the completed
// trace for ordinary, non-live navigation.
export function setReasoningActivity(row, active) {
if (!row) return false;
if (active) {
if (row.dataset.reasoningActive === "true") return false;
const trace = _reasoningTrace(row);
const status = document.createElement("span");
status.className = "reasoning-activity-status";
status.setAttribute("role", "status");
status.setAttribute("aria-live", "polite");
status.setAttribute("aria-atomic", "true");
status.setAttribute("aria-label", "Model reasoning in progress");
status.textContent = "Reasoning";
activeReasoningState.set(row, {
trace,
traceHidden: trace.getAttribute("aria-hidden"),
status,
});
trace.setAttribute("aria-hidden", "true");
row.dataset.reasoningActive = "true";
row.appendChild(status);
return true;
}
if (row.dataset.reasoningActive !== "true") return false;
delete row.dataset.reasoningActive;
const prior = activeReasoningState.get(row) || {};
const trace = prior.trace || row.querySelector(".msg-body");
if (trace) {
if (prior.traceHidden == null) trace.removeAttribute("aria-hidden");
else trace.setAttribute("aria-hidden", prior.traceHidden);
}
const status = prior.status || row.querySelector(".reasoning-activity-status");
if (status) status.remove();
activeReasoningState.delete(row);
return true;
}
const COMPACT_BLOCKER_SELECTOR = [
".conv-actions",
".conv-verdict-spinner",
".conv-warning",
".conv-row.error",
".conv-row-result--error",
".conv-row-status--error",
".conv-status--error",
".conv-batch--pending",
".conv-batch--running",
'.conv-row[data-tool-name="task_agent"]',
'.conv-agent[data-state="running"]',
'[data-agent-step-exceptional="true"]',
".compaction-running",
".conv-agent-compaction-notice",
'[data-output-review-incomplete="true"]',
'[aria-busy="true"]',
".conv-verdict--high",
".conv-verdict--critical",
".conv-verdict-rec--deny",
".conv-verdict-rec--review",
].join(", ");
// A judge-pending spinner is provisional. Once the canonical accepted tool
// result arrives, execution is terminal and that spinner must not remain as a
// permanent compact blocker. A late intent_verdict can still replace/rebuild
// the verdict and re-expand the batch when its disposition is exceptional.
export function clearConvVerdictPending(row) {
if (!row) return false;
let badge = Array.from(row.children || []).find((child) =>
child.classList.contains("conv-verdict"),
);
if (!badge && row.parentNode) {
const siblings = Array.from(row.parentNode.children || []);
const start = siblings.indexOf(row);
for (let index = start + 1; index < siblings.length; index += 1) {
const candidate = siblings[index];
if (candidate.classList.contains("conv-row")) break;
if (candidate.classList.contains("conv-verdict")) {
badge = candidate;
break;
}
}
}
if (!badge) return false;
const spinner = badge.querySelector(".conv-verdict-spinner");
if (!spinner) return false;
spinner.remove();
if (!(badge.children || []).length) badge.remove();
return true;
}
// Cancellation can replace a real executor receipt with controller-authored
// text because output review never completed. Effect disposition is
// orthogonal: even a committed effect remains unresolved for presentation,
// so stamp a dedicated fail-open marker instead of overloading effectStatus.
export function setToolOutputReviewState(row, output) {
if (!row) return false;
const incomplete = String(output == null ? "" : output).includes(
OUTPUT_REVIEW_INCOMPLETE_TEXT,
);
if (incomplete) row.dataset.outputReviewIncomplete = "true";
else delete row.dataset.outputReviewIncomplete;
return incomplete;
}
function _directConvRows(batch) {
return Array.from(batch.children || []).filter((child) =>
child.classList.contains("conv-row"),
);
}
function _convBatchHead(batch) {
return Array.from(batch.children || []).find((child) =>
child.classList.contains("conv-batch-head"),
);
}
function _convBatchDisclosure(batch) {
return batch.querySelector(".conv-batch-disclosure");
}
export function convBatchSummaryText(batch) {
const summary = batch.querySelector(".conv-batch-summary");
const text = summary ? String(summary.textContent || "").trim() : "";
return text || "tool batch";
}
export function isConvVerdictCompactBlocker(verdict) {
if (!verdict) return false;
const risk = normalizeRiskLevel(verdict.risk_level);
const recommendation = verdict.recommendation || "review";
return (
risk === "high" ||
risk === "critical" ||
recommendation !== "approve"
);
}
function _playingBatchMedia(batch) {
return Array.from(batch.querySelectorAll("audio, video")).filter(
(media) => media.paused === false && media.ended !== true,
);
}
function _batchHasProtectedFocus(batch) {
const active = document.activeElement;
if (!active || !batch.contains(active)) return false;
const head = _convBatchHead(batch);
return !head || !head.contains(active);
}
function _focusConvBatchHead(batch) {
const head = _convBatchHead(batch);
if (!head || typeof head.focus !== "function") return;
const temporary = !head.hasAttribute("tabindex");
if (temporary) head.setAttribute("tabindex", "-1");
head.focus({ preventScroll: true });
if (temporary) {
head.addEventListener(
"blur",
() => {
head.removeAttribute("tabindex");
},
{ once: true },
);
}
}
function _syncConvBatchDisclosure(batch, expanded) {
const disclosure = _convBatchDisclosure(batch);
if (!disclosure) return;
const action = expanded ? "Hide" : "Show";
const summary = convBatchSummaryText(batch);
disclosure.setAttribute("aria-expanded", expanded ? "true" : "false");
disclosure.setAttribute(
"aria-label",
action + " completed tool details for " + summary,
);
disclosure.title = action + " completed tool details";
disclosure.textContent = "Completed · " + action + " details";
}
// Structural compact eligibility is independent of the selected presentation:
// Default may stage a fold marker without hiding anything so a later switch to
// Compact is immediate. Every ambiguous or exceptional state fails open.
export function isConvBatchCompactEligible(batch) {
if (!batch || !batch.classList.contains("conv-batch")) return false;
if (batch.dataset.resultsSettled !== "true") return false;
if (
!batch.classList.contains("conv-batch--approved") &&
!batch.classList.contains("conv-batch--auto")
) {
return false;
}
for (const state of [
"conv-batch--pending",
"conv-batch--running",
"conv-batch--denied",
"conv-batch--error",
]) {
if (batch.classList.contains(state)) return false;
}
if (batch.getAttribute("aria-busy") === "true") return false;
const rows = _directConvRows(batch);
if (!rows.length) return false;
for (const row of rows) {
if (row.dataset.resultSettled !== "true") return false;
if (
Object.prototype.hasOwnProperty.call(row.dataset, "effectStatus") &&
row.dataset.effectStatus !== "committed"
) {
return false;
}
}
return !batch.querySelector(COMPACT_BLOCKER_SELECTOR);
}
// The only disclosure-state mutator. Automatic folds preserve focused detail
// and playing media; an explicit fold pauses media first. A late blocker can
// make the disclosure itself inapplicable, so its focused button hands off to
// the surviving batch head before the renderer adds that blocker.
export function setConvBatchExpanded(batch, expanded, options) {
options = options || {};
if (!batch || !batch.classList.contains("conv-batch")) return false;
const disclosure = _convBatchDisclosure(batch);
if (
expanded &&
options.blocker &&
disclosure &&
document.activeElement === disclosure
) {
_focusConvBatchHead(batch);
}
const playing = _playingBatchMedia(batch);
if (!expanded && !options.manual) {
if (_batchHasProtectedFocus(batch) || playing.length) return false;
}
if (!expanded && options.manual) {
for (const media of playing) {
try {
media.pause();
} catch (_) {
// A detached/broken media element must not strand disclosure state.
}
}
}
const wasExpanded = batch.dataset.compactFolded !== "true";
if (expanded) delete batch.dataset.compactFolded;
else batch.dataset.compactFolded = "true";
_syncConvBatchDisclosure(batch, expanded);
return wasExpanded !== expanded;
}
// Project accepted terminal-result truth onto the row/batch DOM. The caller
// must apply effect/error/warning/running state before this finalization step.
export function markConvRowResultSettled(row, options) {
options = options || {};
const batch =
row && row.classList && row.classList.contains("conv-row")
? row.closest(".conv-batch")
: null;
const result = { becameSettled: false, autoFolded: false, batch };
if (!batch) return result;
if ((row.parentElement || row.parentNode) !== batch) return result;
row.dataset.resultSettled = "true";
const rows = _directConvRows(batch);
if (
!rows.length ||
rows.some((candidate) => candidate.dataset.resultSettled !== "true")
) {
if (batch.dataset.resultsSettled === "true") {
delete batch.dataset.resultsSettled;
setConvBatchExpanded(batch, true);
}
return result;
}
if (batch.dataset.resultsSettled === "true") return result;
batch.dataset.resultsSettled = "true";
result.becameSettled = true;
if (options.autoFold !== false && isConvBatchCompactEligible(batch)) {
result.autoFolded = setConvBatchExpanded(batch, false);
}
return result;
}
export function buildConvBatchDisclosure() {
const button = document.createElement("button");
button.type = "button";
button.className = "conv-batch-disclosure";
button.setAttribute("aria-expanded", "true");
button.setAttribute("aria-label", "Hide completed tool details");
button.title = "Hide completed tool details";
button.textContent = "Completed · Hide details";
button.addEventListener("click", () => {
const batch = button.closest(".conv-batch");
if (!batch || !isConvBatchCompactEligible(batch)) return;
const expanded = batch.dataset.compactFolded === "true";
setConvBatchExpanded(batch, expanded, { manual: true });
});
return button;
}
// Empty batch shell (.conv-batch) + header strip (kicker / summary / tier).
// The caller appends rows + actions/status and flips the state modifier
// (--pending/--auto/--running/--approved/--denied/--error). opts:
@@ -432,6 +771,7 @@ export function buildConvBatchShell(opts) {
tier.textContent = opts.tierText;
head.appendChild(tier);
}
head.appendChild(buildConvBatchDisclosure());
batch.appendChild(head);
return batch;
}
@@ -988,7 +1328,12 @@ export function buildAgentCardBody() {
context.className = "conv-agent-context";
context.hidden = true;
context.setAttribute("aria-hidden", "true");
toggle.append(caret, label, context);
const issue = document.createElement("span");
issue.className = "conv-agent-step-issue";
issue.hidden = true;
issue.textContent = "child issue";
issue.title = "Contains an exceptional child step";
toggle.append(caret, label, context, issue);
const body = document.createElement("div");
body.className = "conv-agent-body";
body.id = bodyId;
@@ -998,5 +1343,5 @@ export function buildAgentCardBody() {
toggle.setAttribute("aria-expanded", collapsed ? "true" : "false");
});
wrap.append(toggle, body);
return { wrap, body, label, context, toggle };
return { wrap, body, label, context, issue, toggle };
}
+364 -59
View File
@@ -28,6 +28,7 @@ import {
resetCompactionHolder,
buildSystemNudgeMarker,
buildConvBatchShell,
buildConvBatchDisclosure,
buildConvRow,
buildConvCmd,
buildConvVerdict,
@@ -38,9 +39,22 @@ import {
formatAgentContextTokens,
agentContextIsWarning,
buildPreviewChip,
clearConvVerdictPending,
convBatchSummaryText,
isConvVerdictCompactBlocker,
markConvRowResultSettled,
setReasoningActivity,
setConvBatchExpanded,
setToolOutputReviewState,
batchKicker,
indexLabel,
} from "./conversation.js";
import {
canAutoFoldTranscriptBatch,
getTranscriptPresentation,
preserveTranscriptBottomPin,
registerTranscriptScroller,
} from "./transcript_presentation.js";
import { redactCredentials, tryPrettyJson } from "./redact_credentials.js";
import { tryParseMcpError, buildMcpErrorEmbed } from "./mcp_error.js";
import { authFetch } from "./auth.js";
@@ -408,6 +422,10 @@ class Pane {
// buffers that reading until _relinkAgentCards paints the row. Repeated
// live/synthetic events replace one reading and update one badge.
this._agentContexts = new Map();
// parent call id -> shared-reducer holder for a task agent's transient
// compaction progress. Parallel task agents compact independently, so one
// foreground holder cannot represent them.
this._agentCompactions = new Map();
this._resizeObs = null;
// Set when replay_truncated arrives mid-stream (refetching then would
// detach the live bubble); consumed on the next idle edge. Cleared by
@@ -502,6 +520,7 @@ class Pane {
reset() {
this.currentAssistantEl = null;
setReasoningActivity(this.currentReasoningEl, false);
this.currentReasoningEl = null;
this.contentBuffer = "";
this.setBusy(false);
@@ -759,6 +778,10 @@ class Pane {
}
handleCompactionEvent(evt) {
if ((evt.target || "workstream") === "task_agent") {
this._handleAgentCompaction(evt);
return;
}
// Shared reducer (conversation.applyCompactionEvent) — one lifecycle
// state machine for this pane and the coordinator viewer. The dedup
// set is the same one the system_turn path uses: the persisted marker
@@ -986,6 +1009,10 @@ class Pane {
if (!evt.call_id || evt.risk_level === "none") return;
const toolDiv = this._toolRow(evt.call_id);
if (!toolDiv) return;
const warningBatch = toolDiv.closest(".conv-batch");
if (warningBatch) {
setConvBatchExpanded(warningBatch, true, { blocker: true });
}
// Shared DOM-builder with replayHistory \u2014 single source of truth for
// role / class / escape semantics. Argument shape mirrors the
// server-side output_assessment dict AND the replay payload built by
@@ -1021,33 +1048,36 @@ class Pane {
// path keeps working.
const vRow = this._toolRow(verdict.call_id);
const vScope = (vRow && vRow.closest(".conv-batch")) || this.messagesEl;
const badge = vScope.querySelector(
'.conv-verdict[data-call-id="' + escapedId + '"]',
);
if (!badge) {
// Badge no longer in DOM (tool block replaced by output) — toast the
// late-arriving verdict so the user still sees it.
const conf = Math.round((verdict.confidence || 0) * 100);
const rec = verdict.recommendation || "review";
const func = verdict.func_name || "";
showToast(
"Judge verdict for " + func + ": " + rec + " (" + conf + "%)",
rec === "approve" ? "success" : rec === "deny" ? "error" : "warning",
const verdictBatch = vRow ? vRow.closest(".conv-batch") : null;
preserveTranscriptBottomPin(this.messagesEl, () => {
if (verdictBatch && isConvVerdictCompactBlocker(verdict)) {
setConvBatchExpanded(verdictBatch, true, { blocker: true });
}
const badge = vScope.querySelector(
'.conv-verdict[data-call-id="' + escapedId + '"]',
);
return;
}
// Replace the badge (+ its detail sibling) with a freshly-built one so the
// landed LLM verdict, its risk stripe, and the detail all refresh at once.
const detail = badge.nextElementSibling;
if (detail && detail.classList.contains("conv-verdict-detail")) {
detail.remove();
}
badge.replaceWith(buildConvVerdict(verdict, { judgePending: false }));
if (!badge) {
// Badge no longer in DOM (tool block replaced by output) — toast the
// late-arriving verdict so the user still sees it.
const conf = Math.round((verdict.confidence || 0) * 100);
const rec = verdict.recommendation || "review";
const func = verdict.func_name || "";
showToast(
"Judge verdict for " + func + ": " + rec + " (" + conf + "%)",
rec === "approve" ? "success" : rec === "deny" ? "error" : "warning",
);
return;
}
// Replace the badge (+ its detail sibling) with a freshly-built one so the
// landed LLM verdict, its risk stripe, and the detail all refresh at once.
const detail = badge.nextElementSibling;
if (detail && detail.classList.contains("conv-verdict-detail")) {
detail.remove();
}
badge.replaceWith(buildConvVerdict(verdict, { judgePending: false }));
this.updateVerdictGlow(
verdict.recommendation,
vRow ? vRow.closest(".conv-batch") : null,
);
this.updateVerdictGlow(verdict.recommendation, verdictBatch);
});
}
updateVerdictGlow(recommendation, batchEl) {
@@ -1301,6 +1331,9 @@ class Pane {
this.messagesEl.setAttribute("role", "log");
this.messagesEl.setAttribute("aria-live", "polite");
this.messagesEl.setAttribute("aria-label", "Chat messages");
this._unregisterTranscriptScroller = registerTranscriptScroller(
this.messagesEl,
);
// Track "pinned to bottom" from actual scrolls (user or programmatic)
// instead of reading scroller geometry per event — see isNearBottom().
// Passive: never blocks the compositor thread.
@@ -2218,6 +2251,12 @@ class Pane {
// grace timer would escape buffered steps into the rebuilt pane.
if (this._agentCards) this._agentCards.clear();
if (this._agentContexts) this._agentContexts.clear();
if (this._agentCompactions) {
for (const holder of this._agentCompactions.values()) {
resetCompactionHolder(holder);
}
this._agentCompactions.clear();
}
if (this._agentOrphans) {
for (const entry of this._agentOrphans.values()) {
if (entry.timer != null) clearTimeout(entry.timer);
@@ -2309,18 +2348,26 @@ class Pane {
case "reasoning":
this.removeThinkingIndicator();
let reasoningBody = null;
if (!this.currentReasoningEl) {
this.currentReasoningEl = document.createElement("div");
this.currentReasoningEl.className = "msg reasoning";
reasoningBody = document.createElement("div");
reasoningBody.className = "msg-body";
this.currentReasoningEl.appendChild(reasoningBody);
this.messagesEl.appendChild(this.currentReasoningEl);
} else {
reasoningBody = this.currentReasoningEl.querySelector(".msg-body");
}
this.currentReasoningEl.textContent += evt.text;
setReasoningActivity(this.currentReasoningEl, true);
if (reasoningBody) reasoningBody.textContent += evt.text;
this.scrollToBottom();
break;
case "content":
this.removeThinkingIndicator();
if (this.currentReasoningEl) {
setReasoningActivity(this.currentReasoningEl, false);
this.currentReasoningEl = null;
}
if (!this.currentAssistantEl) {
@@ -2357,6 +2404,7 @@ class Pane {
const doneBuffer = this.contentBuffer;
this.currentAssistantBodyEl = null;
this.currentAssistantEl = null;
setReasoningActivity(this.currentReasoningEl, false);
this.currentReasoningEl = null;
this.contentBuffer = "";
// Finalize the completed streaming segment's markdown. This fires
@@ -2390,14 +2438,27 @@ class Pane {
// streamed view back to a shorter prefix.
this.removeThinkingIndicator();
if (evt.reasoning) {
let snapshotReasoningBody = null;
if (!this.currentReasoningEl) {
this.currentReasoningEl = document.createElement("div");
this.currentReasoningEl.className = "msg reasoning";
snapshotReasoningBody = document.createElement("div");
snapshotReasoningBody.className = "msg-body";
this.currentReasoningEl.appendChild(snapshotReasoningBody);
this.messagesEl.appendChild(this.currentReasoningEl);
} else {
snapshotReasoningBody =
this.currentReasoningEl.querySelector(".msg-body");
}
const curReason = this.currentReasoningEl.textContent || "";
if (curReason.length < evt.reasoning.length) {
this.currentReasoningEl.textContent = evt.reasoning;
setReasoningActivity(this.currentReasoningEl, true);
const curReason = snapshotReasoningBody
? snapshotReasoningBody.textContent || ""
: "";
if (
snapshotReasoningBody &&
curReason.length < evt.reasoning.length
) {
snapshotReasoningBody.textContent = evt.reasoning;
}
}
if (evt.content) {
@@ -2405,6 +2466,7 @@ class Pane {
// the "case content" invariant of clearing currentReasoningEl
// when content begins.
if (this.currentReasoningEl) {
setReasoningActivity(this.currentReasoningEl, false);
this.currentReasoningEl = null;
}
if (!this.currentAssistantEl) {
@@ -2429,6 +2491,7 @@ class Pane {
this._actingUserId = evt.acting_user_id;
}
if (evt.state === "idle" || evt.state === "error") {
setReasoningActivity(this.currentReasoningEl, false);
this.setBusy(false);
// A context event received while its call id still resolved to a
// prior terminal row is retained briefly for a possible successor
@@ -2439,6 +2502,12 @@ class Pane {
this._agentContexts.delete(parentId);
}
}
for (const [parentId, holder] of this._agentCompactions.entries()) {
if (holder.blockedByTerminalRow) {
resetCompactionHolder(holder);
this._agentCompactions.delete(parentId);
}
}
this._attachRetryToLastAssistant();
// Deferred replay_truncated re-sync: the truncation arrived while
// a segment was streaming (refetching then would have detached the
@@ -2702,6 +2771,7 @@ class Pane {
clearTimeout(this._cancelTimeout);
clearTimeout(this._forceTimeout);
this.currentAssistantEl = null;
setReasoningActivity(this.currentReasoningEl, false);
this.currentReasoningEl = null;
this.contentBuffer = "";
this.stopBtn.disabled = true;
@@ -3636,6 +3706,7 @@ class Pane {
// preserves the pane, and preserved DOM keeps valid refs.
this.currentAssistantEl = null;
this.currentAssistantBodyEl = null;
setReasoningActivity(this.currentReasoningEl, false);
this.currentReasoningEl = null;
this.contentBuffer = "";
// In-progress compaction card: the transcript wipe orphaned it; live
@@ -3957,6 +4028,9 @@ class Pane {
);
}
}
if (resultTarget) {
setToolOutputReviewState(resultTarget, msg.content);
}
if (msg.tool_call_id && resultTarget) {
if (msg.effect_status) {
resultTarget.dataset.effectStatus = String(msg.effect_status);
@@ -3978,6 +4052,11 @@ class Pane {
resultAgentWrap.dataset.state = msg.is_error ? "error" : "done";
this._syncAgentToggleLabel(resultAgentWrap);
}
if (resultTarget) {
// Replay is already historical: stage a fold even while Default is
// selected, but never announce completion from the replay loop.
markConvRowResultSettled(resultTarget, { autoFold: true });
}
}
if (msg.event_id != null) {
this._renderedToolEventIds.add(String(msg.event_id));
@@ -4188,7 +4267,11 @@ class Pane {
// place); else build fresh.
const announced = this._takeAnnouncedBlock(items);
const block = announced || document.createElement("div");
if (announced) announced.replaceChildren();
if (announced) {
announced.replaceChildren();
delete announced.dataset.resultsSettled;
delete announced.dataset.compactFolded;
}
block.removeAttribute("aria-busy");
block.className =
"conv-batch " +
@@ -4316,6 +4399,12 @@ class Pane {
});
const statusHost = entry.blockEls[0];
if (statusHost) {
if (!approved) {
this._markAgentStepExceptional(statusHost, {
denied: true,
effectStatus: "none",
});
}
statusHost.appendChild(
buildConvStatus({ approved, always, feedback: feedback || "" }),
);
@@ -4356,6 +4445,115 @@ class Pane {
}
// --- Task-agent card: nest a sub-agent's sub-tool steps under its row -----
_agentTransientRoute(parentId) {
const parentRow = this._toolRow(parentId);
const terminal = this._toolResultNodes.get(parentId);
if (!parentRow || !terminal || terminal.row !== parentRow) {
return { state: "current", row: parentRow };
}
// A fresh/truncated snapshot or live event can race a successor whose
// provider recycled the same public call id. While busy, hold the transient
// away from the earlier terminal occurrence; the next painted row relinks
// it. At idle there can be no successor, so the event is stale.
return this.busy
? { state: "blocked", row: parentRow }
: { state: "drop", row: parentRow };
}
_handleAgentCompaction(evt) {
const parentId =
typeof evt.parent_call_id === "string" ? evt.parent_call_id : "";
if (!parentId) return;
let holder = this._agentCompactions.get(parentId);
const route = this._agentTransientRoute(parentId);
if (route.state === "drop") {
if (holder) resetCompactionHolder(holder);
this._agentCompactions.delete(parentId);
return;
}
if (!holder) {
// A terminal edge with no locally active attempt has no task-context
// result to render. In particular, do not manufacture an empty
// "0 steps · running" task card when a replay begins after start.
if (evt.phase === "end") return;
holder = { card: null, cid: null, pending: null };
this._agentCompactions.set(parentId, holder);
}
const pendingCid =
holder.pending && holder.pending.compaction_id != null
? String(holder.pending.compaction_id)
: null;
const activeCid = holder.cid != null ? holder.cid : pendingCid;
const incomingCid =
evt.compaction_id != null ? String(evt.compaction_id) : null;
if (
evt.phase === "end" &&
activeCid != null &&
incomingCid != null &&
incomingCid !== activeCid
) {
// The shared reducer also rejects this stale end. Return before the
// wrapper's terminal cleanup so it cannot delete the newer holder.
return;
}
holder.pending = evt;
if (route.state === "blocked") {
holder.blockedByTerminalRow = route.row;
if (evt.phase === "end") {
resetCompactionHolder(holder);
this._agentCompactions.delete(parentId);
}
return;
}
delete holder.blockedByTerminalRow;
const card = this._ensureAgentCard(parentId, true);
if (card) this._syncAgentCompaction(parentId, card);
if (evt.phase === "end") {
// _syncAgentCompaction consumes a visible end. If the parent row has not
// painted, there is no live task card to retire or historical result to
// render, so drop the buffered terminal edge too.
resetCompactionHolder(holder);
this._agentCompactions.delete(parentId);
}
}
_syncAgentCompaction(parentId, card) {
const holder = this._agentCompactions.get(parentId);
if (!holder || !holder.pending || holder.blockedByTerminalRow) return;
const evt = holder.pending;
holder.pending = null;
card.wrap.hidden = false;
if (evt.phase === "start") {
const parentBatch = card.wrap.closest(".conv-batch");
if (parentBatch) {
setConvBatchExpanded(parentBatch, true, { blocker: true });
}
const prior = card.wrap.querySelector(".conv-agent-compaction-notice");
if (prior) prior.remove();
}
applyCompactionEvent(holder, evt, {
container: card.wrap,
renderedIds: this._renderedSystemEventIds,
renderResult: false,
// Keep progress visible when the step body is collapsed.
append: (node) => card.wrap.insertBefore(node, card.body),
// Keep a task-local failure inside its card. It has no unscoped typed
// error twin, and escaping it into the workstream transcript would
// misattribute a non-fatal nested controller failure to the parent turn.
onNotice: (message) => {
let notice = card.wrap.querySelector(".conv-agent-compaction-notice");
if (!notice) {
notice = document.createElement("div");
notice.className = "conv-agent-compaction-notice";
notice.setAttribute("role", "status");
card.wrap.insertBefore(notice, card.body);
}
notice.textContent = stripAnsi(message);
},
scroll: (force) => this.scrollToBottom(force),
});
}
// Child events (tool_pending / approve_request) carry parent_call_id; route
// them into the task_agent row's collapsible body. tool_result /
// tool_output_chunk / intent_verdict / output_warning need no special-casing
@@ -4382,6 +4580,10 @@ class Pane {
return false; // parent row not painted yet — fall back top-level
}
if (mode === "approve") {
const parentBatch = card.wrap.closest(".conv-batch");
if (parentBatch) {
setConvBatchExpanded(parentBatch, true, { blocker: true });
}
// Cards default to collapsed, but a pending approval is BLOCKING — it
// can't hide behind the toggle or the turn stalls on a prompt the user
// never sees. Force the card open (sync aria with the data attribute).
@@ -4401,6 +4603,11 @@ class Pane {
row = buildToolDiv(item, "");
card.body.appendChild(row);
}
this._markAgentStepExceptional(row, {
isError: !!(item.is_error || item.error),
denied: !!item.denied,
effectStatus: item.effect_status,
});
if (mode === "approve") {
const verdict =
item.judge_verdict || item.heuristic_verdict || item.verdict;
@@ -4462,14 +4669,22 @@ class Pane {
if (card) {
if (parentRow.contains(card.wrap)) {
this._syncAgentContext(parentCallId, card);
this._syncAgentCompaction(parentCallId, card);
return card;
}
if (!card.wrap.isConnected) {
// Same-turn row rebuild: showInlineToolBlock's replaceChildren on the
// pending->resolved upgrade detached our card. Re-attach the SAME card
// so its already-rendered steps survive the upgrade.
if (card.wrap.dataset.state === "running") {
const parentBatch = parentRow.closest(".conv-batch");
if (parentBatch) {
setConvBatchExpanded(parentBatch, true, { blocker: true });
}
}
parentRow.appendChild(card.wrap);
this._syncAgentContext(parentCallId, card);
this._syncAgentCompaction(parentCallId, card);
return card;
}
// The cached card is still attached to a DIFFERENT (earlier) row — a
@@ -4481,18 +4696,50 @@ class Pane {
card = buildAgentCardBody();
card.wrap.dataset.state = "running";
if (contextOnly) card.wrap.dataset.contextOnly = "true";
const parentBatch = parentRow.closest(".conv-batch");
if (parentBatch) {
setConvBatchExpanded(parentBatch, true, { blocker: true });
}
parentRow.appendChild(card.wrap);
this._agentCards.set(parentCallId, card);
this._syncAgentToggleLabel(card);
this._syncAgentContext(parentCallId, card);
this._syncAgentCompaction(parentCallId, card);
return card;
}
_markAgentStepExceptional(row, details) {
details = details || {};
const effectStatus =
details.effectStatus == null ? "" : String(details.effectStatus);
if (
!details.isError &&
!details.denied &&
!details.exceptional &&
(!effectStatus || effectStatus === "committed")
) {
return false;
}
const card = row && row.closest ? row.closest(".conv-agent") : null;
if (!card) return false;
const parentBatch = card.closest(".conv-batch");
if (parentBatch) {
setConvBatchExpanded(parentBatch, true, { blocker: true });
}
const changed = card.dataset.agentStepExceptional !== "true";
card.dataset.agentStepExceptional = "true";
const issue = card.querySelector(".conv-agent-step-issue");
if (issue) issue.hidden = false;
this._syncAgentToggleLabel(card);
return changed;
}
_syncAgentToggleLabel(card) {
const wrap = card.wrap || card;
const toggle = card.toggle || wrap.querySelector(".conv-agent-toggle");
const label = card.label || wrap.querySelector(".conv-agent-label");
const context = card.context || wrap.querySelector(".conv-agent-context");
const issue = card.issue || wrap.querySelector(".conv-agent-step-issue");
if (!toggle || !label) return;
const parts = ["Show or hide sub-agent steps", label.textContent || "0 steps"];
const state = wrap.dataset.state;
@@ -4506,6 +4753,9 @@ class Pane {
: context.title,
);
}
if (issue && !issue.hidden) {
parts.push("contains an exceptional child step");
}
toggle.setAttribute("aria-label", parts.join(". "));
}
@@ -4554,25 +4804,16 @@ class Pane {
return;
}
// A synthetic fresh-connect snapshot can race the task's terminal result.
// History/replay records which exact latest row already owns a result; do
// not resurrect a running badge on that completed card.
const parentRow = this._toolRow(parentId);
const terminal = this._toolResultNodes.get(parentId);
if (parentRow && terminal && terminal.row === parentRow) {
if (!this.busy) {
this._agentContexts.delete(parentId);
return;
}
// The provider may recycle a parent call id on a later turn. Until that
// successor row paints, _toolRow still resolves the prior terminal row;
// retain the reading without attaching it there. _relinkAgentCards will
// consume it when a different row occurrence arrives. An idle/error edge
// drops it if no successor ever appears.
const route = this._agentTransientRoute(parentId);
if (route.state === "drop") {
this._agentContexts.delete(parentId);
return;
}
if (route.state === "blocked") {
this._agentContexts.set(parentId, {
promptTokens,
contextWindow,
blockedByTerminalRow: parentRow,
blockedByTerminalRow: route.row,
});
return;
}
@@ -4649,11 +4890,31 @@ class Pane {
(items || []).forEach((it) => {
if (!it || !it.call_id) return;
paintedIds.push(it.call_id);
const row = this._toolRow(it.call_id);
const reading = this._agentContexts.get(it.call_id);
const compaction = this._agentCompactions.get(it.call_id);
if (
(reading && reading.blockedByTerminalRow === row) ||
(compaction && compaction.blockedByTerminalRow === row)
) {
return;
}
if (reading && reading.blockedByTerminalRow) {
delete reading.blockedByTerminalRow;
}
if (compaction && compaction.blockedByTerminalRow) {
delete compaction.blockedByTerminalRow;
}
if (
(this._agentCards && this._agentCards.has(it.call_id)) ||
this._agentContexts.has(it.call_id)
this._agentContexts.has(it.call_id) ||
this._agentCompactions.has(it.call_id)
) {
this._ensureAgentCard(it.call_id, this._agentContexts.has(it.call_id));
this._ensureAgentCard(
it.call_id,
this._agentContexts.has(it.call_id) ||
this._agentCompactions.has(it.call_id),
);
}
});
if (paintedIds.length) this._flushAgentOrphans(paintedIds);
@@ -4675,7 +4936,14 @@ class Pane {
// errors don't decide it (an agent can recover and synthesize fine).
const card = buildAgentCardBody();
steps.forEach((step) => {
card.body.appendChild(buildToolDiv(synthToolItem(step), ""));
const stepRow = buildToolDiv(synthToolItem(step), "");
card.body.appendChild(stepRow);
this._markAgentStepExceptional(stepRow, {
isError: !!step.is_error,
denied: !!step.denied,
exceptional: !!step.contains_exceptional,
effectStatus: step.effect_status,
});
const out = stripAnsi(String(step.output || "")).trim();
if (out) {
card.body.appendChild(renderCollapsibleOutput(out, !!step.is_error));
@@ -4715,6 +4983,10 @@ class Pane {
}
}
this._agentContexts.delete(callId);
if (this._agentCompactions && this._agentCompactions.has(callId)) {
resetCompactionHolder(this._agentCompactions.get(callId));
this._agentCompactions.delete(callId);
}
let target = this._toolRow(callId);
if (!target) {
// A minted sub-agent child id ("<parent>::r{run}s{step}::<id>") whose row hasn't
@@ -4735,23 +5007,39 @@ class Pane {
if (!target && tools.length) target = tools[tools.length - 1];
}
if (!target) return false;
const parentBlock = target.closest(".conv-batch");
const stripped = stripAnsi(output || "").trim();
const isDenied =
target.classList.contains("conv-batch--denied") ||
(parentBlock && parentBlock.classList.contains("conv-batch--denied")) ||
/^Denied by user/.test(stripped) ||
/^Blocked/.test(stripped);
this._markAgentStepExceptional(target, {
isError: !!isError,
denied: isDenied,
effectStatus: opts.effectStatus,
});
if (accepted) {
const targetBatch = target.closest(".conv-batch");
clearConvVerdictPending(target);
setToolOutputReviewState(target, output);
// Stop can synthesize a final result after tool_pending but before the
// authoritative tool_info/approval event consumes the early shell. Once
// this exact shell owns an accepted row it is committed transcript DOM,
// not replaceable early paint. Retire map ownership without removing it;
// a later turn may legitimately reuse the same provider call id.
if (targetBatch && this.announcedBlocks) {
if (parentBlock && this.announcedBlocks) {
for (const [key, announced] of this.announcedBlocks.entries()) {
if (announced === targetBatch) {
if (announced === parentBlock) {
this.announcedBlocks.delete(key);
break;
}
}
}
if (opts.effectStatus) {
if (opts.effectStatus !== "committed" && parentBlock) {
setConvBatchExpanded(parentBlock, true, { blocker: true });
}
target.dataset.effectStatus = String(opts.effectStatus);
} else {
delete target.dataset.effectStatus;
@@ -4785,18 +5073,12 @@ class Pane {
this._toolResultNodes.delete(callId);
}
const stripped = stripAnsi(output || "").trim();
// Skip rendering for denied/blocked tool results — the ✗ denied
// badge from resolveApproval already shows the denial reason; the
// SSE tool_result event would otherwise duplicate the text. Mirror
// the guard in the history-replay path (the live path used to be
// safe because no tool_result event was ever emitted for denied
// items, but we now emit one so _tool_error_flags gets set).
const parentBlock = target.closest(".conv-batch");
const isDenied =
(parentBlock && parentBlock.classList.contains("conv-batch--denied")) ||
/^Denied by user/.test(stripped) ||
/^Blocked/.test(stripped);
const resultNodes = [];
let insertCursor = target;
const insertResult = (node) => {
@@ -4838,6 +5120,7 @@ class Pane {
parentBlock &&
!parentBlock.classList.contains("conv-batch--denied")
) {
setConvBatchExpanded(parentBlock, true, { blocker: true });
parentBlock.classList.add("conv-batch--error");
appendToolErrorBadge(parentBlock);
}
@@ -4856,7 +5139,24 @@ class Pane {
if (callId) {
this._toolResultNodes.set(callId, { row: target, nodes: resultNodes });
}
if (resultNodes.length) this.scrollToBottom(stick);
let settlement = null;
if (accepted) {
const compact = getTranscriptPresentation() === "compact";
const allowAutoFold =
!compact ||
canAutoFoldTranscriptBatch(this.messagesEl, parentBlock, {
atBottom: stick,
});
settlement = markConvRowResultSettled(target, {
autoFold: allowAutoFold,
});
if (compact && settlement.autoFolded) {
toolAnnounce("Completed: " + convBatchSummaryText(parentBlock));
}
}
if (resultNodes.length || (settlement && settlement.autoFolded)) {
this.scrollToBottom(stick);
}
return true;
}
@@ -5468,6 +5768,7 @@ function _convApprovalHead(kickerText, items) {
summary.textContent =
items.length >= 2 ? first + " + " + (items.length - 1) + " more" : first;
head.appendChild(summary);
head.appendChild(buildConvBatchDisclosure());
return head;
}
@@ -5870,6 +6171,10 @@ function createInteractivePane(root, wsId, opts) {
pane._resizeObs.disconnect();
pane._resizeObs = null;
}
if (pane._unregisterTranscriptScroller) {
pane._unregisterTranscriptScroller();
pane._unregisterTranscriptScroller = null;
}
if (pane.el && pane.el.parentNode) {
pane.el.parentNode.removeChild(pane.el);
}
+11 -1
View File
@@ -422,6 +422,16 @@
border-color: var(--accent-dim);
color: var(--ink);
}
.rail-foot .transcript-presentation-toggle[aria-pressed="true"] {
border-color: var(--accent-dim);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, var(--panel-2));
}
.transcript-presentation-glyph {
font-size: 16px;
line-height: 1;
transform: scaleX(1.1);
}
/* The user chip is a menu button (click Log out); reset native button
chrome so it reads as the rail's identity line, not a form control. */
.user-chip {
@@ -1407,7 +1417,7 @@
.app.rail-collapsed .proj-grp:not(.open) .proj-grp-items {
display: block;
}
/* footer stacks: theme toggle above the avatar-only user chip */
/* footer stacks: presentation + theme toggles above the avatar-only chip */
.app.rail-collapsed .rail-foot {
flex-direction: column;
gap: 6px;
+5 -2
View File
@@ -34,6 +34,7 @@ import { authFetch } from "./auth.js";
// would 404 and abort the whole shell module.
import { createInteractivePane } from "./interactive.js";
import { createPreviewPane } from "./preview.js";
import { mountTranscriptPresentationToggle } from "./transcript_presentation.js";
function make(tag, className, text) {
const node = document.createElement(tag);
@@ -515,11 +516,13 @@ async function mountShell() {
shell.connSlot.replaceWith(statusBarEl);
}
// Relocate ONLY the theme toggle into the rail footer (id + onclick
// preserved), then retire the now-empty header. The Admin button is dropped
// Mount the viewer-local transcript toggle, then relocate ONLY the legacy
// theme control into the rail footer (id + onclick preserved) and retire the
// now-empty header. The Admin button is dropped
// — Manage already surfaces every admin tab, so a separate footer button is
// redundant. Logout moves into the user menu (the #logout-btn stays in the
// hidden header for its wired onclick + auth.js race-guards; the menu clicks it).
mountTranscriptPresentationToggle(shell.foot, { className: "ico-btn" });
const themeBtn = document.getElementById("theme-toggle");
if (themeBtn) shell.foot.append(themeBtn);
@@ -0,0 +1,350 @@
/* Shared viewer-local transcript presentation preference.
This module owns only browser presentation state. It does not fetch,
mutate history, or write Turnstone settings. Both conversational panes
register their message scroller; the L-shell and standalone coordinator
mount the same two-state control. */
import { setConvBatchExpanded } from "./conversation.js";
const STORAGE_KEY = "turnstone_interface.transcript_presentation";
const MODE_ATTRIBUTE = "data-transcript-presentation";
const TRANSCRIPT_ROOT_ATTRIBUTE = "data-transcript-root";
const BOTTOM_THRESHOLD_PX = 48;
const controls = new Set();
const scrollers = new Map();
function normalizeMode(value) {
return value === "compact" ? "compact" : "default";
}
function readStoredMode() {
try {
return normalizeMode(localStorage.getItem(STORAGE_KEY));
} catch (_) {
return "default";
}
}
let currentMode = readStoredMode();
function applyRootMode(mode) {
const root = document.documentElement;
if (mode === "compact") root.setAttribute(MODE_ATTRIBUTE, "compact");
else root.removeAttribute(MODE_ATTRIBUTE);
}
function syncControl(button) {
const compact = currentMode === "compact";
button.setAttribute("aria-pressed", compact ? "true" : "false");
button.title = compact
? "Switch to default ledger presentation"
: "Switch to compact ledger presentation";
button.classList.toggle("is-compact", compact);
}
function syncControls() {
for (const button of controls) {
if (!button.isConnected) {
controls.delete(button);
continue;
}
syncControl(button);
}
}
function isVisibleScroller(scroller) {
if (!scroller || !scroller.isConnected) return false;
if (typeof scroller.getClientRects !== "function") return true;
return scroller.getClientRects().length > 0;
}
function scrollerIsAtBottom(scroller) {
const distance =
scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
return distance <= BOTTOM_THRESHOLD_PX;
}
function releaseScroller(scroller, state, removeMarker) {
if (scrollers.get(scroller) !== state) return;
scrollers.delete(scroller);
scroller.removeEventListener("scroll", state.onScroll);
if (state.resizeObserver) state.resizeObserver.disconnect();
if (removeMarker) scroller.removeAttribute(TRANSCRIPT_ROOT_ATTRIBUTE);
}
function refreshScrollerFollowState(state) {
const scroller = state.scroller;
if (!isVisibleScroller(scroller)) return;
if (state.pendingBottomRestoreRevision != null) {
const restoreRevision = state.pendingBottomRestoreRevision;
state.pendingBottomRestoreRevision = null;
if (restoreRevision !== state.scrollRevision) {
state.atBottom = scrollerIsAtBottom(scroller);
return;
}
scroller.scrollTop = scroller.scrollHeight;
state.atBottom = true;
return;
}
state.atBottom = scrollerIsAtBottom(scroller);
}
function isPlaying(media) {
return media.paused === false && media.ended !== true;
}
function batchHead(batch) {
return Array.from(batch.children || []).find((child) =>
child.classList.contains("conv-batch-head"),
);
}
function focusBatchHead(batch) {
const head = batchHead(batch);
if (!head || typeof head.focus !== "function") return;
const temporary = !head.hasAttribute("tabindex");
if (temporary) head.setAttribute("tabindex", "-1");
head.focus({ preventScroll: true });
if (temporary) {
head.addEventListener(
"blur",
() => {
head.removeAttribute("tabindex");
},
{ once: true },
);
}
}
function prepareFocusForMode(mode) {
const active = document.activeElement;
if (mode === "default" && active) {
const disclosure = active.closest
? active.closest(".conv-batch-disclosure")
: null;
const batch = disclosure ? disclosure.closest(".conv-batch") : null;
if (batch) focusBatchHead(batch);
return;
}
if (mode !== "compact") return;
for (const scroller of scrollers.keys()) {
if (!scroller.isConnected) continue;
if (active && scroller.contains(active)) {
const batch = active.closest ? active.closest(".conv-batch") : null;
const head = batch ? batchHead(batch) : null;
if (batch && (!head || !head.contains(active))) {
setConvBatchExpanded(batch, true);
}
}
for (const media of scroller.querySelectorAll("audio, video")) {
if (!isPlaying(media)) continue;
const batch = media.closest(".conv-batch");
if (batch) setConvBatchExpanded(batch, true);
}
}
}
function captureScrollers() {
const snapshots = [];
for (const [scroller, state] of scrollers) {
if (!scroller.isConnected) {
releaseScroller(scroller, state, false);
continue;
}
if (isVisibleScroller(scroller)) {
state.atBottom = scrollerIsAtBottom(scroller);
}
snapshots.push({
scroller,
state,
atBottom: state.atBottom,
scrollRevision: state.scrollRevision,
});
}
return snapshots;
}
function restoreBottomPins(snapshots) {
const schedule =
typeof requestAnimationFrame === "function"
? requestAnimationFrame
: (callback) => callback();
schedule(() => {
for (const snapshot of snapshots) {
if (
!snapshot.atBottom ||
!snapshot.scroller.isConnected ||
scrollers.get(snapshot.scroller) !== snapshot.state ||
snapshot.state.scrollRevision !== snapshot.scrollRevision
) {
continue;
}
if (!isVisibleScroller(snapshot.scroller)) {
snapshot.state.pendingBottomRestoreRevision = snapshot.scrollRevision;
continue;
}
snapshot.state.pendingBottomRestoreRevision = null;
snapshot.scroller.scrollTop = snapshot.scroller.scrollHeight;
snapshot.state.atBottom = true;
}
});
}
applyRootMode(currentMode);
export function getTranscriptPresentation() {
return currentMode;
}
// Decide whether a newly-settled live batch may fold without changing the
// user's viewport. Visible panes use the caller's pre-mutation follow snapshot
// plus real geometry. Hidden panes have no meaningful rectangles, so only the
// registered last-visible follow state may authorize a fold.
export function canAutoFoldTranscriptBatch(scroller, batch, options) {
options = options || {};
const state = scrollers.get(scroller);
if (!state || !scroller.isConnected) return false;
if (!isVisibleScroller(scroller)) return state.atBottom;
const atBottom = Object.hasOwn(options, "atBottom")
? options.atBottom === true
: state.atBottom;
if (atBottom) return true;
if (
!batch ||
!batch.isConnected ||
!isVisibleScroller(batch) ||
typeof batch.getBoundingClientRect !== "function" ||
typeof scroller.getBoundingClientRect !== "function"
) {
return false;
}
const batchRect = batch.getBoundingClientRect();
const scrollerRect = scroller.getBoundingClientRect();
return batchRect.top >= scrollerRect.bottom;
}
export function setTranscriptPresentation(mode, options) {
options = options || {};
const next = normalizeMode(mode);
if (next !== currentMode) {
const snapshots = captureScrollers();
prepareFocusForMode(next);
currentMode = next;
applyRootMode(currentMode);
syncControls();
restoreBottomPins(snapshots);
}
if (options.persist !== false) {
try {
localStorage.setItem(STORAGE_KEY, currentMode);
} catch (_) {
// The current page still changes when storage is unavailable.
}
}
return currentMode;
}
export function mountTranscriptPresentationToggle(container, options) {
options = options || {};
const button = document.createElement("button");
button.type = "button";
button.className =
"transcript-presentation-toggle" +
(options.className ? " " + options.className : "");
button.setAttribute("aria-label", "Compact ledger presentation");
button.setAttribute(
"aria-description",
"Compact hides model reasoning and folds completed successful tool details.",
);
const glyph = document.createElement("span");
glyph.className = "transcript-presentation-glyph";
glyph.setAttribute("aria-hidden", "true");
glyph.textContent = "≡";
button.appendChild(glyph);
const onClick = () => {
setTranscriptPresentation(
currentMode === "compact" ? "default" : "compact",
);
};
button.addEventListener("click", onClick);
container.appendChild(button);
controls.add(button);
syncControl(button);
return () => {
controls.delete(button);
button.removeEventListener("click", onClick);
button.remove();
};
}
// Preserve follow state across a synchronous transcript reflow (for example,
// reopening a folded batch when a late exceptional verdict lands). The
// registered scroller owns the cached hidden-pane state; visible panes are
// remeasured immediately before mutation so users who scrolled away are never
// pulled back. Restoration is deferred until layout reflects the mutation.
export function preserveTranscriptBottomPin(element, mutate) {
if (typeof mutate !== "function") return undefined;
const state = scrollers.get(element);
let snapshot = null;
if (state && element.isConnected) {
if (isVisibleScroller(element)) {
state.atBottom = scrollerIsAtBottom(element);
}
snapshot = {
scroller: element,
state,
atBottom: state.atBottom,
scrollRevision: state.scrollRevision,
};
}
try {
return mutate();
} finally {
if (snapshot) restoreBottomPins([snapshot]);
}
}
export function registerTranscriptScroller(element) {
if (!element) return () => {};
const prior = scrollers.get(element);
if (prior) releaseScroller(element, prior, false);
element.setAttribute(TRANSCRIPT_ROOT_ATTRIBUTE, "");
const state = {
scroller: element,
atBottom: isVisibleScroller(element)
? scrollerIsAtBottom(element)
: true,
pendingBottomRestoreRevision: null,
scrollRevision: 0,
onScroll: null,
resizeObserver: null,
};
state.onScroll = () => {
state.scrollRevision += 1;
state.pendingBottomRestoreRevision = null;
if (isVisibleScroller(element)) {
state.atBottom = scrollerIsAtBottom(element);
}
};
element.addEventListener("scroll", state.onScroll, { passive: true });
if (typeof ResizeObserver === "function") {
state.resizeObserver = new ResizeObserver(() => {
refreshScrollerFollowState(state);
});
state.resizeObserver.observe(element);
}
scrollers.set(element, state);
return () => {
releaseScroller(element, state, true);
};
}
if (typeof window !== "undefined" && window.addEventListener) {
window.addEventListener("storage", (event) => {
if (event.key !== STORAGE_KEY && event.key !== null) return;
setTranscriptPresentation(event.newValue, { persist: false });
});
}
Generated
+1 -1
View File
@@ -2671,7 +2671,7 @@ requires-dist = [
{ name = "aiohttp", marker = "extra == 'test'", specifier = ">=3.9" },
{ name = "alembic", specifier = ">=1.14" },
{ name = "altair", specifier = ">=6.0" },
{ name = "anthropic", specifier = ">=0.117" },
{ name = "anthropic", specifier = ">=0.117,<1" },
{ name = "bcrypt", specifier = ">=4.0" },
{ name = "croniter", specifier = ">=3.0" },
{ name = "cryptography", specifier = ">=48.0.1" },