mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-28 06:44:51 -06:00
Merge pull request #1051 from turnstonelabs/main
Forward merge of the stable/1.8 changes into dev branch.
This commit is contained in:
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]] = [
|
||||
{
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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 —
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user