From 6f993a62bb68a42e9d1ef34f0c60338cf60c97da Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 22:07:48 +0200 Subject: [PATCH 01/19] test(engine): the scripted Claude reports its running tasks stopped as Stop closes the CLI ClaudeAdapter's stopSessionInternal reports every live task stopped (with its linkage) before the session exits; the scripted provider dropped them, so a person's Stop met work with no report of its own. --- .../engine/testing/pump/scriptedProvider.ts | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/server/src/engine/testing/pump/scriptedProvider.ts b/apps/server/src/engine/testing/pump/scriptedProvider.ts index cba5b5aa0..76f2d4a26 100644 --- a/apps/server/src/engine/testing/pump/scriptedProvider.ts +++ b/apps/server/src/engine/testing/pump/scriptedProvider.ts @@ -113,7 +113,37 @@ export const makeScriptedProvider = (options: ScriptedProviderOptions) => let items = 0; let tasks = 0; + /** + * The tasks each thread has running, by id, with the linkage every report of them repeats + * (`taskLinkageFor`): Claude's Stop reports each of them stopped as it closes the CLI. + */ + const liveTasks = new Map>>(); + const trackTask = (type: string, thread: string, fields: Record) => { + const payload = (fields.payload ?? {}) as Record; + const taskId = payload.taskId; + if (typeof taskId !== "string") return; + const live = liveTasks.get(thread) ?? new Map>(); + liveTasks.set(thread, live); + const status = payload.status; + if (type === "task.started") { + const { description: _description, prompt: _prompt, ...linkage } = payload; + live.set(taskId, linkage); + } else if ( + type === "task.completed" || + (type === "task.updated" && + (status === "completed" || status === "failed" || status === "cancelled")) + ) { + live.delete(taskId); + } + }; + const emit = (type: string, thread: string, fields: Record = {}) => + Effect.suspend(() => { + trackTask(type, thread, fields); + return publish(type, thread, fields); + }); + + const publish = (type: string, thread: string, fields: Record = {}) => PubSub.publish(pubsub, { sequence: ++published, event: { @@ -176,11 +206,29 @@ export const makeScriptedProvider = (options: ScriptedProviderOptions) => } }); + /** + * Claude's Stop closes the CLI: each task still running is reported stopped first, with its + * linkage (`ClaudeAdapter` `stopSessionInternal`). + */ + const stopTasks = (session: Session) => + Effect.gen(function* () { + const live = liveTasks.get(session.thread); + if (live === undefined) return; + for (const [taskId, linkage] of live) { + live.delete(taskId); + yield* publish("task.completed", session.thread, { + payload: { ...linkage, taskId, status: "stopped" }, + }); + } + }); + /** The session dies: a held send fails with these words. */ const die = (session: Session, words: string) => Effect.gen(function* () { session.alive = false; session.open = null; + // Its process is gone, and every task with it: nothing reports them now. + liveTasks.delete(session.thread); if (session.held !== null) { yield* Deferred.fail( session.held, @@ -261,8 +309,10 @@ export const makeScriptedProvider = (options: ScriptedProviderOptions) => if (driver === "codex" && input.turnId === undefined) return; switch (driver) { case "claudeAgent": - // A Stop kills the CLI: the turn ends interrupted and the session exits with it. + // A Stop kills the CLI: the turn ends interrupted, its tasks are reported stopped, + // and the session exits with it. yield* endTurn(session, { state: "interrupted", errorMessage: "Session stopped." }); + yield* stopTasks(session); yield* die(session, "Session stopped."); yield* emit("session.exited", session.thread, { payload: { exitKind: "graceful" } }); return; @@ -321,6 +371,7 @@ export const makeScriptedProvider = (options: ScriptedProviderOptions) => if (session === undefined || !session.alive) return; if (driver === "claudeAgent") { yield* endTurn(session, { state: "interrupted", errorMessage: "Session stopped." }); + yield* stopTasks(session); yield* die(session, "Session stopped."); yield* emit("session.exited", session.thread, { payload: { exitKind: "graceful" } }); return; From 100f8754f2c15e191bf2d3cbe02ef370e4c5ef8d Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 22:07:57 +0200 Subject: [PATCH 02/19] fix(engine): background work keeps whose it is, the call that started it and how it ended The run-card oracle (milo stress runs 2 and 3) found the record losing what the card needs to tell a helper's work from the Mate's: - a task a helper's tool started (Claude's owning agentId, Codex's parentAgentId) is recorded as that helper's and goes on under the run its helper served; a helper's own call goes there too, never onto the next message's run, which it made a card of its own after a reload; - the work names the call that started it (its tool use), and a closed card's outcome read carries that call, so its line of jobs reads the same after a reload; - its end keeps its report's first line ("failed with exit code 3"); - work a Stop closed with Claude's session is stopped, not lost: Claude reports its tasks only after the turn's end, from a session already closed. --- apps/server/src/engine/bridge/spi3.ts | 6 + apps/server/src/engine/bridge/translate.ts | 72 ++++++- apps/server/src/engine/domain/command.ts | 6 + apps/server/src/engine/domain/decide.ts | 14 +- apps/server/src/engine/engine.pump.test.ts | 197 ++++++++++++++++++ apps/server/src/engine/pump/toCore.ts | 3 + .../src/engine/testing/pump/engineWorld.ts | 6 +- .../server/src/engine/wire/EngineWire.test.ts | 53 +++++ apps/server/src/engine/wire/records.ts | 9 +- packages/contracts/src/engine.ts | 4 + packages/contracts/src/engineWire.ts | 3 +- 11 files changed, 361 insertions(+), 12 deletions(-) diff --git a/apps/server/src/engine/bridge/spi3.ts b/apps/server/src/engine/bridge/spi3.ts index e829ca107..1aa8f92c4 100644 --- a/apps/server/src/engine/bridge/spi3.ts +++ b/apps/server/src/engine/bridge/spi3.ts @@ -295,6 +295,12 @@ export type SignalBody = readonly kind: WorkKind; readonly status: WorkStatus; readonly title?: string; + /** The helper whose own tool started it; absent: the Mate's. */ + readonly helper?: WorkKey; + /** The call that started it. */ + readonly call?: ItemKey; + /** What it said as it ended, in a line. */ + readonly report?: string; } // requests | { diff --git a/apps/server/src/engine/bridge/translate.ts b/apps/server/src/engine/bridge/translate.ts index b8216ce09..770e3a777 100644 --- a/apps/server/src/engine/bridge/translate.ts +++ b/apps/server/src/engine/bridge/translate.ts @@ -16,9 +16,10 @@ * - every item, request and piece of work has an app key, never a native id; * - a late item never reopens a turn: it carries `afterEnd`; * - an open call is closed `unreturned` at its turn's end, an open request - * `superseded`, and at a session's close every live work is `lost` and every - * open request `expired` — except a question asked by message, which no - * callback waits on: a person's message answers it after its turn. + * `superseded`, and at a session's close every live work is `lost` (`stopped` + * when a Stop closed it) and every open request `expired` — except a question + * asked by message, which no callback waits on: a person's message answers it + * after its turn. * * Its state lives in memory: a host restart kills every driver process, so the * engine's own boot ends what was open and a new session starts a new fold. @@ -156,6 +157,12 @@ interface WorkState { kind?: WorkKind; status?: WorkStatus; title?: string | undefined; + /** The helper whose own tool started it; none: the Mate's. */ + helper?: WorkKey; + /** The call that started it: the command sent to the background, the helper's launch. */ + call?: ItemKey; + /** What it said as it ended, in a line. */ + report?: string; } interface RequestState { @@ -423,9 +430,11 @@ export function makeTranslator(options: TranslatorOptions): Translator { undelivered: "unknown", }); } + // A Stop that closed the session stopped what it ran: that work was stopped, not lost (Claude + // reports its tasks stopped only after the turn's end, from a session already closed). for (const work of owner.work.values()) { if (work.status !== undefined && isLiveWork(work.status)) { - work.status = "lost"; + work.status = cause === "stopped-turn" ? "stopped" : "lost"; emitWork(owner, work); } } @@ -516,6 +525,9 @@ export function makeTranslator(options: TranslatorOptions): Translator { kind: work.kind ?? "other", status: work.status ?? "running", ...(work.title === undefined ? {} : { title: work.title }), + ...(work.helper === undefined ? {} : { helper: work.helper }), + ...(work.call === undefined ? {} : { call: work.call }), + ...(work.report === undefined ? {} : { report: work.report }), }); }; @@ -532,7 +544,13 @@ export function makeTranslator(options: TranslatorOptions): Translator { const nativeId = event.itemId === undefined ? undefined : String(event.itemId); let item = nativeId === undefined ? undefined : owner.items.get(nativeId); if (item === undefined) { - const turn = turnOf(owner, event); + // A helper's own call goes on under the turn that started its helper, whatever turn is open + // when it comes: it is that card's, never the next message's. + const launched = payload.agentId ?? payload.parentToolUseId; + const helper = launched === undefined ? undefined : owner.work.get(launched); + const helperTurn = + helper === undefined || helper.origin === "unknown" ? undefined : turns.get(helper.origin); + const turn = helperTurn ?? turnOf(owner, event); if (turn === undefined) return; item = newItem(turn, itemBody(event)); if (nativeId !== undefined) owner.items.set(nativeId, item); @@ -757,11 +775,43 @@ export function makeTranslator(options: TranslatorOptions): Translator { return; } const work = workFor(owner, nativeIds); - const before = JSON.stringify([work.kind, work.status, work.title, work.origin]); + const facts = () => + JSON.stringify([ + work.kind, + work.status, + work.title, + work.origin, + work.helper, + work.call, + work.report, + ]); + const before = facts(); + // Whose it is: the helper whose own tool started it, as each report of it repeats + // (Claude's `agentId`, Codex's `parentAgentId` of a nested helper). It is that helper's, + // and goes on under the run its helper served (Milo's second stress run drew a helper's + // sleep as the Mate's, and a nested helper among the Mate's own). + const owning = + payload.agentId ?? ("parentAgentId" in payload ? payload.parentAgentId : undefined); + if (owning !== undefined && work.helper === undefined) { + const helper = workFor(owner, [owning]); + if (helper !== work) { + work.helper = helper.key; + if (helper.origin !== "unknown") work.origin = helper.origin; + } + } if (work.origin === "unknown" && event.turnId !== undefined) { const origin = owner.nativeTurns.get(String(event.turnId)); if (origin !== undefined) work.origin = origin.handle; } + // The call that started it, by the tool use the task names. + if (work.call === undefined && payload.toolUseId !== undefined) { + const started = owner.items.get(payload.toolUseId); + if (started !== undefined) work.call = started.key; + } + if (event.type === "task.completed" && event.payload.summary !== undefined) { + const line = reportLine(event.payload.summary); + if (line !== undefined) work.report = line; + } if ( work.kind === undefined || payload.agentKind !== undefined || @@ -777,7 +827,7 @@ export function makeTranslator(options: TranslatorOptions): Translator { payload.title ?? work.title; work.status = taskStatus(event, work.status); - if (JSON.stringify([work.kind, work.status, work.title, work.origin]) !== before) { + if (facts() !== before) { emitWork(owner, work); } return; @@ -1262,6 +1312,14 @@ function itemStatus( return payload.status === "completed" ? "completed" : "running"; } +/** What a report says first, in a line a row can hold: "… failed with exit code 3". */ +const REPORT_LINE = 280; +function reportLine(summary: string): string | undefined { + const first = summary.trim().split("\n")[0]?.trim() ?? ""; + if (first === "") return undefined; + return first.length <= REPORT_LINE ? first : `${first.slice(0, REPORT_LINE - 1)}…`; +} + function workKind( stamped: "agent" | "background" | undefined, taskType: string | undefined, diff --git a/apps/server/src/engine/domain/command.ts b/apps/server/src/engine/domain/command.ts index c35a7d1aa..1a1ab742d 100644 --- a/apps/server/src/engine/domain/command.ts +++ b/apps/server/src/engine/domain/command.ts @@ -135,6 +135,12 @@ export type ProviderSignal = readonly workKind: Extract["workKind"]; readonly status: Extract["status"]; readonly title?: string; + /** The helper (its work's key) whose own tool started it; absent: the Mate's. */ + readonly helper?: string; + /** The key of the call that started it. */ + readonly call?: string; + /** What it said as it ended, in a line. */ + readonly report?: string; } /** A limit whose reset was unknown learned its reset time (the driver's rate-limit report). */ | { readonly kind: "usage-reset-known"; readonly resetsAt: number } diff --git a/apps/server/src/engine/domain/decide.ts b/apps/server/src/engine/domain/decide.ts index 94a6d68c8..8648a54da 100644 --- a/apps/server/src/engine/domain/decide.ts +++ b/apps/server/src/engine/domain/decide.ts @@ -1885,12 +1885,20 @@ const signalOne = (b: StepBuilder, sessionId: SessionId, signal: ProviderSignal) case "work-upserted": { // Work a person's Stop cut with its session was stopped, not lost. const stopped = signal.status === "lost" && b.state.closing?.reason === "stop"; + // The call that started it, by its key: still open, or closed in a run the state keeps. + const call = + signal.call === undefined + ? undefined + : (b.state.closedItems[signal.call]?.itemId ?? + Object.values(b.state.items).find((item) => item.key === signal.call)?.id); const body: ItemBody = { kind: "work", work: signal.work, workKind: signal.workKind, status: stopped ? "stopped" : signal.status, title: signal.title ?? null, + ...(call === undefined ? {} : { call }), + ...(signal.report === undefined ? {} : { report: signal.report }), }; const ends = WORK_ENDED.has(signal.status); const closed = b.state.closedItems[signal.work]; @@ -1929,7 +1937,11 @@ const signalOne = (b: StepBuilder, sessionId: SessionId, signal: ProviderSignal) runId: owner.id, itemId: id, key: signal.work, - by: { kind: "mate" }, + // A helper's own work is the helper's, as its calls are. + by: + signal.helper === undefined + ? { kind: "mate" } + : { kind: "helper", helperId: signal.helper }, body, }); if (ends) b.emit({ _tag: "ItemClosed", runId: owner.id, itemId: id, body }); diff --git a/apps/server/src/engine/engine.pump.test.ts b/apps/server/src/engine/engine.pump.test.ts index 5e41b719f..1254bbfdd 100644 --- a/apps/server/src/engine/engine.pump.test.ts +++ b/apps/server/src/engine/engine.pump.test.ts @@ -1259,3 +1259,200 @@ describe("the running engine", () => { ), ); }); + +/** + * Claude's helpers and background work as its adapter reports them: a helper's own calls carry the + * launch they run under and the helper's task (`agentId`), a task a helper's tool started names that + * helper as its owner, and every report of a task repeats its linkage (`ClaudeAdapter` + * `taskLinkageFor`). + */ +describe("Claude's helpers and background work, as the engine records them", () => { + const claude = (w: EngineWorld) => ({ + call: ( + id: string, + args: Record, + owner: { readonly helper: string; readonly launch: string } | null = null, + tool = "Bash", + result = "", + ) => + w.agent((agent, thread) => + Effect.gen(function* () { + const head = { + itemType: tool === "Agent" ? "collab_agent_tool_call" : "command_execution", + title: tool === "Agent" ? "Subagent task" : "Command run", + ...(owner === null ? {} : { agentId: owner.helper, parentToolUseId: owner.launch }), + }; + yield* agent.emit("item.started", thread, { + itemId: id, + payload: { ...head, status: "inProgress", data: { toolName: tool, input: args } }, + }); + yield* agent.emit("item.completed", thread, { + itemId: id, + payload: { + ...head, + status: "completed", + data: { + toolName: tool, + input: args, + result: { type: "tool_result", content: result }, + }, + }, + }); + }), + ), + task: (type: "task.started" | "task.completed", payload: Record) => + w.agent((agent, thread) => agent.emit(type, thread, { payload })), + }); + + const works = (w: EngineWorld, n: number) => + Effect.map(w.items(r(n)), (items) => items.filter((item) => item.kind === "work")); + + // Milo's second stress run drew a helper's background sleep as the Mate's own job, and a nested + // helper among the Mate's helpers. + it.effect("a helper's own background job and helper are recorded as that helper's", () => + scene( + Effect.gen(function* () { + const w = yield* world("claudeAgent"); + const c = claude(w); + yield* send(w); + yield* c.call( + "toolu_h", + { description: "Look into it", run_in_background: true }, + null, + "Agent", + ); + yield* c.task("task.started", { + taskId: "ah", + description: "Look into it", + taskType: "local_agent", + toolUseId: "toolu_h", + }); + const owner = { helper: "ah", launch: "toolu_h" }; + yield* c.call("toolu_j", { command: "sleep 30", run_in_background: true }, owner); + yield* c.task("task.started", { + taskId: "bj", + description: "Sleep", + taskType: "local_bash", + agentId: "ah", + toolUseId: "toolu_j", + }); + yield* c.call("toolu_n", { description: "Dig deeper" }, owner, "Agent"); + yield* c.task("task.started", { + taskId: "an", + description: "Dig deeper", + taskType: "local_agent", + agentId: "ah", + toolUseId: "toolu_n", + }); + const [helper, job, nested] = yield* works(w, 1); + assert.deepStrictEqual( + [helper?.by, job?.by, nested?.by], + [ + { kind: "mate" }, + { kind: "helper", helperId: helper?.body.work }, + { kind: "helper", helperId: helper?.body.work }, + ], + ); + yield* w.shutdown; + }), + ), + ); + + it.effect( + "a helper's own call goes on under the run that started its helper, never the next message's", + () => + scene( + Effect.gen(function* () { + const w = yield* world("claudeAgent"); + const c = claude(w); + yield* send(w); + yield* c.call("toolu_h", { description: "Look into it" }, null, "Agent"); + yield* c.task("task.started", { + taskId: "ah", + description: "Look into it", + taskType: "local_agent", + toolUseId: "toolu_h", + }); + yield* w.agent((agent, thread) => agent.finish(thread)); + yield* send(w, "second"); + yield* c.call("toolu_x", { command: "ls" }, { helper: "ah", launch: "toolu_h" }); + const calls = (n: number) => + Effect.map(w.items(r(n)), (items) => + items.flatMap((item) => + item.kind === "call" ? [(item.body.tool as { readonly name: string }).name] : [], + ), + ); + assert.deepStrictEqual([yield* calls(1), yield* calls(2)], [["Agent", "Bash"], []]); + yield* w.shutdown; + }), + ), + ); + + it.effect( + "a background job's record names the call that started it and the line it ended with", + () => + scene( + Effect.gen(function* () { + const w = yield* world("claudeAgent"); + const c = claude(w); + yield* send(w); + yield* c.call( + "toolu_j", + { command: "exit 3", run_in_background: true }, + null, + "Bash", + "Command running in background with ID: bj.", + ); + yield* c.task("task.started", { + taskId: "bj", + description: "Fail on purpose", + taskType: "local_bash", + toolUseId: "toolu_j", + }); + yield* c.task("task.completed", { + taskId: "bj", + status: "failed", + summary: 'Background command "Fail on purpose" failed with exit code 3', + taskType: "local_bash", + toolUseId: "toolu_j", + }); + const items = yield* w.items(r(1)); + const call = items.find((item) => item.kind === "call"); + const [job] = yield* works(w, 1); + assert.deepStrictEqual( + [job?.body.call, job?.body.status, job?.body.report], + [ + call?.item_id, + "failed", + 'Background command "Fail on purpose" failed with exit code 3', + ], + ); + yield* w.shutdown; + }), + ), + ); + + // Milo's run 2 rig: the job a person's Stop killed read "didn't report back". + it.effect( + "background work a person's Stop killed with Claude's session is recorded stopped", + () => + scene( + Effect.gen(function* () { + const w = yield* world("claudeAgent"); + const c = claude(w); + yield* send(w); + yield* c.call("toolu_j", { command: "sleep 120", run_in_background: true }); + yield* c.task("task.started", { + taskId: "bj", + description: "Sleep two minutes", + taskType: "local_bash", + toolUseId: "toolu_j", + }); + yield* stop(w); + const [job] = yield* works(w, 1); + assert.strictEqual(job?.body.status, "stopped"); + yield* w.shutdown; + }), + ), + ); +}); diff --git a/apps/server/src/engine/pump/toCore.ts b/apps/server/src/engine/pump/toCore.ts index fa178753b..3946c01f3 100644 --- a/apps/server/src/engine/pump/toCore.ts +++ b/apps/server/src/engine/pump/toCore.ts @@ -353,6 +353,9 @@ export const makeToCore = (options: ToCoreOptions = {}): ToCore => { workKind: signal.kind, status: signal.status, ...(signal.title === undefined ? {} : { title: signal.title }), + ...(signal.helper === undefined ? {} : { helper: signal.helper }), + ...(signal.call === undefined ? {} : { call: signal.call }), + ...(signal.report === undefined ? {} : { report: signal.report }), }); break; case "request.opened": diff --git a/apps/server/src/engine/testing/pump/engineWorld.ts b/apps/server/src/engine/testing/pump/engineWorld.ts index d579e94de..9e2b4b9c1 100644 --- a/apps/server/src/engine/testing/pump/engineWorld.ts +++ b/apps/server/src/engine/testing/pump/engineWorld.ts @@ -280,15 +280,19 @@ export const makeEngineWorld = (options: WorldOptions) => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const rows = yield* sql<{ + readonly item_id: string; readonly kind: string; readonly state: string; readonly body_json: string; + readonly by_json: string; }>` - SELECT kind, state, body_json FROM engine_item WHERE run_id = ${runId} ORDER BY opened_seq + SELECT item_id, kind, state, body_json, by_json FROM engine_item + WHERE run_id = ${runId} ORDER BY opened_seq `; return rows.map((row) => ({ ...row, body: JSON.parse(row.body_json) as Record, + by: JSON.parse(row.by_json) as Record, })); }), ); diff --git a/apps/server/src/engine/wire/EngineWire.test.ts b/apps/server/src/engine/wire/EngineWire.test.ts index f5c4b8630..57d4cf676 100644 --- a/apps/server/src/engine/wire/EngineWire.test.ts +++ b/apps/server/src/engine/wire/EngineWire.test.ts @@ -476,6 +476,59 @@ describe("a client reading more of an engine conversation", () => { ), ); + // Milo's stress runs: a reload's closed card lost the line of jobs it read live, its commands unread. + it.effect("reads a closed card's background work with the commands that started it", () => + Effect.scoped( + Effect.gen(function* () { + const w = yield* world; + const wire = yield* wireOf(w); + yield* w.tell({ _tag: "Send", text: "Start the soak" }); + yield* w.agent((agent, thread) => + Effect.gen(function* () { + const payload = { + itemType: "command_execution", + title: "Command run", + data: { toolName: "Bash", input: { command: "./soak.sh", run_in_background: true } }, + }; + yield* agent.emit("item.started", thread, { + itemId: "toolu_soak", + payload: { ...payload, status: "inProgress" }, + }); + yield* agent.emit("item.completed", thread, { + itemId: "toolu_soak", + payload: { ...payload, status: "completed" }, + }); + yield* agent.emit("task.started", thread, { + payload: { + taskId: "bsoak", + description: "Run the soak", + taskType: "local_bash", + toolUseId: "toolu_soak", + }, + }); + }), + ); + yield* w.agent((agent, thread) => agent.say(thread, "It runs.")); + yield* w.agent((agent, thread) => agent.finish(thread)); + const page = yield* wire.readRun({ + protocol, + conversationId: mate, + runId: runId(mate, 1), + afterSeq: 0, + only: "outcome", + }); + if (page._tag !== "Page") throw new Error(page._tag); + const call = page.items.find((item) => item.kind === "call"); + const work = page.items.find((item) => item.kind === "work"); + assert.deepStrictEqual( + [call?.tool.name, work?.kind === "work" ? work.call : undefined], + ["Bash", call?.id], + ); + yield* w.shutdown; + }), + ), + ); + it.effect( "counts a run's generic calls by their tool, and the files its edits changed once", () => diff --git a/apps/server/src/engine/wire/records.ts b/apps/server/src/engine/wire/records.ts index 956cf2449..8927aa39f 100644 --- a/apps/server/src/engine/wire/records.ts +++ b/apps/server/src/engine/wire/records.ts @@ -450,7 +450,8 @@ export const makeRecords = Effect.gen(function* () { /** * A run's items, the newest page; `more` when older ones exist before it. After `afterSeq`, * the oldest page; `more` when later ones exist. `only: "outcome"`: the items a closed card - * draws its result from. + * draws its result from — its background work with the calls that started it, which its line + * of jobs is drawn from. */ runPage: ( conversation: ConversationId, @@ -472,7 +473,11 @@ export const makeRecords = Effect.gen(function* () { options.only === "outcome" ? sql`AND (kind = 'work' OR (kind = 'call' AND ( json_extract(body_json, '$.result') IS NOT NULL - OR json_extract(body_json, '$.step') = 'look')))` + OR json_extract(body_json, '$.step') = 'look' + OR item_id IN ( + SELECT json_extract(body_json, '$.call') FROM engine_item + WHERE conversation_id = ${conversation} AND run_id = ${runId} + AND kind = 'work'))))` : sql`` } ORDER BY opened_seq ${forward ? sql`ASC` : sql`DESC`} LIMIT ${options.limit + 1} diff --git a/packages/contracts/src/engine.ts b/packages/contracts/src/engine.ts index 54f5fbee7..80ca7b24b 100644 --- a/packages/contracts/src/engine.ts +++ b/packages/contracts/src/engine.ts @@ -468,6 +468,10 @@ const itemBodyFields = { workKind: forwardCompatibleLiterals(WORK_KINDS), status: forwardCompatibleLiterals(WORK_STATUSES), title: Schema.NullOr(Schema.String), + /** The call that started it: the command sent to the background, the helper's launch. */ + call: Schema.optionalKey(ItemId), + /** What it said as it ended, in a line: `Background command "…" failed with exit code 3`. */ + report: Schema.optionalKey(Schema.String), }, context: { notes: Schema.Array(Schema.String) }, marker: { diff --git a/packages/contracts/src/engineWire.ts b/packages/contracts/src/engineWire.ts index 06b39d0c8..81f0ea071 100644 --- a/packages/contracts/src/engineWire.ts +++ b/packages/contracts/src/engineWire.ts @@ -316,7 +316,8 @@ export type EngineReadEarlierInput = typeof EngineReadEarlierInput.Type; /** * A run's items, the newest page first (`beforeSeq` pages back); with `afterSeq`, the oldest page * after it (`more`: later ones exist). `only: "outcome"` reads just the items a closed card draws - * its result from: calls with a result or a picture looked at, and background work. + * its result from: calls with a result or a picture looked at, and background work with the calls + * that started it. */ export const EngineReadRunInput = Schema.Struct({ ...protocol, From 90a5681960d1d1f26331126c8e0212dbe584cff8 Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 22:08:12 +0200 Subject: [PATCH 03/19] fix(client): an engine card reads whose its work is, its call and its end from the record Background work's activities carry the helper the record names (agentId), the call that started it (toolUseId, so a job is its command's and a helper's launch is its helper's row) and its report (detail: "Exit code 3"). The guess by a helper call's words is gone: it held only while the helper's calls were held, so the dock and the card drew a helper's job as the Mate's after a reload, and a helper's launch read as a finished one-second step while it worked (Milo's third stress run). The pipeline test arranges its record as the engine now writes it. --- .../chat/engineRunCard.pipeline.test.ts | 2 ++ .../src/data/projections/mateEngine.ts | 34 +++++-------------- 2 files changed, 11 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/chat/engineRunCard.pipeline.test.ts b/apps/web/src/components/chat/engineRunCard.pipeline.test.ts index 90ccde4b4..94f156906 100644 --- a/apps/web/src/components/chat/engineRunCard.pipeline.test.ts +++ b/apps/web/src/components/chat/engineRunCard.pipeline.test.ts @@ -461,6 +461,8 @@ describe("an engine Mate's run card, from the engine's record", () => { status: "completed", title: description, }), + // The engine records whose it is: the helper whose own command started it. + by: { kind: "helper", helperId: "conversation/s/1.w2" }, at: t0 + 42_400, } as Item, noteItem(run1, 5, "Both helpers are back.", { at: t0 + 77_700 }), diff --git a/packages/client-runtime/src/data/projections/mateEngine.ts b/packages/client-runtime/src/data/projections/mateEngine.ts index 4bfd00837..cdc4b3d18 100644 --- a/packages/client-runtime/src/data/projections/mateEngine.ts +++ b/packages/client-runtime/src/data/projections/mateEngine.ts @@ -329,21 +329,23 @@ function callActivities( ]; } -/** Background work as V1's task lifecycle: a helper on the helpers' surface, the rest jobs. */ +/** + * Background work as V1's task lifecycle: a helper on the helpers' surface, the rest jobs. A + * helper's own work is the helper's, as V1 stamps it (`agentId`): never the Mate's row. The call + * that started it is its tool use, as V1 names it: its job is that command's. + */ function workActivities( item: Extract, cardOf: CardOf, - helperOfWork: (item: Extract) => string | undefined, ): ReadonlyArray { const taskType = WORK_TASK_TYPES[item.workKind]; - const helper = helperOfWork(item); const payload = { taskId: item.work, ...(item.workKind === "helper" ? { agentKind: "agent" } : {}), - // A helper's own background job is the helper's, as V1 stamps it: never the Mate's row. - ...(helper === undefined ? {} : { agentId: helper }), + ...(item.by.kind === "helper" ? { agentId: item.by.helperId } : {}), ...(taskType === undefined ? {} : { taskType }), ...(item.title === null ? {} : { title: item.title }), + ...(item.call === undefined ? {} : { toolUseId: item.call }), }; const card = cardOf(item.runId); const summary = item.title ?? "Background task"; @@ -364,7 +366,7 @@ function workActivities( item.id, "task.completed", summary, - { ...payload, status: end }, + { ...payload, status: end, ...(item.report === undefined ? {} : { detail: item.report }) }, card, item.at, item.rev, @@ -742,24 +744,6 @@ export function engineThreadOf( const requests = valuesOf(read, "mateEngineRequest", "engineRequestsIn", conversationKey).sort( (left, right) => left.seq - right.seq, ); - // The background commands each helper sent, by the words it gave them: the work a helper's own - // command started bears no helper of its own on the record (Milo's second stress run drew a - // helper's sleep and poll as the Mate's "finished · in the background" rows). - const helperCommands = new Map(); - for (const item of items) { - if (item.kind !== "call" || item.by.kind !== "helper") continue; - const input = ( - item.shows as { readonly input?: { readonly description?: unknown } } | undefined - )?.input; - if (typeof input?.description === "string") - helperCommands.set(`${item.runId}\u0000${input.description.trim()}`, item.by.helperId); - } - const helperOfWork = (item: Extract): string | undefined => - item.by.kind === "helper" - ? item.by.helperId - : item.workKind === "helper" || item.title === null - ? undefined - : helperCommands.get(`${item.runId}\u0000${item.title.trim()}`); const messages: EngineMessage[] = []; const activities: OrchestrationThreadActivity[] = []; const runById = new Map(runs.map((run) => [run.id as string, run])); @@ -775,7 +759,7 @@ export function engineThreadOf( activities.push(...callActivities(item, cardOf)); break; case "work": - activities.push(...workActivities(item, cardOf, helperOfWork)); + activities.push(...workActivities(item, cardOf)); break; case "marker": { const marked = markerActivity(item, cardOf); From d9245794c55191ee0c4104a827ab0d91f81a03ca Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 22:08:16 +0200 Subject: [PATCH 04/19] fix(client): a card waiting on its helpers never counts their own commands as the Mate's Milo's third stress run waited on "its helpers and 2 background commands": the commands were the nested helper's. A helper's own work counts as its helpers' (the oracle's row: a card waiting on its helpers). --- packages/client-runtime/src/data/projections/mateEngine.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/data/projections/mateEngine.ts b/packages/client-runtime/src/data/projections/mateEngine.ts index cdc4b3d18..dcacf84b4 100644 --- a/packages/client-runtime/src/data/projections/mateEngine.ts +++ b/packages/client-runtime/src/data/projections/mateEngine.ts @@ -912,7 +912,9 @@ export const engineRunCards: Projection< if (item.kind !== "known" || item.value.kind !== "work") continue; card.holdsWork = true; if (!ALIVE_WORK.has(item.value.status) || item.value.workKind === "monitor") continue; - if (item.value.workKind === "helper") helpers += 1; + // A helper's own command is its helper's work, never one the Mate sent (Milo's third + // stress run waited on "its helpers and 2 background commands": the helper's own). + if (item.value.workKind === "helper" || item.value.by.kind === "helper") helpers += 1; else commands += 1; } if (card.runs.at(-1)?.state === "ended" && helpers + commands > 0) From 34c671bd91b9e0d3b62375669ea07f8e49681f42 Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 22:08:36 +0200 Subject: [PATCH 05/19] fix(web): a helper's own helper is never among the helpers the Mate started Anything a helper started (agentId) is the helper's, its own helpers too: the helpers' surface keeps it under its helper (spawnedBy). Milo's second stress run merged a nested helper started 40 s later into "Started 4 helpers". --- apps/web/src/session-logic.test.ts | 27 +++++++++++++++++++++++++++ apps/web/src/session-logic.ts | 19 ++++++++++--------- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index b0c2fe9e9..8a266d95d 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -2396,6 +2396,33 @@ describe("deriveWorkLogEntries quiet-timeline guarantee", () => { expect(entries[0]!.agentSpawn?.agentTaskIds).toEqual(["child-1", "child-2"]); }); + // Milo's second stress run: a helper's helper, started 40 s later, read "Started 4 helpers" on + // the Mate's first wave. + it("a helper's own helper is never among the helpers the Mate started", () => { + const entries = deriveWorkLogEntries([ + makeActivity({ + kind: "task.started", + summary: "Look into it", + tone: "info", + payload: { taskId: "helper-1", agentKind: "agent", taskType: "local_agent" }, + turnId: "turn-1", + }), + makeActivity({ + kind: "task.started", + summary: "Dig deeper", + tone: "info", + payload: { + taskId: "helper-2", + agentKind: "agent", + taskType: "local_agent", + agentId: "helper-1", + }, + turnId: "turn-1", + }), + ]); + expect(entries.map((entry) => entry.agentSpawn?.agentTaskIds)).toEqual([["helper-1"]]); + }); + it("timelineBypass non-agent rows (background shells) stay suppressed", () => { const entries = deriveWorkLogEntries([ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 76b94987d..5d10387e9 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -870,17 +870,18 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean activity.kind === "task.progress" || activity.kind === "task.updated" || activity.kind === "task.completed"; - // Task rows classify by the server stamp: a subagent's own background - // shell (agentId + "background") is agent-internal, but a nested AGENT - // (agentId + "agent") stays visible so its rows can anchor a spawn row - // (review finding: hiding on agentId alone removed nested agents and - // their anchors). Bypassed agent lifecycle rows also pass — collapse - // folds every such row into its batch's single CTA row, which is how - // Codex children (whose rows are ALL bypassed) get an anchor at the - // spawn point. + // Task rows classify by the server stamp: anything a helper started — its + // background shell, a helper of its own (agentId) — is the helper's, never + // among what the Mate started (Milo's second stress run: a helper's helper, + // started 40 s later, read "Started 4 helpers" on the Mate's first wave); + // the helpers' surface keeps it under its helper (`spawnedBy`). Bypassed + // agent lifecycle rows pass — collapse folds every such row into its + // batch's single CTA row, which is how Codex children (whose rows are ALL + // bypassed) get an anchor at the spawn point. if (isTaskRow) { const ownedByAgent = typeof payload.agentId === "string" && payload.agentId.trim().length > 0; - if (ownedByAgent || payload.timelineBypass === true) { + if (ownedByAgent) return true; + if (payload.timelineBypass === true) { const isAgentTaskRow = activity.kind !== "task.updated" && typeof payload.taskId === "string" && From 57a405e299999c241552796466dffeabd6bbf470 Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 22:08:57 +0200 Subject: [PATCH 06/19] fix(web): a helpers bubble marks a failure at once and says how its helpers ended MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bubble's words and state are a logic of their own (helpersBubbleOf), as the card and the oracle read them: - a helper that failed marks the bubble while the others still work (Milo's stress runs: one failed in a batch of three and the bubble said "Working"); - once none works, it says how those that did not finish ended: "2 stopped" after a person's Stop (the card read "Milo worked 11s · 2 helpers" and nothing more), "1 didn't report back" for one its session took; - a helper Claude reports stopped reads Stopped (cancelled), no longer Cut off, which is the engine's lost. --- apps/web/src/components/chat/RunChat.tsx | 44 ++-------- .../chat/helpersBubble.logic.test.ts | 82 +++++++++++++++++++ .../components/chat/helpersBubble.logic.ts | 69 ++++++++++++++++ .../src/state/subagentRuntime.ts | 3 +- 4 files changed, 159 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/components/chat/helpersBubble.logic.test.ts create mode 100644 apps/web/src/components/chat/helpersBubble.logic.ts diff --git a/apps/web/src/components/chat/RunChat.tsx b/apps/web/src/components/chat/RunChat.tsx index 3c9208187..ecdf97be1 100644 --- a/apps/web/src/components/chat/RunChat.tsx +++ b/apps/web/src/components/chat/RunChat.tsx @@ -41,7 +41,6 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import type { ZeropsOperation } from "@t3tools/client-runtime/zerops/model"; import { isActiveSubagentStatus, - type AgentPanelModel, type RuntimeSubagent, } from "@t3tools/client-runtime/state/subagentRuntime"; import { @@ -86,7 +85,6 @@ import { cn } from "~/lib/utils"; import { MessageFilesAbove, useMessageFileUrls } from "./MessageFiles"; import { useMateBrowserCallFrames } from "../../zerops/browserStreamLinks"; import { frameImageSrc } from "@t3tools/client-runtime/zerops/browserStream"; -import { UNNAMED_HELPER } from "@t3tools/client-runtime/state/subagentRuntime"; import { FixAction } from "./FixAction"; import { useMateOfEnvironment } from "../../zerops/accountEnvironments"; import { RunShimmer } from "./RunShimmer"; @@ -114,7 +112,7 @@ import { type OperationCardRegions, } from "../../zerops/activity/useOperationCard"; import { useZeropsTopology } from "../../zerops/useZeropsFeeds"; -import { deriveAgentSpawnSummary } from "./agentSpawnSummary"; +import { helpersBubbleOf } from "./helpersBubble.logic"; import { BrowserStrip, BrowserTakes } from "./BrowserStrip"; import { browserCheckCaption, @@ -2276,50 +2274,20 @@ function HelperFace() { ); } -/** The helpers a launch started, as the helpers panel knows them. */ -function spawnAgents(model: AgentPanelModel, spawn: NonNullable) { - const memberIds = new Set(spawn.agentTaskIds); - const workflowGroup = spawn.workflowId - ? model.workflows.find((group) => group.workflow.id === spawn.workflowId) - : undefined; - const agents = workflowGroup - ? [...workflowGroup.phases.flatMap((phase) => phase.members), ...workflowGroup.unphasedMembers] - : model.directAgents.filter((agent) => memberIds.has(agent.id)); - const count = Math.max( - agents.length, - Math.max(memberIds.size - (spawn.workflowId ? 1 : 0), 0), - 1, - ); - const summary = deriveAgentSpawnSummary({ - agents, - agentCount: count, - coordinatorStatus: workflowGroup?.workflow.status, - }); - const workflowName = - workflowGroup?.workflow.workflowName ?? workflowGroup?.workflow.title ?? null; - return { agents, count, summary, workflowName }; -} - /** Helpers it started: how many and what for; each one, its state and what it said, opened under it. */ function HelpersBubble({ entry }: { readonly entry: WorkLogEntry }) { const ctx = use(TimelineRowCtx); const disclosure = useDisclosure(); const spawn = entry.agentSpawn; if (!spawn) return null; - const { agents, count, summary, workflowName } = spawnAgents(ctx.agentPanelModel, spawn); - const words = count === 1 ? "Started a helper" : `Started ${count} helpers`; - // A helper no one named says nothing past "Started a helper". - const what = - workflowName ?? - agents - .map((agent) => agent.title) - .filter((title) => title !== UNNAMED_HELPER) - .join(" · "); - const failed = summary.tone === "failed"; + const { agents, summary, words, what, failed, ended } = helpersBubbleOf( + ctx.agentPanelModel, + spawn, + ); // Helpers not known yet: nothing to open onto. const opens = opensOnto({ control: "helpers", agents: agents.length }); const head = ( - + {words} {what ? {` · ${what}`} : null} diff --git a/apps/web/src/components/chat/helpersBubble.logic.test.ts b/apps/web/src/components/chat/helpersBubble.logic.test.ts new file mode 100644 index 000000000..823393912 --- /dev/null +++ b/apps/web/src/components/chat/helpersBubble.logic.test.ts @@ -0,0 +1,82 @@ +import type { RuntimeSubagent } from "@t3tools/client-runtime/state/subagentRuntime"; +import { deriveAgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime"; +import { describe, expect, it } from "vite-plus/test"; + +import { helpersBubbleOf } from "./helpersBubble.logic"; + +const helper = (id: string, status: RuntimeSubagent["status"]): RuntimeSubagent => + ({ + id, + kind: "subagent", + title: `Helper ${id}`, + role: "general-purpose", + model: null, + effort: null, + status, + activationCount: 1, + usage: null, + progress: null, + lastToolName: null, + result: null, + error: null, + outputFile: null, + parentAgentId: null, + agentIndex: null, + phaseIndex: null, + phaseTitle: null, + attempt: null, + workflowName: null, + phases: [], + runHandles: null, + recentActivity: [], + prompt: null, + toolUseId: null, + spawnedBy: null, + liveCall: null, + firstSeenAt: "2026-10-09T18:00:00.000Z", + startedAt: "2026-10-09T18:00:00.000Z", + completedAt: null, + updatedAt: "2026-10-09T18:00:00.000Z", + }) as RuntimeSubagent; + +describe("the bubble of helpers a launch started", () => { + it.each([ + { + // Milo's stress runs: one failed in a batch of three, and the bubble said only "Working". + name: "one failed while the others still work: marked failed at once", + statuses: ["failed", "running"] as const, + failed: true, + ended: null, + }, + { + // A person's Stop after the turn ended both: the card read "2 helpers" and nothing more. + name: "a person's Stop ended them: said stopped", + statuses: ["cancelled", "cancelled"] as const, + failed: false, + ended: "2 stopped", + }, + { + name: "one failed, one stopped, one its session took: each end said", + statuses: ["failed", "cancelled", "interrupted"] as const, + failed: true, + ended: "1 failed, 1 stopped, 1 didn't report back", + }, + { + name: "all finished: nothing more to say", + statuses: ["completed", "completed"] as const, + failed: false, + ended: null, + }, + ])("says how its helpers stand: $name", ({ statuses, failed, ended }) => { + const agents = statuses.map((status, index) => helper(`h${index}`, status)); + const bubble = helpersBubbleOf(deriveAgentPanelModel({ agents }), { + workflowId: null, + agentTaskIds: agents.map((agent) => agent.id), + }); + expect({ words: bubble.words, failed: bubble.failed, ended: bubble.ended }).toEqual({ + words: `Started ${statuses.length} helpers`, + failed, + ended, + }); + }); +}); diff --git a/apps/web/src/components/chat/helpersBubble.logic.ts b/apps/web/src/components/chat/helpersBubble.logic.ts new file mode 100644 index 000000000..51a38c4f8 --- /dev/null +++ b/apps/web/src/components/chat/helpersBubble.logic.ts @@ -0,0 +1,69 @@ +/** + * The bubble a launch of helpers draws on its run's card: how many it started, what for, and + * whether one of them failed — said from the helpers' surface, which knows each one's state. + */ +import { + UNNAMED_HELPER, + type AgentPanelModel, +} from "@t3tools/client-runtime/state/subagentRuntime"; + +import type { WorkLogEntry } from "../../session-logic"; +import { deriveAgentSpawnSummary } from "./agentSpawnSummary"; + +export function helpersBubbleOf( + model: AgentPanelModel, + spawn: NonNullable, +) { + const memberIds = new Set(spawn.agentTaskIds); + const workflowGroup = spawn.workflowId + ? model.workflows.find((group) => group.workflow.id === spawn.workflowId) + : undefined; + const agents = workflowGroup + ? [...workflowGroup.phases.flatMap((phase) => phase.members), ...workflowGroup.unphasedMembers] + : model.directAgents.filter((agent) => memberIds.has(agent.id)); + const count = Math.max( + agents.length, + Math.max(memberIds.size - (spawn.workflowId ? 1 : 0), 0), + 1, + ); + const summary = deriveAgentSpawnSummary({ + agents, + agentCount: count, + coordinatorStatus: workflowGroup?.workflow.status, + }); + const workflowName = + workflowGroup?.workflow.workflowName ?? workflowGroup?.workflow.title ?? null; + // A helper no one named says nothing past "Started a helper". + const what = + workflowName ?? + agents + .map((agent) => agent.title) + .filter((title) => title !== UNNAMED_HELPER) + .join(" · "); + // How the helpers that did not finish ended, once none works: a person's Stop or a failure is + // said on the bubble, never left to the helpers' surface ("Milo worked 11s · 2 helpers" and + // nothing more after a Stop that ended both). + const failedCount = agents.filter((agent) => agent.status === "failed").length; + const stoppedCount = agents.filter((agent) => agent.status === "cancelled").length; + const cutCount = agents.filter((agent) => agent.status === "interrupted").length; + const ended = summary.live + ? null + : [ + failedCount > 0 ? `${failedCount} failed` : null, + stoppedCount > 0 ? `${stoppedCount} stopped` : null, + cutCount > 0 ? `${cutCount} didn't report back` : null, + ] + .filter((part): part is string => part !== null) + .join(", ") || null; + return { + agents, + count, + summary, + ended, + words: count === 1 ? "Started a helper" : `Started ${count} helpers`, + what, + // A helper that failed marks the bubble at once, never hidden behind the others still at it + // (Milo's stress runs: one failed in a batch of three, and the bubble said only "Working"). + failed: summary.tone === "failed" || agents.some((agent) => agent.status === "failed"), + }; +} diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index 2683d88e4..f2b659574 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -493,7 +493,8 @@ function applyStatus(agent: MutableAgent, status: RuntimeSubagentStatus, at: str const TASK_COMPLETED_STATUS: ReadonlyMap = new Map([ ["completed", "completed"], ["failed", "failed"], - ["stopped", "interrupted"], + // Stopped — by the person's Stop, by the Mate — is its own end, never one cut off. + ["stopped", "cancelled"], // The engine's: its session went before it reported. ["lost", "interrupted"], ]); From ff67ba3211c8adff213eb7e85c77e455f8901e5c Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 22:09:13 +0200 Subject: [PATCH 07/19] fix(web): every call the Mate made counts in its card's effort, its Zerops calls too Milo's third stress run read "1 tool used" for six Zerops calls: a call whose result is a row of its card (a deploy, a check, a log read) was left out of the worked line. It counts as a tool used, held whole or paged alike; a tool the timeline never draws (the question, a tool search) still does not. The paging test's row "a deploy left to its result" now reads "a deploy among the tools used": the owner's run-3 report (every call the Mate made counts). --- apps/web/src/components/chat/MessagesTimeline.logic.ts | 7 ++++++- apps/web/src/components/chat/conversation.logic.ts | 3 +++ .../src/components/chat/engineRunCard.pipeline.test.ts | 2 +- apps/web/src/zerops/engineCardPaging.logic.test.ts | 8 ++++++-- apps/web/src/zerops/engineCardPaging.logic.ts | 10 ++++++---- 5 files changed, 22 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 31843d6fd..ca73a658e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1949,7 +1949,12 @@ function turnActivity(turn: ConversationTurn): OutcomeActivity[] { const launches = entries.flatMap((entry) => entry.kind === "work" && entry.entry.agentSpawn !== undefined ? [entry.entry] : [], ); - return activityCounts(calls, launches); + // Every call the Mate made counts: its operations' too, though their cards say them. + const operationCalls = entries.reduce( + (sum, entry) => sum + (entry.kind === "operation" ? entry.operation.callIds.length : 0), + 0, + ); + return activityCounts(calls, launches, operationCalls); } /** diff --git a/apps/web/src/components/chat/conversation.logic.ts b/apps/web/src/components/chat/conversation.logic.ts index a9852cf28..3b00c2a87 100644 --- a/apps/web/src/components/chat/conversation.logic.ts +++ b/apps/web/src/components/chat/conversation.logic.ts @@ -1261,6 +1261,8 @@ export interface OutcomeActivity { export function activityCounts( calls: ReadonlyArray, launches: ReadonlyArray = [], + /** The calls its operations' cards hold (a deploy, a check): tools it used all the same. */ + operationCalls = 0, ): OutcomeActivity[] { const counts = new Map(); const edited = new Set(); @@ -1278,6 +1280,7 @@ export function activityCounts( : action; counts.set(kind, (counts.get(kind) ?? 0) + 1); } + if (operationCalls > 0) counts.set("tool", (counts.get("tool") ?? 0) + operationCalls); if (edited.size + unnamedEdits > 0) counts.set("edit", edited.size + unnamedEdits); const helpers = launches.reduce( (sum, entry) => sum + Math.max(1, entry.agentSpawn?.agentTaskIds.length ?? 1), diff --git a/apps/web/src/components/chat/engineRunCard.pipeline.test.ts b/apps/web/src/components/chat/engineRunCard.pipeline.test.ts index 94f156906..7206a32a4 100644 --- a/apps/web/src/components/chat/engineRunCard.pipeline.test.ts +++ b/apps/web/src/components/chat/engineRunCard.pipeline.test.ts @@ -188,7 +188,7 @@ describe("an engine Mate's run card, from the engine's record", () => { }), ); expect(runEffortWords(card.outcome)).toBe( - "1 file edited · 7 commands · 1 page fetched · 1 tool used · 1 helper", + "1 file edited · 7 commands · 1 page fetched · 2 tools used · 1 helper", ); }); diff --git a/apps/web/src/zerops/engineCardPaging.logic.test.ts b/apps/web/src/zerops/engineCardPaging.logic.test.ts index 59ae31f4a..e1e4deae0 100644 --- a/apps/web/src/zerops/engineCardPaging.logic.test.ts +++ b/apps/web/src/zerops/engineCardPaging.logic.test.ts @@ -87,6 +87,10 @@ function heldEffort(calls: ReadonlyArray) { return activityCounts( entries.filter(isActivityWork), entries.filter((entry) => entry.agentSpawn !== undefined), + model.entries.reduce( + (sum, entry) => sum + (entry.kind === "operation" ? entry.operation.callIds.length : 0), + 0, + ), ); } @@ -129,7 +133,7 @@ describe("an engine card too long to read whole", () => { run: [codexEdit("/app/a.ts", "/app/b.ts"), codexEdit("/app/a.ts"), BASH], }, { - calls: "Zerops tools by what they did, a deploy left to its result", + calls: "Zerops tools by what they did, a deploy among the tools used", run: [ zerops("zerops_knowledge", { query: "nodejs" }), zerops("zerops_knowledge", { query: "postgres" }), @@ -154,7 +158,7 @@ describe("an engine card too long to read whole", () => { { kind: "command", count: 812 }, { kind: "read", count: 140 }, { kind: "guides", count: 9 }, - { kind: "tool", count: 15 }, + { kind: "tool", count: 21 }, { kind: "helpers", count: 2 }, ]); }); diff --git a/apps/web/src/zerops/engineCardPaging.logic.ts b/apps/web/src/zerops/engineCardPaging.logic.ts index c1534edd1..e5c853985 100644 --- a/apps/web/src/zerops/engineCardPaging.logic.ts +++ b/apps/web/src/zerops/engineCardPaging.logic.ts @@ -9,7 +9,6 @@ import { type EngineCardCounts, type EngineCardPaging, } from "@t3tools/client-runtime/data"; -import { classifyZeropsCall } from "@t3tools/client-runtime/zerops/model"; import { ACTIVITY_ORDER, @@ -18,6 +17,7 @@ import { type OutcomeActivity, type OutcomeModel, } from "../components/chat/conversation.logic"; +import { classifyZeropsCall } from "@t3tools/client-runtime/zerops/model"; /** A call's step as the worked line counts it; a generic or MCP call by its tool. */ const STEP_KIND: Readonly> = { @@ -36,14 +36,16 @@ const NAMED_TOOL_KIND: Readonly> = { }; /** - * What a tool's calls count as: a Zerops tool by what it did ("the workflow checked"), never one - * whose result is a row of its card (a deploy, a check) or the timeline leaves out. + * What a tool's calls count as: a Zerops tool by what it did ("the workflow checked"), every other + * as a tool used — one whose result is a row of its card (a deploy, a check) too: every call the + * Mate made counts (Milo's third stress run read "1 tool used" for six Zerops calls). */ function toolKind(name: string): ActivityKind | null { const named = NAMED_TOOL_KIND[name]; if (named !== undefined) return named; const bare = name.split("__").at(-1) ?? name; - if (classifyZeropsCall(bare, undefined, "completed") !== "generic") return null; + // A tool the timeline never draws (the question asked, a tool search) is no effort to count. + if (classifyZeropsCall(bare, undefined, "completed") === "hidden") return null; return ZEROPS_TOOL_KIND[bare] ?? "tool"; } From 71f29ed7147b2945edf1af4be6410407516b81e0 Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 22:09:31 +0200 Subject: [PATCH 08/19] fix(web): a message steered into a run is drawn once, above its card A message sent into a running run stood above its card and again as a mark in its chat; a closed card after a reload held no lines, so it drew one copy (Milo's third stress run, a steer twice live). It is drawn once, on its own row above the card (the owner's rule), and so is one steered into a turn Claude opened on its own (engine #158). The timeline tests that read the mark as a line of the card's chat now read the card without it; the first's sentence "marked in its chat" becomes "drawn once" by the owner's rule relayed with run 3. --- .../chat/MessagesTimeline.logic.test.ts | 24 ++++---- .../components/chat/MessagesTimeline.logic.ts | 15 ++--- .../chat/engineRunCard.pipeline.test.ts | 59 +++++++++++++++++++ 3 files changed, 73 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index bc1f69719..ae2fab45d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -293,11 +293,12 @@ describe("deriveMessagesTimelineRows", () => { expect(list.at(-1)).toMatchObject({ showAssistantMeta: true, receipt: null }); }); - // What the person sent into the run stands on the page above its card, and - // the card's chat marks where each reached the Mate: one card, one status - // (the owner, 2026-09-28, of the card breaking around each: "these split - // working groups have no chance to stay like this when the work is done"). - it("keeps every message the person sent above the whole card, marked in its chat", () => { + // What the person sent into the run stands on the page above its card, once: + // one card, one status (the owner, 2026-09-28, of the card breaking around + // each: "these split working groups have no chance to stay like this when + // the work is done"; Milo's third stress run drew a steer twice, above the + // card and marked in its chat). + it("keeps every message the person sent above the whole card, drawn once", () => { const list = rows({ entries: [ user("m0", 0), @@ -324,7 +325,7 @@ describe("deriveMessagesTimelineRows", () => { expect.objectContaining({ aside: true, receipt: "seen" }), expect.objectContaining({ aside: true, receipt: "seen" }), ]); - expect(lines(list)).toEqual(["· pnpm test", "> message m1", "· pnpm test", "> message m2"]); + expect(lines(list)).toEqual(["· pnpm test", "· pnpm test"]); }); it("keeps the running run's heading live, its record, and what its hands are on at the end", () => { @@ -1087,7 +1088,7 @@ describe("deriveMessagesTimelineRows", () => { live: "t1", }); const running = scene("inProgress"); - expect(lines(running)).toEqual(["· pnpm test", "> and the footer", "· pnpm test"]); + expect(lines(running)).toEqual(["· pnpm test", "· pnpm test"]); expect(allItems(running)[0]).toMatchObject({ step: { state: "running" } }); const returned = scene("completed"); expect(allItems(returned).map((item) => item.key)).toEqual( @@ -1201,7 +1202,6 @@ describe("deriveMessagesTimelineRows", () => { "One question first.", "? Which accent colour do you prefer?", "> Green", - "> and make it bold", "· pnpm test", ]); }); @@ -2022,11 +2022,7 @@ describe("deriveMessagesTimelineRows", () => { live: "t1", }); expect(list.some((row) => row.id === "a1")).toBe(false); - expect(lines(list)).toEqual([ - "· pnpm test", - "Found it: the build used the dev setup.", - "> revert it", - ]); + expect(lines(list)).toEqual(["· pnpm test", "Found it: the build used the dev setup."]); }); // A check is its row of the chat from its start, where it happened, live @@ -2042,7 +2038,7 @@ describe("deriveMessagesTimelineRows", () => { tool("w2", "t1", 4), ]; const live = rows({ entries, live: "t1" }); - const drawn = ["strip op:b1", "· pnpm test", "> and the footer", "· pnpm test"]; + const drawn = ["strip op:b1", "· pnpm test", "· pnpm test"]; expect(lines(live)).toEqual(drawn); expect(live.find((row) => row.kind === "working")).not.toHaveProperty("strip"); const settled = rows({ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index ca73a658e..a15cc59f1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -2665,17 +2665,10 @@ export function deriveMessagesTimelineRows(input: { }, }); } - if (index > 0 && stretch.lead !== null && stretch.leadIndex !== null) { - const person = personRow(stretch.lead, stretch.leadIndex, stretch.aside); - exchanges.push(person); - items.push({ - kind: "person", - key: `person:${stretch.lead.id}`, - at: stretch.lead.createdAt, - message: stretch.lead.message, - imageOnly: person.kind === "message" && person.imageOnly, - }); - } + // A message sent into the run is drawn once: its own row above the card, never a mark in + // its chat as well (Milo's third stress run drew a steer twice; a reload drew it once). + if (index > 0 && stretch.lead !== null && stretch.leadIndex !== null) + exchanges.push(personRow(stretch.lead, stretch.leadIndex, stretch.aside)); const recordInput = { stretch, answer: turn.answer, diff --git a/apps/web/src/components/chat/engineRunCard.pipeline.test.ts b/apps/web/src/components/chat/engineRunCard.pipeline.test.ts index 7206a32a4..27aba0591 100644 --- a/apps/web/src/components/chat/engineRunCard.pipeline.test.ts +++ b/apps/web/src/components/chat/engineRunCard.pipeline.test.ts @@ -472,4 +472,63 @@ describe("an engine Mate's run card, from the engine's record", () => { const said = JSON.stringify([card.items, rows.filter((row) => row.kind === "background")]); expect(said).not.toContain(description); }); + + // Milo's third stress run: the steer stood above the card and again inside it, live; a reload + // drew it once. Engine #158 steers a waiting message into a turn Claude opened on its own: that + // turn is the message's run, on the card it goes on in. + const steer = "also tell me the page title"; + it.each([ + { into: "the running run", held: "every item", own: false, paged: false }, + { into: "the running run", held: "what a reload reads", own: false, paged: true }, + { into: "a turn Claude opened on its own", held: "every item", own: true, paged: false }, + { + into: "a turn Claude opened on its own", + held: "what a reload reads", + own: true, + paged: true, + }, + ])( + "a message steered into $into is drawn once, above its card, holding $held", + ({ own, paged }) => { + const into = own ? run2 : run1; + const runs = [ + stressRun(), + ...(own + ? [ + engineRun(key.conversationId, 2, { + trigger: { kind: "wake", cause: "self", wakeId: null }, + joins: RunId.make(run1), + queuedAt: t0 + 80_000, + admittedAt: t0 + 80_000, + startedAt: t0 + 80_000, + endedAt: t0 + 90_000, + } as never), + ] + : []), + ]; + const items = [ + personItem(run1, 1, "Run the stress checks", { at: t0 }), + bash(2, 39_000, "node --version", "Check the node version"), + personItem(into, own ? 20 : 3, steer, { + at: t0 + (own ? 80_000 : 40_000), + delivery: { state: "steered", at: t0 + (own ? 80_000 : 40_000) }, + }), + noteItem(into, own ? 21 : 4, "Both titles read Shop.", { at: t0 + 85_000 }), + ]; + // A reload holds every message the person sent, and none of a closed card's lines. + const rows = render({ + runs, + items: paged ? items.filter((item) => item.kind === "person") : items, + paged, + }); + const drawn = rows.filter( + (row) => + row.kind === "message" && row.message.role === "user" && row.message.text === steer, + ); + const marked = rows.flatMap((row) => + row.kind === "record" ? row.items.filter((item) => item.kind === "person") : [], + ); + expect({ drawn: drawn.length, marked: marked.length }).toEqual({ drawn: 1, marked: 0 }); + }, + ); }); From b472a509be96082cd2be10b34a3e7ef1ee2d3e20 Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 22:09:39 +0200 Subject: [PATCH 09/19] fix(web): a wake's answer appears under the reply the person was reading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a job's end woke the Mate, its run went on in the folded card and the reply under that card ("…hasn't printed yet") was pulled into the card: the reply vanished, the content dropped and the wake's answer landed below the view (Milo's third stress run, 798 px for 10 s). An engine card's earlier runs' answers stay where they stood under it; the latest follows them. The engine card journey watches the reply through the wake. --- .../components/chat/MessagesTimeline.logic.ts | 23 ++- .../src/components/chat/conversation.logic.ts | 20 +++ .../chat/engineRunCard.pipeline.test.ts | 63 ++++++++ .../areas/c-mate/engine-card.scenario.ts | 144 ++++++++++-------- 4 files changed, 183 insertions(+), 67 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index a15cc59f1..531827a29 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1528,6 +1528,8 @@ export function thoughtPreview(messages: ReadonlyArray function stretchRecord(input: { stretch: Stretch; answer: MessageEntry | null; + /** The card's earlier runs' answers: drawn under it, never lines of its chat. */ + earlierAnswers: ReadonlyArray; writing: MessageEntry | null; brokeOffEntryId: string | null; pauseRow: MessagesTimelineRow | null; @@ -1717,7 +1719,11 @@ function stretchRecord(input: { // them would have the note land between its halves once it is known. The // thinking before them ended where they began, though: it stays in the // chat while they are on their way. - if (entry === input.answer || entry === input.writing) { + if ( + entry === input.answer || + entry === input.writing || + (entry.kind === "message" && input.earlierAnswers.includes(entry)) + ) { flushReasoning(entry.createdAt); continue; } @@ -2091,6 +2097,7 @@ function batchReads( function recordReads(input: { stretch: Stretch; answer: MessageEntry | null; + earlierAnswers: ReadonlyArray; writing: MessageEntry | null; brokeOffEntryId: string | null; tracked: TrackedCommands; @@ -2104,6 +2111,7 @@ function recordReads(input: { stretch.startedAt, stretch.endedAt, input.answer, + ...input.earlierAnswers, input.writing, input.brokeOffEntryId, input.until, @@ -2672,6 +2680,7 @@ export function deriveMessagesTimelineRows(input: { const recordInput = { stretch, answer: turn.answer, + earlierAnswers: turn.earlierAnswers, writing: turn.writing, brokeOffEntryId: turn.brokeOff?.entryId ?? null, tracked, @@ -2980,6 +2989,18 @@ export function deriveMessagesTimelineRows(input: { }; // A provider pause owns its stage, outside the work card it follows. if (pause !== null) rows.push(pause); + // Each earlier run's answer stays under the card where it stood; the latest follows them. + for (const earlier of turn.earlierAnswers) + rows.push({ + kind: "message", + id: earlier.id, + createdAt: earlier.createdAt, + message: earlier.message, + receipt: null, + aside: false, + imageOnly: false, + showAssistantMeta: true, + }); // The answer follows the card, settled or still streaming. if (answer !== null) { rows.push({ diff --git a/apps/web/src/components/chat/conversation.logic.ts b/apps/web/src/components/chat/conversation.logic.ts index 3b00c2a87..25bde39cd 100644 --- a/apps/web/src/components/chat/conversation.logic.ts +++ b/apps/web/src/components/chat/conversation.logic.ts @@ -567,6 +567,12 @@ export interface ConversationTurn { readonly stretches: ReadonlyArray; /** The Mate's answer: the turn's last message, once the turn has settled. */ readonly answer: MessageEntry | null; + /** + * An engine card's earlier runs' answers, oldest first: each stays where the person read it, + * under the card, as a run a job's end woke goes on in that card and answers below them + * (Milo's third stress run: the reply under the folded card vanished into it at the wake). + */ + readonly earlierAnswers: ReadonlyArray; /** * The words the Mate is writing while it runs that cannot be placed yet: * its last, nothing after them, not reading as its answer. A note if it @@ -1007,6 +1013,19 @@ export function deriveConversationStructure(given: { entry.kind === "message" && String(entry.message.id) === run?.summary.answerItemId, ) ?? null) : span.terminalEntry; + const earlierAnswers = typed + ? cardRuns.flatMap((each) => { + const id = each.summary.answerItemId; + const said = + id === null + ? undefined + : turnEntries.find( + (entry): entry is MessageEntry => + entry.kind === "message" && String(entry.message.id) === id, + ); + return said === undefined || said === answer ? [] : [said]; + }) + : []; // Words still streaming, nothing after them: the working row's, as they // come. Anything after them — a step, a thought — makes them a note in // the record, and so does their end: Codex says nothing of a command @@ -1137,6 +1156,7 @@ export function deriveConversationStructure(given: { span, stretches, answer, + earlierAnswers, writing, live, waiting, diff --git a/apps/web/src/components/chat/engineRunCard.pipeline.test.ts b/apps/web/src/components/chat/engineRunCard.pipeline.test.ts index 27aba0591..ee5a989bb 100644 --- a/apps/web/src/components/chat/engineRunCard.pipeline.test.ts +++ b/apps/web/src/components/chat/engineRunCard.pipeline.test.ts @@ -532,3 +532,66 @@ describe("an engine Mate's run card, from the engine's record", () => { }, ); }); +// Milo's third stress run: a job's end woke Milo, the reply under the folded card ("…hasn't +// printed yet") vanished into it, and the final answer landed 798 px below the view. +it.each([ + { wake: "still at work", answered: false }, + { wake: "answered", answered: true }, +])( + "a wake's answer appears under the reply the person was reading, which stays where it stood: the wake $wake", + ({ answered }) => { + const first = noteItem(run1, 12, "The background wait hasn't printed yet.", { + at: t0 + 77_700, + answer: true, + } as never); + const rows = render({ + runs: [ + stressRun({ + summary: { ...stressSummary, answerItemId: `${run1}/i/12` }, + } as never), + engineRun(key.conversationId, 2, { + trigger: { kind: "wake", cause: "self", wakeId: null }, + joins: RunId.make(run1), + queuedAt: t0 + 101_400, + admittedAt: t0 + 101_400, + startedAt: t0 + 101_400, + ...(answered + ? { + endedAt: t0 + 103_300, + summary: { + items: 2, + calls: { command: 1 }, + answerItemId: `${run2}/i/21`, + lastItemSeq: 21, + }, + } + : { state: "running", turnState: "running", endedAt: null, end: null }), + } as never), + ], + items: [ + ...stressItems("completed").slice(0, -1), + first, + { + ...bash(20, 101_500, "cat /tmp/s/out", "Read what the wait printed"), + runId: run2, + id: `${run2}/i/20`, + } as Item, + ...(answered + ? [noteItem(run2, 21, "It printed done.", { at: t0 + 103_000, answer: true } as never)] + : []), + ], + isWorking: !answered, + }); + const said = rows.flatMap((row) => + row.kind === "message" && row.message.role === "assistant" ? [row.message.text] : [], + ); + expect(said).toEqual([ + "The background wait hasn't printed yet.", + ...(answered ? ["It printed done."] : []), + ]); + // The reply stands under its card, the wake's answer under it. + const card = rows.findIndex((row) => row.kind === "record"); + const reply = rows.findIndex((row) => row.kind === "message" && row.id === first.id); + expect(card).toBeLessThan(reply); + }, +); diff --git a/apps/web/test/scenarios/areas/c-mate/engine-card.scenario.ts b/apps/web/test/scenarios/areas/c-mate/engine-card.scenario.ts index 58b07ee9e..1ba35b096 100644 --- a/apps/web/test/scenarios/areas/c-mate/engine-card.scenario.ts +++ b/apps/web/test/scenarios/areas/c-mate/engine-card.scenario.ts @@ -106,73 +106,85 @@ describe("C: an engine Mate's run card in a live conversation", () => { it.layer(tempPostgresLayer, { excludeTestServices: true })((it) => { // Milo's stress run: the card waited on a background job, folded when the job ended, and the // run the job's end woke snapped it open 532 px in one frame and scrolled the page 768 px. - it.effect("a run its job's end wakes goes on in the folded card without moving the page", () => - Effect.gen(function* () { - const { s, chat, engine } = yield* longConversation; - const run = engine.personRun("Start the wait in the background"); - engine.item(run, { - kind: "call", - step: "command", - tool: { name: "Bash" }, - words: "Command run", - state: "done", - endedAt: 1791552412202, - input: "Bash: sleep 45 && echo done", - shows: { - toolName: "Bash", - command: "sleep 45 && echo done", - input: { description: "Wait 45 seconds in the background, then print done" }, - rawOutput: { - content: - "Command running in background with ID: b1job. Output is being written to: /tmp/x", + it.effect( + "a run its job's end wakes goes on in the folded card without moving the page, its answer under the reply the person was reading", + () => + Effect.gen(function* () { + const { s, chat, engine } = yield* longConversation; + const run = engine.personRun("Start the wait in the background"); + engine.item(run, { + kind: "call", + step: "command", + tool: { name: "Bash" }, + words: "Command run", + state: "done", + endedAt: 1791552412202, + input: "Bash: sleep 45 && echo done", + shows: { + toolName: "Bash", + command: "sleep 45 && echo done", + input: { description: "Wait 45 seconds in the background, then print done" }, + rawOutput: { + content: + "Command running in background with ID: b1job. Output is being written to: /tmp/x", + }, }, - }, - }); - const job = engine.item(run, { - kind: "work", - work: "session.w2", - workKind: "shell", - status: "running", - title: "Wait 45 seconds in the background, then print done", - }); - engine.note(run, "The background wait hasn't printed yet.", { kind: "completed" }); - yield* chat.then.text("Waiting for its background command"); - engine.update(job, { status: "completed" }); - yield* chat.then.noText("Waiting for its background command"); - yield* chat.then.noText("running in the background"); - // The fold eases shut; the page is still once it has. - yield* Effect.promise(() => - s.page.waitForFunction( - () => - [...document.querySelectorAll("[data-run-chat]")].at(-1)?.dataset - .runFold === "folded", - { polling: "raf", timeout: 8000 }, - ), - ); - yield* trace(s.page, "Start the wait in the background"); - const wake = engine.startRun(run); - engine.note(wake, "The background wait finished and printed done.", { - kind: "completed", - }); - yield* chat.then.text("The background wait finished and printed done."); - const frames = yield* readTrace(s.page, 1500); - expect(frames.length, "ASSERTION: the wake was sampled").toBeGreaterThan(20); - expect( - largestStep(frames, (frame) => frame.card), - "ASSERTION: the card never snaps open in one frame", - ).toBeLessThan(48); - expect( - Math.max(...frames.map((frame) => frame.card)) - - Math.min(...frames.map((frame) => frame.card)), - "ASSERTION: the folded card keeps its height while the woken run goes on in it", - ).toBeLessThan(48); - expect( - largestStep(frames, (frame) => frame.top), - "ASSERTION: the list never jumps", - ).toBeLessThan(120); - expect(frames.at(-1)!.end, "ASSERTION: the view ends at the conversation's end").toBe(0); - yield* s.then.noExternalNetwork; - }), + }); + const job = engine.item(run, { + kind: "work", + work: "session.w2", + workKind: "shell", + status: "running", + title: "Wait 45 seconds in the background, then print done", + }); + engine.note(run, "The background wait hasn't printed yet.", { kind: "completed" }); + yield* chat.then.text("Waiting for its background command"); + engine.update(job, { status: "completed" }); + yield* chat.then.noText("Waiting for its background command"); + yield* chat.then.noText("running in the background"); + // The fold eases shut; the page is still once it has. + yield* Effect.promise(() => + s.page.waitForFunction( + () => + [...document.querySelectorAll("[data-run-chat]")].at(-1)?.dataset + .runFold === "folded", + { polling: "raf", timeout: 8000 }, + ), + ); + // Milo's third stress run: the reply under the folded card vanished into it at the wake, + // and the wake's answer landed 798 px below the view. + yield* trace(s.page, "The background wait hasn't printed yet."); + const wake = engine.startRun(run); + engine.note(wake, "The background wait finished and printed done.", { + kind: "completed", + }); + yield* chat.then.text("The background wait finished and printed done."); + const frames = yield* readTrace(s.page, 1500); + expect(frames.length, "ASSERTION: the wake was sampled").toBeGreaterThan(20); + expect( + largestStep(frames, (frame) => frame.card), + "ASSERTION: the card never snaps open in one frame", + ).toBeLessThan(48); + expect( + Math.max(...frames.map((frame) => frame.card)) - + Math.min(...frames.map((frame) => frame.card)), + "ASSERTION: the folded card keeps its height while the woken run goes on in it", + ).toBeLessThan(48); + expect( + largestStep(frames, (frame) => frame.top), + "ASSERTION: the list never jumps", + ).toBeLessThan(120); + expect(frames.at(-1)!.end, "ASSERTION: the view ends at the conversation's end").toBe(0); + expect( + frames.every((frame) => frame.anchor !== null), + "ASSERTION: the reply the person was reading stays on screen through the wake", + ).toBe(true); + expect( + largestStep(frames, (frame) => frame.anchor), + "ASSERTION: the reply the person was reading never jumps as the wake answers", + ).toBeLessThan(60); + yield* s.then.noExternalNetwork; + }), ); // Milo's stress run: the question opening in the composer pushed the conversation up 250 px in From fb75e20c075e109601ccb266156cbe381f73a5d4 Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 22:09:50 +0200 Subject: [PATCH 10/19] fix(web): a background job that ended ran in the background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "… finished · in the background" read as if it still ran (Milo's stress runs 2 and 3): an ended job's line says where it ran, "ran in the background"; a running one still says "running in the background". --- apps/web/src/components/chat/MessagesTimeline.test.tsx | 2 +- apps/web/src/components/chat/RunChat.tsx | 2 +- apps/web/src/components/chat/backgroundLine.logic.test.ts | 8 ++++---- apps/web/src/components/chat/backgroundLine.logic.ts | 4 +++- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 2d5b95a6e..acb35599d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -2063,7 +2063,7 @@ describe("MessagesTimeline — the conversation", () => { // The task in its own words, where it ran, and what it reported in a // few words, on the line itself: nothing left to open. expect(markup).toContain("Run the smoke tests finished"); - expect(markup).toContain("in the background · 4 passed"); + expect(markup).toContain("ran in the background · 4 passed"); expect(markup).not.toContain("Show what it reported"); expect(markup).not.toContain("1 background task "); }); diff --git a/apps/web/src/components/chat/RunChat.tsx b/apps/web/src/components/chat/RunChat.tsx index ecdf97be1..0441055f4 100644 --- a/apps/web/src/components/chat/RunChat.tsx +++ b/apps/web/src/components/chat/RunChat.tsx @@ -2352,7 +2352,7 @@ function TaskBubble({ entry }: { readonly entry: WorkLogEntry }) { const disclosure = useDisclosure(); const failed = workEntryDisplayIndicatesToolFailure(entry); const words = `${taskTitle(entry)} ${failed ? "failed" : "finished"}`; - const where = entry.agentRole !== undefined ? "helper" : "in the background"; + const where = entry.agentRole !== undefined ? "helper" : "ran in the background"; const reported = Boolean(entry.detail?.trim()); const line = ( diff --git a/apps/web/src/components/chat/backgroundLine.logic.test.ts b/apps/web/src/components/chat/backgroundLine.logic.test.ts index a4b7f5c83..963fe777a 100644 --- a/apps/web/src/components/chat/backgroundLine.logic.test.ts +++ b/apps/web/src/components/chat/backgroundLine.logic.test.ts @@ -42,7 +42,7 @@ describe("backgroundLineOf", () => { name: "one finished, saying nothing more: nothing to open", items: [item("Run the soak test", "done")], words: "Run the soak test finished", - where: "in the background", + where: "ran in the background", failed: false, opens: [], }, @@ -50,7 +50,7 @@ describe("backgroundLineOf", () => { name: "one failed, saying how in a few words: on the line, nothing to open", items: [item("Run the failing job", "failed", "Exit code 3")], words: "Run the failing job failed", - where: "in the background · Exit code 3", + where: "ran in the background · Exit code 3", failed: true, opens: [], }, @@ -60,7 +60,7 @@ describe("backgroundLineOf", () => { item("Run the failing job", "failed", "Error: listen EADDRINUSE\n at Server.listen"), ], words: "Run the failing job failed", - where: "in the background", + where: "ran in the background", failed: true, opens: ["Run the failing job"], }, @@ -96,7 +96,7 @@ describe("backgroundLineOf", () => { name: "one whose session is gone", items: [item("Run the soak test", "lost")], words: "Run the soak test didn't report back", - where: "in the background", + where: "ran in the background", failed: false, opens: [], }, diff --git a/apps/web/src/components/chat/backgroundLine.logic.ts b/apps/web/src/components/chat/backgroundLine.logic.ts index 2cd54a7a1..e97261968 100644 --- a/apps/web/src/components/chat/backgroundLine.logic.ts +++ b/apps/web/src/components/chat/backgroundLine.logic.ts @@ -79,7 +79,9 @@ export function backgroundLineOf( if (items.length === 1 && only !== undefined) { // A short report reads on the line itself: nothing left to open. const inline = only.report !== null && reportsInline(only.report); - const at = only.state === "running" ? `running ${where}` : where; + // An ended job ran in the background: "finished · in the background" read as if it still ran + // (Milo's stress runs). + const at = only.state === "running" ? `running ${where}` : helpers ? where : `ran ${where}`; return { words: only.state === "running" ? only.title : `${only.title} ${STATE_WORD[only.state]}`, where: inline ? `${at} · ${only.report}` : at, From 417ca235ff0b2dd57b5b8052be678e5b6dc94d6c Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 22:09:51 +0200 Subject: [PATCH 11/19] fix(web): a check of a site's front page names the home page, never "/" "Checking / in the browser" said the path as words (Milo's stress runs 2 and 3). The now line, the check's row and the strip's heading say "the home page"; the caption keeps "/" for what counts pages and names pictures. --- apps/web/src/components/chat/BrowserStrip.tsx | 7 ++++--- apps/web/src/components/chat/RunChat.test.tsx | 5 ++++- apps/web/src/components/chat/RunChat.tsx | 7 ++++--- apps/web/src/components/chat/conversation.logic.ts | 8 ++++++++ apps/web/src/components/chat/runCard.logic.test.ts | 8 ++++++++ apps/web/src/components/chat/runCard.logic.ts | 3 ++- 6 files changed, 30 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/chat/BrowserStrip.tsx b/apps/web/src/components/chat/BrowserStrip.tsx index ead5494bc..0eead0a7a 100644 --- a/apps/web/src/components/chat/BrowserStrip.tsx +++ b/apps/web/src/components/chat/BrowserStrip.tsx @@ -30,6 +30,7 @@ import { useMateBrowserCallFrame } from "../../zerops/browserStreamLinks"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { browserCheckCaption, + browserPageWords, browserCheckDevice, browserCheckFailure, browserTakeState, @@ -236,11 +237,11 @@ export function BrowserStrip({ }; const heading = running - ? `Checking ${caption}` + ? `Checking ${browserPageWords(caption)}` : frames === 1 ? failedOnStage - ? `${caption} failed` - : `Checked ${caption}` + ? `${browserPageWords(caption)} failed` + : `Checked ${browserPageWords(caption)}` : [ plural(frames, "check", "checks"), strip.failures > 0 diff --git a/apps/web/src/components/chat/RunChat.test.tsx b/apps/web/src/components/chat/RunChat.test.tsx index 10ad2f651..8d9fe3fb9 100644 --- a/apps/web/src/components/chat/RunChat.test.tsx +++ b/apps/web/src/components/chat/RunChat.test.tsx @@ -832,7 +832,10 @@ describe("RunChat", () => { // Two pages of one host are said by name, as one is; more by their count, // and so are pages of two hosts, whose paths do not say which is which. it.each([ - { subjects: ["https://shop.dev/", "https://shop.dev/health"], words: "Checked / and /health" }, + { + subjects: ["https://shop.dev/", "https://shop.dev/health"], + words: "Checked the home page and /health", + }, { subjects: ["https://shop.dev/", "https://api.dev/"], words: "Checked 2 pages" }, // A path names a page on one host only: "/" on another port is not the // app's front page (Nova, 2026-09-28: "/missing and /" for port 9's "/"). diff --git a/apps/web/src/components/chat/RunChat.tsx b/apps/web/src/components/chat/RunChat.tsx index 0441055f4..25121928a 100644 --- a/apps/web/src/components/chat/RunChat.tsx +++ b/apps/web/src/components/chat/RunChat.tsx @@ -116,6 +116,7 @@ import { helpersBubbleOf } from "./helpersBubble.logic"; import { BrowserStrip, BrowserTakes } from "./BrowserStrip"; import { browserCheckCaption, + browserPageWords, browserTakeState, formatWorkDuration, operationLineWords, @@ -2068,11 +2069,11 @@ function ChecksBubble({ strip: recorded }: { readonly strip: BrowserStripModel } // As the now line said it while it ran: "Checking /status in the browser". const words = `${ running - ? `Checking ${browserCheckCaption(latest)}` + ? `Checking ${browserPageWords(browserCheckCaption(latest))}` : strip.views === 1 - ? `Checked ${browserCheckCaption(latest)}` + ? `Checked ${browserPageWords(browserCheckCaption(latest))}` : strip.views === 2 && pages.length === 2 && hosts.size === 1 - ? `Checked ${pages[0]} and ${pages[1]}` + ? `Checked ${browserPageWords(pages[0]!)} and ${browserPageWords(pages[1]!)}` : `Checked ${strip.views} pages` } in the browser`; const verdict = running diff --git a/apps/web/src/components/chat/conversation.logic.ts b/apps/web/src/components/chat/conversation.logic.ts index 25bde39cd..1d73cc66a 100644 --- a/apps/web/src/components/chat/conversation.logic.ts +++ b/apps/web/src/components/chat/conversation.logic.ts @@ -1820,6 +1820,14 @@ export function browserCheckCaption(operation: ZeropsOperation): string { return path === "" ? "/" : path; } +/** + * The page a check looked at, in words: its path, the front page by name — "Checked / in the + * browser" said the path, never the page (Milo's stress runs). + */ +export function browserPageWords(caption: string): string { + return caption === "/" ? "the home page" : caption; +} + export type BrowserDevice = "desktop" | "tablet" | "phone"; /** diff --git a/apps/web/src/components/chat/runCard.logic.test.ts b/apps/web/src/components/chat/runCard.logic.test.ts index 2d98296e6..a8b27aca2 100644 --- a/apps/web/src/components/chat/runCard.logic.test.ts +++ b/apps/web/src/components/chat/runCard.logic.test.ts @@ -304,6 +304,7 @@ const browser = (() => { }); return entry.kind === "operation" ? entry.operation : null; })()!; +const frontPage = { ...browser, subject: "https://shop.example.dev/" }; const deploy = (() => { const entry = operationEntry("d1", "t1", 1, { kind: "deploy", @@ -476,6 +477,13 @@ describe("the now line", () => { words: "Checking /status in the browser", face: { state: "working" }, }, + { + // Milo's stress runs read "Checking / in the browser". + name: "checking the front page in the browser, by name", + now: { kind: "operation", operation: frontPage }, + words: "Checking the home page in the browser", + face: { state: "working" }, + }, { name: "deploying", now: { kind: "operation", operation: deploy }, diff --git a/apps/web/src/components/chat/runCard.logic.ts b/apps/web/src/components/chat/runCard.logic.ts index 8f980133f..ed57e2527 100644 --- a/apps/web/src/components/chat/runCard.logic.ts +++ b/apps/web/src/components/chat/runCard.logic.ts @@ -13,6 +13,7 @@ import { quoteWords } from "@t3tools/shared/messagePreview"; import { browserCheckCaption, + browserPageWords, checksStrip, devServerRunning, formatWorkDuration, @@ -630,7 +631,7 @@ export function stepNowWords(step: WorkStep): string { /** A platform operation as the now line says it: a check names its page. */ export function operationNowWords(operation: ZeropsOperation): string { return operation.kind === "browser" - ? `Checking ${browserCheckCaption(operation)} in the browser` + ? `Checking ${browserPageWords(browserCheckCaption(operation))} in the browser` : operationLineWords(operation); } From ab28c4a8d7ef42a827d305f591f10c0869f93165 Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 22:09:56 +0200 Subject: [PATCH 12/19] fix(claude): a turn Claude opens on its own owns the command it streams before its first message A turn Claude Code opens to hand the model a background result started at its first assistant message, but its stream comes first: a command it streamed before that message had no turn, fell to the turn before, and closed there unreturned (Milo's third stress run: the wake's first command read "No result" on the previous run's card). The turn now opens at its stream's start; its first assistant message still names where it began for the resume cursor. --- .../src/provider/Layers/ClaudeAdapter.test.ts | 89 ++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 112 +++++++++++------- 2 files changed, 158 insertions(+), 43 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index a77d3c0b9..601a9ec9b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -7441,6 +7441,95 @@ describe("ClaudeAdapterLive", () => { ); }); + // Milo's third stress run: a turn Claude opened to hand the model a background result streamed + // its first command before its first assistant message; the command fell to the run before, as + // "No result". + it.effect( + "a turn Claude opens on its own owns the command it streams before its first message", + () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-wake", + uuid: "result-first", + } as unknown as SDKMessage); + yield* drainSdkMessages; + + const stream = (event: Record, uuid: string) => + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-wake", + uuid, + parent_tool_use_id: null, + event, + } as unknown as SDKMessage); + stream({ type: "message_start", message: { id: "msg-wake" } }, "stream-start"); + stream( + { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "toolu_wake", + name: "Bash", + input: { command: "cat /tmp/out", description: "Read the short job's output" }, + }, + }, + "stream-block", + ); + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-wake", + uuid: "assistant-wake", + parent_tool_use_id: null, + message: { + id: "msg-wake", + content: [ + { + type: "tool_use", + id: "toolu_wake", + name: "Bash", + input: { command: "cat /tmp/out", description: "Read the short job's output" }, + }, + ], + }, + } as unknown as SDKMessage); + yield* drainSdkMessages; + + const started = runtimeEvents.findLast((event) => event.type === "turn.started"); + const call = runtimeEvents.find( + (event) => event.type === "item.started" && event.itemId === "toolu_wake", + ); + assert.ok(started !== undefined && call !== undefined); + assert.isBelow(runtimeEvents.indexOf(started), runtimeEvents.indexOf(call)); + assert.strictEqual(call.turnId, started.turnId); + assert.strictEqual( + runtimeEvents.filter((event) => event.type === "turn.started").length, + 2, + ); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + it.effect("drops an unusable Claude reset time, not the row or the session", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index baea5f00d..81b89e75c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -307,6 +307,8 @@ interface ClaudeTurnState { readonly thinkingSnapshotIds: Set; /** The Mate's model response streaming now (`message_start`): its calls are one batch. */ responseId?: string | undefined; + /** A synthetic turn its stream opened: its first assistant message is still to name it. */ + startMessagePending?: boolean; } interface AssistantTextBlockState { @@ -2992,6 +2994,64 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* updateResumeCursor(context); }); + /** + * A turn Claude Code opens on its own (a background result handed to the model between prompts): + * from its first assistant message, or from its stream's start when that comes first — a call it + * streams before its first message is that turn's, never the turn before (Milo's third stress + * run read a wake's first command as the previous run's "No result"). + */ + const startSyntheticTurn = Effect.fn("startSyntheticTurn")(function* ( + context: ClaudeSessionContext, + startMessageId: string | undefined, + ) { + const turnId = TurnId.make(yield* randomUUIDv4); + const startedAt = yield* nowIso; + if (startMessageId !== undefined) context.turnStartMessageIds.push(startMessageId); + context.turnState = { + turnId, + startedAt, + synthetic: true, + ...(startMessageId === undefined ? { startMessagePending: true } : {}), + assistantTextBlocks: new Map(), + assistantTextBlockOrder: [], + capturedProposedPlanKeys: new Set(), + latestAssistantUsage: undefined, + compactedSinceLatestAssistantUsage: false, + nextSyntheticAssistantBlockIndex: -1, + authenticationFailureMessage: undefined, + rejectedRateLimitTypes: new Set(), + latestAssistantRateLimited: false, + emittedThinkingText: false, + thinkingSnapshotIds: new Set(), + }; + context.session = { + ...context.session, + status: "running", + activeTurnId: turnId, + updatedAt: startedAt, + }; + yield* updateResumeCursor(context); + const turnStartedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.started", + eventId: turnStartedStamp.eventId, + provider: PROVIDER, + createdAt: turnStartedStamp.createdAt, + threadId: context.session.threadId, + turnId, + payload: {}, + providerRefs: { + ...nativeProviderRefs(context), + providerTurnId: turnId, + }, + raw: { + source: "claude.sdk.message", + method: "claude/synthetic-turn-start", + payload: {}, + }, + }); + }); + const handleStreamEvent = Effect.fn("handleStreamEvent")(function* ( context: ClaudeSessionContext, message: SDKMessage, @@ -3028,6 +3088,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } } + // Claude's own turn streaming before its first message: it opens here, so what it streams is + // its own. + if (event.type === "message_start" && !context.turnState && !streamParentToolUseId) { + yield* startSyntheticTurn(context, undefined); + } if (event.type === "message_start" && context.turnState && !streamParentToolUseId) { context.turnState.emittedThinkingText = false; context.turnState.responseId = trimmedString(event.message.id); @@ -3764,51 +3829,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* updateResumeCursor(context); return; } - const turnId = TurnId.make(yield* randomUUIDv4); - const startedAt = yield* nowIso; + yield* startSyntheticTurn(context, message.uuid); + } else if (context.turnState.startMessagePending === true) { + // A turn its stream opened: its first assistant message names where it began. + context.turnState.startMessagePending = false; context.turnStartMessageIds.push(message.uuid); - context.turnState = { - turnId, - startedAt, - synthetic: true, - assistantTextBlocks: new Map(), - assistantTextBlockOrder: [], - capturedProposedPlanKeys: new Set(), - latestAssistantUsage: undefined, - compactedSinceLatestAssistantUsage: false, - nextSyntheticAssistantBlockIndex: -1, - authenticationFailureMessage: undefined, - rejectedRateLimitTypes: new Set(), - latestAssistantRateLimited: false, - emittedThinkingText: false, - thinkingSnapshotIds: new Set(), - }; - context.session = { - ...context.session, - status: "running", - activeTurnId: turnId, - updatedAt: startedAt, - }; yield* updateResumeCursor(context); - const turnStartedStamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "turn.started", - eventId: turnStartedStamp.eventId, - provider: PROVIDER, - createdAt: turnStartedStamp.createdAt, - threadId: context.session.threadId, - turnId, - payload: {}, - providerRefs: { - ...nativeProviderRefs(context), - providerTurnId: turnId, - }, - raw: { - source: "claude.sdk.message", - method: "claude/synthetic-turn-start", - payload: {}, - }, - }); } yield* presentCalls(context, message); From 02a16c67621f2573c9eb54a9f9ecd2abd10eb435 Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 22:10:08 +0200 Subject: [PATCH 13/19] test(web): a run-card oracle holds an engine Mate's card against what its Mate did Seeded trees play on the running engine (the live layer over a scripted Claude, SQLite, the test clock): the Mate's turn starts helpers and background commands, helpers run calls and start their own commands and helpers, each piece of work ends its own way (finished, failed with an exit code, stopped by the Mate or by the person, lost to a restart or a crash), an end after the turn wakes the Mate, whose wake starts more; the person sends, steers, stops and reloads between it all. Claude's events are its adapter's shapes (agentId, parentToolUseId, task linkage). The record reaches the card through the client's own adapter and store over the engine's wire, frames and pages through the contract schemas, into the card's logic as ChatView feeds it. After every op the oracle holds the card against the tree: whose each line is and on which card, each piece's true end (and never back), the waiting line, counts, the helpers' surface and the dock; at a reload and at the end, a fresh client's faces and opened cards against the live ones. The table keeps each defect it found as its smallest tree; the gate's 24 fixed seeds run in the chat gate's engine proof (ENGINE_PROOF_RANDOM=n adds fresh seeds, ENGINE_PROOF_SEED=s replays one, minimised). --- apps/web/test/engine-oracle/claude.ts | 143 +++++ apps/web/test/engine-oracle/harness.ts | 142 +++++ apps/web/test/engine-oracle/oracle.ts | 308 ++++++++++ apps/web/test/engine-oracle/reading.ts | 284 +++++++++ .../test/engine-oracle/runCard.oracle.test.ts | 156 +++++ apps/web/test/engine-oracle/tree.ts | 570 ++++++++++++++++++ apps/web/test/engine-oracle/tsconfig.json | 28 + apps/web/test/engine-oracle/world.ts | 275 +++++++++ apps/web/tsconfig.json | 2 +- apps/web/vite.config.ts | 19 +- scripts/chat-gate.test.ts | 8 +- scripts/chat-gate.ts | 32 +- scripts/gate-changed.ts | 13 + 13 files changed, 1974 insertions(+), 6 deletions(-) create mode 100644 apps/web/test/engine-oracle/claude.ts create mode 100644 apps/web/test/engine-oracle/harness.ts create mode 100644 apps/web/test/engine-oracle/oracle.ts create mode 100644 apps/web/test/engine-oracle/reading.ts create mode 100644 apps/web/test/engine-oracle/runCard.oracle.test.ts create mode 100644 apps/web/test/engine-oracle/tree.ts create mode 100644 apps/web/test/engine-oracle/tsconfig.json create mode 100644 apps/web/test/engine-oracle/world.ts diff --git a/apps/web/test/engine-oracle/claude.ts b/apps/web/test/engine-oracle/claude.ts new file mode 100644 index 000000000..b845ea328 --- /dev/null +++ b/apps/web/test/engine-oracle/claude.ts @@ -0,0 +1,143 @@ +/** + * Claude Code's helpers and background work, as its adapter reports them + * (`apps/server/src/provider/Layers/ClaudeAdapter.ts`): each event here is the one the adapter + * offers for the SDK message named beside it, with the payload keys it sets. A helper's own calls + * carry the launch's `parentToolUseId` and the helper's task id as `agentId` (`:3242`); a task a + * helper's tool started carries that helper as its owning `agentId` (`task_started`, `:4139`), and + * every later report of a task repeats its linkage (`taskLinkageFor`, `:1471`). + */ +import * as Effect from "effect/Effect"; + +import type { EngineWorld } from "../../../server/src/engine/testing/pump/engineWorld.ts"; + +type Emit = (type: string, fields: Record) => Effect.Effect; + +const emitter = + (w: EngineWorld): Emit => + (type, fields) => + w.agent((agent, thread) => agent.emit(type, thread, fields)).pipe(Effect.asVoid); + +/** A task's identity as every report of it repeats it (`taskLinkageFor`). */ +export interface TaskLinkage { + readonly taskId: string; + readonly taskType: "local_bash" | "local_agent"; + readonly title: string; + readonly toolUseId: string; + /** The helper whose tool started it. */ + readonly agentId?: string; +} + +const linkage = (task: TaskLinkage) => ({ + taskType: task.taskType, + ...(task.agentId === undefined ? {} : { agentId: task.agentId }), + title: task.title, + toolUseId: task.toolUseId, +}); + +/** Who made a call: the Mate, or a helper by its task id and the launch it runs under. */ +export type CallOwner = null | { readonly helper: string; readonly launch: string }; + +const owned = (owner: CallOwner) => + owner === null ? {} : { agentId: owner.helper, parentToolUseId: owner.launch }; + +/** + * A call's start (`content_block_start`, `:3263`), and its result (`user` tool_result, `:3530`): + * the Mate's has an update before its completion, a helper's only its completion (`:3540`). + */ +export const call = ( + w: EngineWorld, + input: { + readonly id: string; + readonly owner: CallOwner; + readonly tool: "Bash" | "Agent" | "TaskStop"; + readonly args: Record; + readonly result: string; + readonly failed: boolean; + /** Something said between the start and the result: a task the call started, say. */ + readonly between?: Effect.Effect; + }, +) => + Effect.gen(function* () { + const emit = emitter(w); + const itemType = + input.tool === "Bash" + ? "command_execution" + : input.tool === "Agent" + ? "collab_agent_tool_call" + : "dynamic_tool_call"; + const title = + input.tool === "Bash" + ? "Command run" + : input.tool === "Agent" + ? "Subagent task" + : "Tool call"; + const command = input.args.command; + const detail = + typeof command === "string" + ? `${input.tool}: ${command}` + : input.tool === "Agent" + ? String(input.args.description) + : `${input.tool}: ${String(input.args.task_id ?? "")}`; + const head = { itemType, title, detail, ...owned(input.owner) }; + yield* emit("item.started", { + itemId: input.id, + payload: { ...head, status: "inProgress", data: { toolName: input.tool, input: input.args } }, + }); + if (input.between !== undefined) yield* input.between; + const data = { + toolName: input.tool, + input: input.args, + result: { + type: "tool_result", + tool_use_id: input.id, + content: input.result, + ...(input.failed ? { is_error: true } : {}), + }, + }; + if (input.owner === null) + yield* emit("item.updated", { + itemId: input.id, + payload: { ...head, status: input.failed ? "failed" : "inProgress", data }, + }); + yield* emit("item.completed", { + itemId: input.id, + payload: { ...head, status: input.failed ? "failed" : "completed", data }, + }); + }); + +/** `task_started` (`:4206`): a background shell, or a helper launched in the background. */ +export const taskStarted = (w: EngineWorld, task: TaskLinkage) => + emitter(w)("task.started", { + payload: { taskId: task.taskId, description: task.title, ...linkage(task) }, + }); + +/** `task_progress` (`:4243`): what the task runs now, never what it is. */ +export const taskProgress = (w: EngineWorld, task: TaskLinkage, now: string) => + emitter(w)("task.progress", { + payload: { taskId: task.taskId, description: now, summary: now, ...linkage(task) }, + }); + +/** `task_updated` with `killed` (`:4262`): TaskStop's kill, said as `cancelled`. */ +export const taskKilled = (w: EngineWorld, task: TaskLinkage) => + emitter(w)("task.updated", { + payload: { taskId: task.taskId, status: "cancelled", ...linkage(task) }, + }); + +/** `task_notification` (`:4295`): the task's end, with the summary Claude Code writes of it. */ +export const taskNotification = ( + w: EngineWorld, + task: TaskLinkage, + status: "completed" | "failed" | "stopped", + summary: string, +) => + emitter(w)("task.completed", { + payload: { + taskId: task.taskId, + status, + summary, + ...(task.taskType === "local_bash" + ? { outputFile: `/tmp/claude/${task.taskId}.output` } + : {}), + ...linkage(task), + }, + }); diff --git a/apps/web/test/engine-oracle/harness.ts b/apps/web/test/engine-oracle/harness.ts new file mode 100644 index 000000000..9ed72258d --- /dev/null +++ b/apps/web/test/engine-oracle/harness.ts @@ -0,0 +1,142 @@ +/** + * Plays a tree on a fresh world with the oracle after every op, and minimises a failing tree to + * the fewest ops that still break the same rule. + */ +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import { engineFactId } from "@t3tools/client-runtime/data"; + +import { makeRng } from "../../../server/src/engine/testing/rng.ts"; +import { checkFace, checkReading, checkSame, checkSince, type Finding } from "./oracle.ts"; +import { readConversation, type Reading } from "./reading.ts"; +import { drain, emptyModel, play, type Model, type Op } from "./tree.ts"; +import { KEY, makeOracleWorld, type Client, type OracleWorld } from "./world.ts"; + +export interface Verdict { + readonly findings: ReadonlyArray; + /** The ops the world allowed, in order, the drain's included. */ + readonly played: ReadonlyArray; + /** The index in the tree of the op after which the oracle first spoke; -1 for the end. */ + readonly at: number; +} + +/** Opens every card: each run's lines paged in, both ways, until the client holds them all. */ +const openEveryCard = (world: OracleWorld, client: Client, model: Model) => + Effect.gen(function* () { + for (let round = 0; round < 40; round++) { + let asked = false; + for (const runId of model.cardOfRun.keys()) { + const span = client.reads().fact("mateEngineSpan", engineFactId(KEY.environmentId, runId)); + if (span.kind !== "known") continue; + if (span.value.from !== null) + asked = client.conversations.readRunPage(KEY, runId, "earlier") || asked; + if (span.value.to !== null) + asked = client.conversations.readRunPage(KEY, runId, "later") || asked; + } + yield* world.settle; + if (!asked) return; + } + }); + +/** A fresh client on the same record: its closed faces, then every card opened, against the live. */ +const reloadAgainst = (world: OracleWorld, live: Client, model: Model) => + Effect.gen(function* () { + const fresh = world.openClient(); + yield* world.settle; + const now = yield* Clock.currentTimeMillis; + const findings: Finding[] = []; + const liveReading = readConversation(live.reads(), KEY, now); + const face = readConversation(fresh.reads(), KEY, now); + if (liveReading !== null && face !== null) findings.push(...checkFace(liveReading, face)); + // What a reload shows before any card opens — its helpers, its dock — holds on its own. + if (face !== null) findings.push(...checkReading(model, face, false)); + yield* openEveryCard(world, fresh, model); + yield* openEveryCard(world, live, model); + const opened = readConversation(fresh.reads(), KEY, now); + const liveOpened = readConversation(live.reads(), KEY, now); + if (liveOpened !== null && opened !== null) findings.push(...checkSame(liveOpened, opened)); + fresh.close(); + return findings; + }); + +export const playTree = (ops: ReadonlyArray, options: { readonly drainSeed?: number } = {}) => + Effect.scoped( + Effect.gen(function* () { + const world = yield* makeOracleWorld; + const live = world.openClient(); + yield* world.settle; + const model = emptyModel(); + let before: Reading | null = null; + const look = (settled: boolean) => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const reading = readConversation(live.reads(), KEY, now); + if (reading === null) return []; + const findings = [ + ...checkReading(model, reading, settled), + ...checkSince(before, reading), + ]; + before = reading; + return findings; + }); + for (const [index, op] of ops.entries()) { + const done = yield* play(world, model, op); + if (!done) { + model.skipped += 1; + continue; + } + model.played.push(op); + const findings = yield* look(false); + if (op.op === "reload") findings.push(...(yield* reloadAgainst(world, live, model))); + if (findings.length > 0) + return { findings, played: model.played, at: index } satisfies Verdict; + } + const drained = yield* drain(world, model, makeRng(options.drainSeed ?? 1)); + model.played.push(...drained); + const findings = yield* look(true); + findings.push(...(yield* reloadAgainst(world, live, model))); + return { findings, played: model.played, at: -1 } satisfies Verdict; + }), + ); + +/** A finding's class: its rule and words with the tree's ids and numbers taken out. */ +export const classOf = (finding: Finding) => { + const words = finding.words + .replace(/mate\/r\/\d+/gu, "R") + .replace(/\b[CJH]\d+\b/gu, "X") + .replace(/\d+/gu, "n"); + // A reload's difference is classed by where it is, not by what each side said. + return `${finding.rule}: ${finding.rule === "same" ? words.split(":")[0] : words}`; +}; + +/** + * The fewest ops (ddmin over the played ops) that still break the rule of `target`: each tree + * tried is played whole on a fresh world. + */ +export const minimise = (ops: ReadonlyArray, target: string) => + Effect.gen(function* () { + const fails = (tree: ReadonlyArray) => + Effect.map(playTree(tree), (verdict) => + verdict.findings.some((finding) => classOf(finding) === target), + ); + let current = [...ops]; + let chunks = 2; + while (current.length >= 2) { + const size = Math.ceil(current.length / chunks); + let reduced = false; + for (let start = 0; start < current.length; start += size) { + const without = [...current.slice(0, start), ...current.slice(start + size)]; + if (without.length > 0 && (yield* fails(without))) { + current = without; + chunks = Math.max(chunks - 1, 2); + reduced = true; + break; + } + } + if (!reduced) { + if (chunks >= current.length) break; + chunks = Math.min(chunks * 2, current.length); + } + } + return current; + }); diff --git a/apps/web/test/engine-oracle/oracle.ts b/apps/web/test/engine-oracle/oracle.ts new file mode 100644 index 000000000..6950ca465 --- /dev/null +++ b/apps/web/test/engine-oracle/oracle.ts @@ -0,0 +1,308 @@ +/** + * The oracle: after every op, what the live client reads is held against the tree (what happened, + * whose it is, how it ended) and against itself a moment before; at a reload, and at the end, a + * fresh client's reading of the same record is held against the live one. Each finding names its + * rule — the product sentence it breaks — so a minimised tree is one defect. + */ +import type { Model, Status } from "./tree.ts"; +import type { CardReading, Reading } from "./reading.ts"; + +export interface Finding { + readonly rule: Rule; + readonly words: string; +} + +export const RULES = { + same: "a reload reads every card as the live client read it", + counts: "a settled card counts the Mate's own commands and helpers, as the tree ran them", + owner: + "every line sits on the card of the run that started it, under the Mate or helper whose it is", + whole: "every piece of the Mate's own work is on its card, once", + ends: "every piece of work reads its true end — finished, failed with its exit, stopped, didn't report back", + back: "nothing that ended reads running again, and a card never loses a line it showed", + settles: "once everything ended, no card waits or works", +} as const; +export type Rule = keyof typeof RULES; + +/** How a background job's line says each end. */ +const JOB_STATE: Record = { + running: "running", + completed: "done", + failed: "failed", + stopped: "stopped", + lost: "lost", +}; + +/** How the helpers' surface says each end. */ +const HELPER_STATE: Record> = { + running: ["pending", "running", "waiting"], + completed: ["completed"], + failed: ["failed"], + stopped: ["cancelled"], + lost: ["interrupted"], +}; + +const count = (words: string | null, noun: string): number => { + const found = words?.match(new RegExp(`(\\d+|a|an) ${noun}s?\\b`, "u"))?.[1]; + return found === undefined ? 0 : found === "a" || found === "an" ? 1 : Number(found); +}; + +/** The checks of one reading against the tree. */ +export function checkReading(model: Model, reading: Reading, settled: boolean): Finding[] { + const findings: Finding[] = []; + const say = (rule: Rule, words: string) => findings.push({ rule, words }); + const entity = (id: string) => model.entities.get(id); + const mates = (card: string, kind: "call" | "job" | "helper") => + [...model.entities.values()].filter( + (each) => each.owner === null && each.kind === kind && each.card === card, + ); + + for (const card of reading.cards) { + // (c) Every line on the card is the Mate's own, from a run of this card. + for (const step of card.steps) { + const it = entity(step.what); + if (it === undefined) continue; + // A helper is its row of helpers, never its launch call's step: a launch returns at once, + // and its step read "1s" while the helper worked on (Milo's third stress run). + if (it.kind === "helper") + say("ends", `${card.card} draws ${it.id}'s launch as a step reading ${step.state}`); + else if (it.owner !== null) + say( + "owner", + `${card.card} draws ${it.id}, ${it.owner}'s own ${it.kind}, as the Mate's step`, + ); + else if (it.card !== card.card) + say("owner", `${card.card} draws ${it.id}, which belongs on ${it.card}`); + } + for (const spawn of card.spawns) + for (const id of spawn.helpers) { + const it = entity(id); + if (it === undefined) continue; + if (it.owner !== null) + say( + "owner", + `${card.card} says "${spawn.words}" counting ${id}, which ${it.owner} started`, + ); + else if (it.card !== card.card) + say( + "owner", + `${card.card} says "${spawn.words}" counting ${id}, which belongs on ${it.card}`, + ); + } + for (const line of card.background) + for (const job of line.jobs) { + const it = entity(job.what); + if (it === undefined) continue; + if (it.owner !== null) + say("owner", `${card.card}'s line "${line.words}" lists ${it.id}, ${it.owner}'s own job`); + else if (it.card !== card.card) + say( + "owner", + `${card.card}'s line "${line.words}" lists ${it.id}, which belongs on ${it.card}`, + ); + } + + // (d) Each job and helper the card names reads how it stands. + const jobStates = new Map>(); + const note = (id: string, state: string) => + jobStates.set(id, (jobStates.get(id) ?? new Set()).add(state)); + for (const step of card.steps) if (step.job !== undefined) note(step.what, step.job.state); + for (const line of card.background) for (const job of line.jobs) note(job.what, job.state); + for (const [id, states] of jobStates) { + const it = entity(id); + if (it === undefined || it.owner !== null) continue; + if (states.size > 1) + say("ends", `${card.card} reads ${id} as ${[...states].join(" and ")} at once`); + const want = JOB_STATE[it.status]; + if (!states.has(want)) + say("ends", `${card.card} reads ${id} as ${[...states].join("/")}; it ${it.status}`); + } + for (const step of card.steps) { + const it = entity(step.what); + if (it?.status === "failed" && it.kind === "job" && it.exit !== undefined) + if (step.job !== undefined && !(step.job.report ?? "").includes(String(it.exit))) + say( + "ends", + `${card.card} reads ${id(it)} failed with no exit code (it exited ${it.exit})`, + ); + } + for (const spawn of card.spawns) { + const members = spawn.helpers.flatMap((each) => entity(each) ?? []); + const failed = members.some((each) => each.status === "failed"); + if (failed && !spawn.failed) + say("ends", `${card.card}'s "${spawn.words}" has no failure mark, though a helper failed`); + // Once none works, how each that did not finish ended is said on the bubble. + const ends = [ + ["failed", "failed"], + ["stopped", "stopped"], + ["lost", "didn't report back"], + ] as const; + if (!members.some((each) => each.status === "running")) + for (const [status, words] of ends) { + const n = members.filter((each) => each.status === status).length; + if (n > 0 && !(spawn.ended ?? "").includes(`${n} ${words}`)) + say( + "ends", + `${card.card}'s "${spawn.words}" says ${spawn.ended ?? "nothing"}; ${n} of them ${status}`, + ); + } + } + + // (b) A settled card's counts. + if (!card.live && card.effort !== null) { + const commands = mates(card.card, "call").length + mates(card.card, "job").length; + const helpers = mates(card.card, "helper").length; + const said = { + commands: count(card.effort, "command"), + helpers: count(card.effort, "helper"), + }; + if (said.commands !== commands || said.helpers !== helpers) + say( + "counts", + `${card.card} reads "${card.effort}"; the Mate ran ${commands} commands and ${helpers} helpers there`, + ); + } + // (d) What a card whose runs are over waits on: the Mate's own commands still running, and + // its helpers (with whatever they started) still at it. + if (card.line.startsWith("Waiting for its") || /^Waiting for \d+ background/u.test(card.line)) { + const running = (kind: "job" | "helper") => + [...model.entities.values()].filter( + (each) => each.kind === kind && each.status === "running" && each.card === card.card, + ); + const commands = running("job").filter((each) => each.owner === null).length; + const helpers = + running("helper").length + running("job").filter((each) => each.owner !== null).length; + const saidCommands = count( + card.line.replace(/\bits background command\b/u, "a background command"), + "background command", + ); + const saidHelpers = card.line.includes("helpers"); + if (saidCommands !== commands || saidHelpers !== helpers > 0) + say( + "ends", + `${card.card} reads "${card.line}"; ${commands} of the Mate's commands and ${helpers} helpers' pieces still run`, + ); + } + if (settled && card.live) + say("settles", `${card.card} still reads "${card.line}" with everything ended`); + } + + // The helpers' surface: each helper once, in its state, under the helper that started it. + for (const it of model.entities.values()) { + if (it.kind !== "helper" || it.card === null) continue; + const rows = reading.helpers.filter((row) => row.what === it.id); + if (rows.length !== 1) { + say("whole", `the helpers' surface lists ${it.id} ${rows.length} times`); + continue; + } + const row = rows[0]!; + if (row.parent !== it.owner) + say( + "owner", + `the helpers' surface puts ${it.id} under ${row.parent ?? "the Mate"}; ${it.owner ?? "the Mate"} started it`, + ); + if (!HELPER_STATE[it.status].includes(row.status)) + say("ends", `the helpers' surface reads ${it.id} as ${row.status}; it ${it.status}`); + } + // The dock: the Mate's own background jobs, never a helper's. + for (const row of reading.dock) { + const it = entity(row.what); + if (it === undefined) continue; + if (it.owner !== null) say("owner", `the dock lists ${it.id}, ${it.owner}'s own job`); + else if ( + row.state !== JOB_STATE[it.status] && + !(it.status === "completed" && row.state === "done") + ) + say("ends", `the dock reads ${it.id} as ${row.state}; it ${it.status}`); + } + return findings; +} + +const id = (it: { readonly id: string }) => it.id; + +/** + * What the live client read, against what it read a moment before: an entity that read ended never + * reads running again; a card never loses a line it showed. + */ +export function checkSince(before: Reading | null, now: Reading): Finding[] { + if (before === null) return []; + const findings: Finding[] = []; + const statesOf = (reading: Reading) => { + const states = new Map(); + for (const card of reading.cards) { + for (const step of card.steps) if (step.job) states.set(step.what, step.job.state); + for (const line of card.background) + for (const job of line.jobs) states.set(job.what, job.state); + } + for (const row of reading.helpers) states.set(row.what, row.status); + return states; + }; + const was = statesOf(before); + for (const [what, state] of statesOf(now)) { + const prior = was.get(what); + if (prior !== undefined && prior !== "running" && prior !== "pending" && state === "running") + findings.push({ rule: "back", words: `${what} read ${prior}, then running again` }); + } + const linesOf = (card: CardReading) => card.steps.map((step) => step.what); + for (const card of before.cards) { + const after = now.cards.find((each) => each.card === card.card); + const lost = linesOf(card).filter((line) => !after || !linesOf(after).includes(line)); + if (lost.length > 0) + findings.push({ + rule: "back", + words: `${card.card} lost ${lost.join(", ")}${after === undefined ? " (the card went)" : ""}`, + }); + } + return findings; +} + +/** A fresh client's reading against the live one: the first place they differ. */ +export function checkSame(live: Reading, reload: Reading): Finding[] { + const findings: Finding[] = []; + const diff = (path: string, a: unknown, b: unknown) => { + const left = JSON.stringify(a); + const right = JSON.stringify(b); + if (left !== right) + findings.push({ rule: "same", words: `${path}: live ${left}, after a reload ${right}` }); + }; + const cards = new Set([...live.cards, ...reload.cards].map((card) => card.card)); + for (const card of cards) { + const a = live.cards.find((each) => each.card === card); + const b = reload.cards.find((each) => each.card === card); + if (a === undefined || b === undefined) { + diff(`${card}`, a === undefined ? null : "card", b === undefined ? null : "card"); + continue; + } + diff(`${card} line`, a.line, b.line); + diff(`${card} effort`, a.effort, b.effort); + diff(`${card} steps`, a.steps, b.steps); + diff(`${card} helpers started`, a.spawns, b.spawns); + diff(`${card} reports`, a.reports, b.reports); + diff(`${card} background lines`, a.background, b.background); + } + diff("the helpers' surface", live.helpers, reload.helpers); + diff("the dock", live.dock, reload.dock); + return findings; +} + +/** A closed card's face — its line, its effort, the lines under it — before any of it is opened. */ +export function checkFace(live: Reading, reload: Reading): Finding[] { + const findings: Finding[] = []; + for (const card of reload.cards) { + const was = live.cards.find((each) => each.card === card.card); + if (was === undefined || was.live) continue; + const face = (each: CardReading) => ({ + line: each.line, + effort: each.effort, + background: each.background.map((line) => line.words), + }); + const left = JSON.stringify(face(was)); + const right = JSON.stringify(face(card)); + if (left !== right) + findings.push({ + rule: "same", + words: `${card.card}'s closed face: live ${left}, after a reload ${right}`, + }); + } + return findings; +} diff --git a/apps/web/test/engine-oracle/reading.ts b/apps/web/test/engine-oracle/reading.ts new file mode 100644 index 000000000..be0b128cb --- /dev/null +++ b/apps/web/test/engine-oracle/reading.ts @@ -0,0 +1,284 @@ +/** + * What a person reads of an engine conversation, from a client's store: each run card as the + * timeline draws it (`deriveMessagesTimelineRows`, fed as `ChatView` and `MessagesTimeline` feed + * it for an engine Mate), the helpers' surface (`deriveAgentPanelModel`) and the dock's background + * band (`foldBackgroundTasks`) — in words and states, never in markup. Everything a person reads + * names the tree's entity it is about by the id the generator wrote into its words (`C4`, `J2`, + * `H3`), so the oracle can hold each line against the record and the tree. + */ +import { + engineCardPaging, + engineRunCards, + engineThreadOf, + type EngineConversationKey, +} from "@t3tools/client-runtime/data"; +import { + deriveAgentPanelModel, + foldSubagentActivities, + type AgentPanelModel, +} from "@t3tools/client-runtime/state/subagentRuntime"; +import type { OrchestrationThreadActivity } from "@t3tools/contracts"; + +import { foldBackgroundTasks } from "../../src/components/chat/conversationDock.logic"; +import { helpersBubbleOf } from "../../src/components/chat/helpersBubble.logic"; +import { + backgroundLineOf, + jobItems, + taskItems, +} from "../../src/components/chat/backgroundLine.logic"; +import { + deriveMessagesTimelineRows, + helperFinishesOf, + type MessagesTimelineRow, + type RecordItem, +} from "../../src/components/chat/MessagesTimeline.logic"; +import { nowLineOf, nowLineWords } from "../../src/components/chat/runCard.logic"; +import { runEffortWords } from "../../src/components/chat/runResult.logic"; +import { + deriveActiveWorkStartedAt, + derivePhase, + deriveTimelineEntries, + deriveWorkLogEntries, + type WorkLogEntry, +} from "../../src/session-logic"; +import type { ChatMessage } from "../../src/types"; + +type Reads = Parameters[0]; + +/** The tree's entity a line is about, by the id written into its words. */ +export const entityOf = (text: string | null | undefined): string | null => + text?.match(/\b([CJH]\d+)\b/u)?.[1] ?? null; + +export interface StepReading { + /** The entity it is (`C4`, `J2`), or its words when they name none. */ + readonly what: string; + readonly state: string; + /** A command it sent to the background: the job as its line says it. */ + readonly job?: { readonly state: string; readonly report: string | null }; +} + +export interface SpawnReading { + /** "Started 3 helpers". */ + readonly words: string; + /** The helpers it names, by entity. */ + readonly helpers: ReadonlyArray; + readonly failed: boolean; + readonly live: boolean; + /** How those that did not finish ended, once none works: "1 failed, 2 stopped". */ + readonly ended: string | null; +} + +export interface CardReading { + readonly card: string; + readonly live: boolean; + /** Its line: what it does now, or who worked and for how long. */ + readonly line: string; + /** What its runs came to: "3 commands · 2 helpers". */ + readonly effort: string | null; + readonly steps: ReadonlyArray; + readonly spawns: ReadonlyArray; + /** A task or helper reporting back, where its result reached the run. */ + readonly reports: ReadonlyArray; + /** The background lines under it, and each job they list. */ + readonly background: ReadonlyArray<{ + readonly words: string; + readonly jobs: ReadonlyArray<{ readonly what: string; readonly state: string }>; + }>; +} + +export interface Reading { + readonly cards: ReadonlyArray; + /** The helpers' surface: each helper, its state. */ + readonly helpers: ReadonlyArray<{ + readonly what: string; + readonly status: string; + readonly parent: string | null; + }>; + /** The dock's band: each background task, its state. */ + readonly dock: ReadonlyArray<{ readonly what: string; readonly state: string }>; +} + +const stepWhat = (entry: WorkLogEntry | undefined, words: string | null, code: string | null) => + entityOf(words) ?? entityOf(code) ?? entityOf(entry?.detail) ?? words ?? code ?? "?"; + +function stepsOf(items: ReadonlyArray): StepReading[] { + const steps: StepReading[] = []; + for (const item of items) { + if (item.kind !== "step") continue; + for (const part of item.parts ?? [item]) { + const step = part.step; + steps.push({ + what: stepWhat(step.entries[0], step.words, step.code), + state: step.noResult === undefined ? step.state : `${step.state}/${step.noResult}`, + ...(step.background === undefined + ? {} + : { job: { state: step.background.state, report: step.background.report } }), + }); + } + } + return steps; +} + +function spawnsOf(items: ReadonlyArray, panel: AgentPanelModel): SpawnReading[] { + return items.flatMap((item) => { + if (item.kind !== "helpers" || item.entry.agentSpawn === undefined) return []; + const bubble = helpersBubbleOf(panel, item.entry.agentSpawn); + return [ + { + words: bubble.words, + helpers: bubble.agents.map((agent) => entityOf(agent.title) ?? agent.title), + failed: bubble.failed, + live: bubble.summary.live, + ended: bubble.ended, + }, + ]; + }); +} + +function readCard( + row: Extract, + after: ReadonlyArray, + panel: AgentPanelModel, +): CardReading { + const effort = runEffortWords(row.outcome); + const line = + row.status === null + ? "" + : nowLineWords( + nowLineOf({ + status: row.status, + now: row.now, + answering: row.answering, + compacting: false, + speaker: "Milo", + effort, + }), + ); + return { + card: row.turnId ?? row.turnKey, + live: row.live, + line, + effort, + steps: stepsOf(row.items), + spawns: spawnsOf(row.items, panel), + reports: row.items.flatMap((item) => + item.kind === "task" ? [entityOf(item.entry.toolTitle ?? item.entry.label) ?? "?"] : [], + ), + background: after.flatMap((next) => + next.kind === "background" + ? [ + { + words: backgroundLineOf( + next.jobs === undefined ? taskItems(next.entries) : jobItems(next.jobs), + next.helpers, + ).words, + jobs: (next.jobs ?? []).map((job) => ({ + what: entityOf(job.title) ?? job.title, + state: job.state, + })), + }, + ] + : [], + ), + }; +} + +/** What the conversation reads as, from this store, at `nowMs`. */ +export function readConversation( + reads: Reads, + key: EngineConversationKey, + nowMs: number, +): Reading | null { + const thread = engineThreadOf(reads, key); + if (thread === null) return null; + const runCards = engineRunCards.derive(reads, key) ?? undefined; + const cardPaging = engineCardPaging.derive(reads, key); + const activities = thread.activities as ReadonlyArray; + const phase = derivePhase(thread.session); + const latestTurn = thread.latestTurn; + const isWorking = phase === "running"; + const runningTurnId = + (thread.session?.status === "running" ? thread.session.activeTurnId : null) ?? + (latestTurn?.state === "running" ? latestTurn.turnId : null); + const panel = deriveAgentPanelModel({ + agents: foldSubagentActivities(activities, { sessionLive: phase !== "disconnected" }), + }); + const agents = [ + ...panel.directAgents, + ...panel.workflows.flatMap((group) => group.unphasedMembers), + ]; + const latestUserMessageAt = + thread.messages.findLast((message) => message.role === "user")?.createdAt ?? null; + const rows = deriveMessagesTimelineRows({ + ...(runCards === undefined ? {} : { runCards }), + cardPaging, + timelineEntries: deriveTimelineEntries( + thread.messages as ReadonlyArray, + [], + deriveWorkLogEntries(activities), + ), + latestTurn, + runningTurnId, + isWorking, + activeTurnStartedAt: deriveActiveWorkStartedAt( + latestTurn, + thread.session, + null, + latestUserMessageAt, + ), + turnDiffSummaries: [], + supportsConversationRollback: false, + afterTurnWork: null, + liveJobs: null, + nowMs, + helperFinishes: helperFinishesOf(panel), + provider: thread.session?.providerName ?? null, + }); + const cards: CardReading[] = []; + rows.forEach((row, index) => { + // A run with no chat to end on draws its line alone: a card all the same. + if (row.kind === "work-line") { + cards.push({ + card: row.turnId ?? row.stretchKey, + live: row.live, + line: nowLineWords( + nowLineOf({ + status: row, + now: null, + answering: false, + compacting: false, + speaker: "Milo", + effort: null, + }), + ), + effort: null, + steps: [], + spawns: [], + reports: [], + background: [], + }); + return; + } + if (row.kind !== "record") return; + const next = rows.slice(index + 1); + const end = next.findIndex((later) => later.kind === "record"); + cards.push(readCard(row, end === -1 ? next : next.slice(0, end), panel)); + }); + return { + cards, + helpers: agents.map((agent) => { + const parent = agent.spawnedBy ?? agent.parentAgentId; + const parentAgent = agents.find((each) => each.id === parent); + return { + what: entityOf(agent.title) ?? agent.title, + status: agent.status, + parent: + parent === null ? null : (entityOf(parentAgent?.title) ?? parentAgent?.title ?? parent), + }; + }), + dock: foldBackgroundTasks(activities).map((task) => ({ + what: entityOf(task.title) ?? task.title, + state: task.state, + })), + }; +} diff --git a/apps/web/test/engine-oracle/runCard.oracle.test.ts b/apps/web/test/engine-oracle/runCard.oracle.test.ts new file mode 100644 index 000000000..f4dc5675a --- /dev/null +++ b/apps/web/test/engine-oracle/runCard.oracle.test.ts @@ -0,0 +1,156 @@ +// @effect-diagnostics globalConsoleInEffect:off - a failing seed prints its minimised tree. +/** + * The run-card oracle: does an engine Mate's card report precisely what its Mate did — helpers + * that start background commands, whose ends wake the Mate, which starts more helpers — running + * and finished, live and after a reload? Each tree plays on the running engine (scripted Claude, + * SQLite, the test clock) through the client's own wire, adapter and store into the card's logic, + * and the oracle (`oracle.ts`) reads the card after every op. + * + * The table holds each defect the oracle found, as the smallest tree that showed it. The seeds run + * the generator: the gate's fixed seeds here; `ENGINE_PROOF_RANDOM=n` adds n fresh ones and + * `ENGINE_PROOF_SEED=s` replays one (a failure prints its tree minimised). + */ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { gateSeeds } from "../../../server/src/engine/testing/rng.ts"; +import { classOf, minimise, playTree } from "./harness.ts"; +import { RULES } from "./oracle.ts"; +import { generate, type Op } from "./tree.ts"; + +const send: Op = { op: "send", text: "Start the checks." }; + +interface Defect { + readonly title: string; + readonly tree: ReadonlyArray; +} + +/** Each defect the oracle found, as its smallest tree. */ +const DEFECTS: ReadonlyArray = [ + { + title: "a background job that failed says its exit code on its card", + tree: [ + send, + { op: "job", id: "J1", helper: null, told: "after" }, + { op: "finish" }, + { op: "end", id: "J1", how: "failed", exit: 3 }, + ], + }, + { + title: "a helper's own helper is never counted among the helpers the Mate started", + tree: [ + send, + { op: "helper", id: "H1", parent: null }, + { op: "helper", id: "H2", parent: "H1" }, + ], + }, + { + title: + "a helper's own background job is never the Mate's, on its card or in the dock, live or after a reload", + tree: [ + send, + { op: "helper", id: "H1", parent: null }, + { op: "job", id: "J2", helper: "H1", told: "after" }, + { op: "finish" }, + { op: "end", id: "J2", how: "completed", exit: 0 }, + { op: "reload" }, + ], + }, + { + title: "a closed card's line of background jobs reads the same after a reload", + tree: [ + send, + { op: "job", id: "J1", helper: null, told: "after" }, + { op: "finish" }, + { op: "end", id: "J1", how: "completed", exit: 0 }, + { op: "reload" }, + ], + }, + { + title: "a helper that failed marks the helpers it was started with while the others still work", + tree: [ + send, + { op: "helper", id: "H1", parent: null }, + { op: "end", id: "H1", how: "failed", exit: 0 }, + { op: "helper", id: "H2", parent: null }, + ], + }, + { + title: + "a background job the person's Stop ended reads stopped, never that it didn't report back", + tree: [send, { op: "job", id: "J1", helper: null, told: "after" }, { op: "stop" }], + }, + { + title: "a helper at work reads as its helper on its card, never as its launch's finished step", + tree: [ + send, + { op: "helper", id: "H1", parent: null }, + { op: "call", id: "C2", helper: null, fail: false }, + ], + }, + { + title: "a card waiting on its helpers never counts their own commands as the Mate's", + tree: [ + send, + { op: "helper", id: "H1", parent: null }, + { op: "job", id: "J2", helper: "H1", told: "after" }, + { op: "finish" }, + ], + }, + { + title: + "a helper's own call stays on the card of the run that started it, never the next message's, live or after a reload", + tree: [ + send, + { op: "helper", id: "H1", parent: null }, + { op: "finish" }, + { op: "send", text: "Message 2." }, + { op: "call", id: "C2", helper: "H1", fail: false }, + { op: "finish" }, + { op: "reload" }, + ], + }, + { + title: + "helpers and a job a person's Stop ended after the turn read stopped on the card, live and after a reload", + tree: [ + send, + { op: "helper", id: "H1", parent: null }, + { op: "helper", id: "H2", parent: null }, + { op: "job", id: "J3", helper: null, told: "after" }, + { op: "finish" }, + { op: "stop" }, + { op: "reload" }, + ], + }, +]; + +const said = ( + findings: ReadonlyArray<{ readonly rule: keyof typeof RULES; readonly words: string }>, +) => findings.map((finding) => `${RULES[finding.rule]}: ${finding.words}`); + +describe("an engine Mate's run card, held against what its Mate did", () => { + it.effect.each(DEFECTS)("$title", ({ tree }) => + Effect.gen(function* () { + const verdict = yield* playTree(tree); + assert.deepStrictEqual(said(verdict.findings), []); + }), + ); + + it.effect("every seeded tree of helpers, background work and wakes reads as it ran", () => + Effect.gen(function* () { + const failures: string[] = []; + for (const seed of gateSeeds(24)) { + const tree = generate(seed, 10 + (seed % 40)); + const verdict = yield* playTree(tree); + if (verdict.findings.length === 0) continue; + const first = verdict.findings[0]!; + const smallest = yield* minimise(verdict.played, classOf(first)); + failures.push( + `seed ${seed}: ${said(verdict.findings).join("\n ")}\n smallest tree: ${JSON.stringify(smallest)}`, + ); + } + assert.deepStrictEqual(failures, []); + }), + ); +}); diff --git a/apps/web/test/engine-oracle/tree.ts b/apps/web/test/engine-oracle/tree.ts new file mode 100644 index 000000000..b6d6bc4bb --- /dev/null +++ b/apps/web/test/engine-oracle/tree.ts @@ -0,0 +1,570 @@ +/** + * The trees: a seeded walk of what a Mate and its person do — the Mate's turn starts helpers and + * background commands, helpers run calls and start their own commands (and helpers), each piece + * of work ends its own way (finished, failed with an exit code, stopped by the Mate, lost to a + * restart or a crash), an end after the turn wakes the Mate (whose wake turn starts more), and the + * person sends, steers, stops and reloads between it all, while the server may restart. + * + * A tree is a list of ops. Each op says what happens, never whether it can: the player skips an + * op its moment does not allow (an end of work that already ended, a call with no turn open), so + * any sub-list of a tree is a tree — what minimising a failure needs. + */ +import * as Effect from "effect/Effect"; +import { runId } from "@t3tools/contracts"; + +import { mate } from "../../../server/src/engine/testing/pump/engineWorld.ts"; +import { makeRng, type Rng } from "../../../server/src/engine/testing/rng.ts"; +import * as claude from "./claude.ts"; +import type { OracleWorld } from "./world.ts"; + +export type End = "completed" | "failed" | "stopped"; + +export type Op = + | { readonly op: "send"; readonly text: string } + | { readonly op: "steer"; readonly text: string } + | { readonly op: "say" } + | { + readonly op: "call"; + readonly id: string; + readonly helper: string | null; + readonly fail: boolean; + } + /** A command sent to the background; its task is told before its call returns, or after. */ + | { + readonly op: "job"; + readonly id: string; + readonly helper: string | null; + readonly told: "before" | "after"; + } + | { readonly op: "helper"; readonly id: string; readonly parent: string | null } + | { readonly op: "progress"; readonly id: string } + | { readonly op: "end"; readonly id: string; readonly how: End; readonly exit: number } + /** The Mate's TaskStop on its job: the kill, and (or not) the notification after it. */ + | { readonly op: "taskStop"; readonly id: string; readonly notified: boolean } + | { readonly op: "finish" } + /** Claude opens a turn of its own to hand the Mate what ended. */ + | { readonly op: "wake" } + | { readonly op: "stop" } + | { readonly op: "reload" } + | { readonly op: "restart" } + /** The agent's process dies. */ + | { readonly op: "crash" } + | { readonly op: "wait"; readonly ms: number }; + +export type Status = "running" | "completed" | "failed" | "stopped" | "lost"; + +/** A piece of the tree as it happened: who started it, on which card it belongs, how it stands. */ +export interface Entity { + readonly id: string; + readonly kind: "call" | "job" | "helper"; + /** The helper whose own it is; null: the Mate's. */ + readonly owner: string | null; + readonly task?: claude.TaskLinkage; + /** A helper's launch call: its own calls run under it. */ + readonly launch?: string; + /** The card it belongs on: the card of the run it served. */ + readonly card: string | null; + readonly run: string | null; + status: Status; + exit?: number; + failed?: boolean; +} + +export interface Model { + readonly entities: Map; + /** The card each run draws on, as the tree knows it. */ + readonly cardOfRun: Map; + /** Runs whose card the tree cannot tell, as the engine joined them. */ + readonly loose: Set; + /** What ended last, since the Mate's last turn: what a wake hands the Mate. */ + ended: Array; + /** The work a wake handed the Mate, by the turn it opened. */ + wakeOf: Entity | null; + /** Work lost since the last lost-work wake, by card. */ + lostCards: Set; + wakes: number; + played: Array; + skipped: number; +} + +export const emptyModel = (): Model => ({ + entities: new Map(), + cardOfRun: new Map(), + loose: new Set(), + ended: [], + wakeOf: null, + lostCards: new Set(), + wakes: 0, + played: [], + skipped: 0, +}); + +interface EngineRun { + readonly id: string; + readonly state: string; + readonly joins: string | null; + readonly trigger: { readonly kind: string; readonly cause?: string }; +} + +/** The engine's runs, oldest first. */ +const runsOf = (world: OracleWorld) => + Effect.gen(function* () { + const rows = yield* world.w.runs; + const runs: EngineRun[] = []; + for (const row of rows) { + const run = yield* world.w.run(row.run_id as never); + if (run !== undefined) + runs.push({ id: row.run_id, state: run.state, joins: run.joins, trigger: run.trigger }); + } + return runs; + }); + +const LIVE_RUN: ReadonlySet = new Set(["running", "waiting"]); + +/** How long each step of a tree takes. */ +const STEP_MS = 300; + +/** Each run the engine opened since: the card the tree says it belongs on. */ +const learnRuns = (world: OracleWorld, model: Model) => + Effect.gen(function* () { + const runs = yield* runsOf(world); + const rootOf = (id: string): string => { + let at = runs.find((run) => run.id === id); + for (let hops = 0; at?.joins != null && hops < runs.length; hops++) + at = runs.find((run) => run.id === at!.joins) ?? at; + return at?.id ?? id; + }; + for (const run of runs) { + if (model.cardOfRun.has(run.id)) continue; + if (run.trigger.kind === "person") { + model.cardOfRun.set(run.id, run.id); + } else if (run.trigger.kind === "wake" && run.trigger.cause === "self" && model.wakeOf) { + // The wake hands the Mate what ended: it goes on on that work's card. + model.cardOfRun.set(run.id, model.wakeOf.card ?? rootOf(run.id)); + model.wakeOf = null; + } else if (run.trigger.kind === "wake" && run.trigger.cause === "lost-work") { + // The note of lost work goes on on the card of the run the work served while that run is + // the latest; else it is a card of its own (`noteLostWork`). + const root = rootOf(run.id); + model.cardOfRun.set( + run.id, + run.joins === null || model.lostCards.has(root) + ? root + : ([...model.lostCards][0] ?? root), + ); + model.lostCards.clear(); + } else { + model.cardOfRun.set(run.id, rootOf(run.id)); + model.loose.add(run.id); + } + } + return runs; + }); + +const activeRunOf = (runs: ReadonlyArray) => + runs.findLast((run) => LIVE_RUN.has(run.state)) ?? null; + +const NAMES: Record string> = { + call: (id) => `Check ${id}`, + job: (id) => `Background job ${id}`, + helper: (id) => `Helper ${id} looks into it`, +}; + +/** Plays one op on the world; false when its moment does not allow it. */ +export const play = (world: OracleWorld, model: Model, op: Op) => + Effect.gen(function* () { + const { w } = world; + const session = () => w.provider.sessions.get(w.thread); + const alive = () => session()?.alive === true; + const turnOpen = () => alive() && session()!.open !== null; + const runs = yield* learnRuns(world, model); + const active = activeRunOf(runs); + const live = (id: string) => { + const entity = model.entities.get(id); + return entity !== undefined && entity.status === "running" ? entity : undefined; + }; + /** Where a new piece of the Mate's own goes: the live run's card. */ + const mateCard = () => + active === null + ? null + : { run: active.id, card: model.cardOfRun.get(active.id) ?? active.id }; + const ownerOf = (helper: string | null) => (helper === null ? null : live(helper)); + const allowedOwner = (helper: string | null) => + helper === null ? turnOpen() : alive() && ownerOf(helper) !== undefined; + const placeOf = (helper: string | null) => { + if (helper === null) return mateCard(); + const owner = live(helper)!; + return { run: owner.run, card: owner.card }; + }; + const ending = (entity: Entity, status: Status) => { + entity.status = status; + if (status === "lost" && entity.card !== null) model.lostCards.add(entity.card); + // Only work that reported its own end hands the Mate something to wake to. + if (status === "completed" || status === "failed") model.ended.push(entity); + }; + /** The session went: its work with it, and nothing it ended wakes the Mate any more. */ + const markAll = (status: Status) => { + for (const entity of model.entities.values()) + if (entity.status === "running" && entity.kind !== "call") ending(entity, status); + model.ended = []; + }; + + switch (op.op) { + case "send": + yield* world.person.send(op.text); + break; + case "steer": { + if (active === null || !turnOpen()) return false; + yield* world.person.steer(active.id, op.text); + break; + } + case "say": { + if (!turnOpen()) return false; + yield* w.agent((agent, thread) => agent.say(thread, "Working on it.")); + break; + } + case "call": { + if (model.entities.has(op.id) || !allowedOwner(op.helper)) return false; + const owner = ownerOf(op.helper); + const place = placeOf(op.helper); + model.entities.set(op.id, { + id: op.id, + kind: "call", + owner: op.helper, + card: place?.card ?? null, + run: place?.run ?? null, + status: op.fail ? "failed" : "completed", + }); + yield* claude.call(w, { + id: `toolu_${op.id}`, + owner: + owner === undefined || owner === null + ? null + : { helper: owner.task!.taskId, launch: owner.launch! }, + tool: "Bash", + args: { command: `test -e /srv/${op.id}`, description: NAMES.call(op.id) }, + result: op.fail ? "Exit code 1" : "", + failed: op.fail, + }); + break; + } + case "job": { + if (model.entities.has(op.id) || !allowedOwner(op.helper)) return false; + const owner = ownerOf(op.helper); + const place = placeOf(op.helper); + const task: claude.TaskLinkage = { + taskId: `b${op.id}`, + taskType: "local_bash", + title: NAMES.job(op.id), + toolUseId: `toolu_${op.id}`, + ...(owner === undefined || owner === null ? {} : { agentId: owner.task!.taskId }), + }; + model.entities.set(op.id, { + id: op.id, + kind: "job", + owner: op.helper, + task, + card: place?.card ?? null, + run: place?.run ?? null, + status: "running", + }); + const started = claude.taskStarted(w, task); + yield* claude.call(w, { + id: task.toolUseId, + owner: + owner === undefined || owner === null + ? null + : { helper: owner.task!.taskId, launch: owner.launch! }, + tool: "Bash", + args: { + command: `sleep 30 && echo ${op.id}`, + description: NAMES.job(op.id), + run_in_background: true, + }, + result: `Command running in background with ID: ${task.taskId}. Output is being written to: /tmp/claude/${task.taskId}.output`, + failed: false, + ...(op.told === "before" ? { between: started } : {}), + }); + if (op.told === "after") yield* started; + break; + } + case "helper": { + if (model.entities.has(op.id) || !allowedOwner(op.parent)) return false; + const parent = ownerOf(op.parent); + const place = placeOf(op.parent); + const launch = `toolu_${op.id}`; + const task: claude.TaskLinkage = { + taskId: `a${op.id}`, + taskType: "local_agent", + title: NAMES.helper(op.id), + toolUseId: launch, + ...(parent === undefined || parent === null ? {} : { agentId: parent.task!.taskId }), + }; + model.entities.set(op.id, { + id: op.id, + kind: "helper", + owner: op.parent, + task, + launch, + card: place?.card ?? null, + run: place?.run ?? null, + status: "running", + }); + yield* claude.call(w, { + id: launch, + owner: + parent === undefined || parent === null + ? null + : { helper: parent.task!.taskId, launch: parent.launch! }, + tool: "Agent", + args: { + description: NAMES.helper(op.id), + prompt: `Look into ${op.id}.`, + subagent_type: "general-purpose", + run_in_background: true, + }, + result: `Async agent launched successfully. agentId: ${task.taskId}`, + failed: false, + between: claude.taskStarted(w, task), + }); + break; + } + case "progress": { + const entity = live(op.id); + if (entity?.task === undefined || !alive()) return false; + yield* claude.taskProgress(w, entity.task, `Running a step of ${op.id}`); + break; + } + case "end": { + const entity = live(op.id); + if (entity?.task === undefined || !alive()) return false; + const summary = + entity.kind === "job" + ? op.how === "failed" + ? `Background command "${entity.task.title}" failed with exit code ${op.exit}` + : `Background command "${entity.task.title}" completed (exit code 0)` + : `${entity.task.title}: ${op.how === "failed" ? "it failed" : "done"}.`; + if (op.how === "failed") entity.exit = op.exit; + ending(entity, op.how); + yield* claude.taskNotification(w, entity.task, op.how, summary); + break; + } + case "taskStop": { + const entity = live(op.id); + if (entity?.task === undefined || entity.kind !== "job" || entity.owner !== null) + return false; + if (!turnOpen()) return false; + ending(entity, "stopped"); + yield* claude.call(w, { + id: `toolu_stop_${op.id}`, + owner: null, + tool: "TaskStop", + args: { task_id: entity.task.taskId }, + result: "Stopped.", + failed: false, + }); + yield* claude.taskKilled(w, entity.task); + if (op.notified) + yield* claude.taskNotification(w, entity.task, "stopped", "Background command stopped."); + break; + } + case "finish": { + if (!turnOpen()) return false; + yield* w.agent((agent, thread) => agent.finish(thread)); + break; + } + case "wake": { + if (!alive() || turnOpen() || active !== null || model.ended.length === 0) return false; + const cause = model.ended.at(-1); + if (cause === undefined) return false; + model.wakes += 1; + model.wakeOf = cause; + model.ended = []; + yield* w.agent((agent, thread) => agent.selfTurn(thread)); + break; + } + case "stop": { + // The session the Stop met: gone after it (or replaced by the next message's), it took + // its work with it. + const before = alive() ? session() : undefined; + yield* world.person.stop.pipe(Effect.ignore); + if (before !== undefined && (session() !== before || !alive())) markAll("stopped"); + break; + } + case "reload": + // The oracle reads a fresh client here (`checkReload`). + break; + case "restart": { + markAll("lost"); + yield* world.restart; + break; + } + case "crash": { + if (!alive()) return false; + markAll("lost"); + yield* w.agent((agent, thread) => agent.crash(thread)); + break; + } + case "wait": + yield* w.advance(op.ms); + break; + } + // Nothing a person or an agent does takes no time: each step lands a moment after the last, as + // on a real clock (a whole tree at one instant orders by nothing). + yield* w.advance(STEP_MS); + yield* world.settle; + yield* learnRuns(world, model); + return true; + }); + +/** Every piece of work ended, every turn finished: the tree's end as its record settles. */ +export const drain = (world: OracleWorld, model: Model, rng: Rng) => + Effect.gen(function* () { + const ops: Op[] = []; + for (let round = 0; round < 40; round++) { + const session = world.w.provider.sessions.get(world.w.thread); + const running = [...model.entities.values()].filter( + (entity) => entity.status === "running" && entity.kind !== "call", + ); + // Children before their helper: a helper reports once what it started is done. + const leaf = running.find((entity) => !running.some((child) => child.owner === entity.id)); + let op: Op; + if (session?.alive === true && session.open !== null) op = { op: "finish" }; + else if (leaf !== undefined && session?.alive === true) + op = { op: "end", id: leaf.id, how: rng.pick(["completed", "failed"] as const), exit: 3 }; + else if ( + model.ended.some((entity) => entity.status !== "lost") && + model.wakes < 3 && + rng.chance(0.5) + ) + op = { op: "wake" }; + else op = { op: "wait", ms: 5_000 }; + const done = yield* play(world, model, op); + if (done) ops.push(op); + if ( + op.op === "wait" && + running.length === 0 && + (session?.alive !== true || session.open === null) + ) { + const runs = yield* runsOf(world); + if (activeRunOf(runs) === null && !runs.some((run) => run.state !== "ended")) break; + } + } + return ops; + }); + +/** A random tree from a seed: up to `size` ops. */ +export function generate(seed: number, size: number): Op[] { + const rng = makeRng(seed); + const ops: Op[] = []; + let ids = 0; + const next = (prefix: string) => `${prefix}${++ids}`; + // What the tree has started that may still run, as the generator imagines it (the player + // skips what turns out not to fit). + const helpers: string[] = []; + const work: string[] = []; + const mateJobs: string[] = []; + let open = false; + let wakes = 0; + ops.push({ op: "send", text: "Start the checks." }); + open = true; + while (ops.length < size) { + const choice = rng.weighted([ + ["say", open ? 1 : 0], + ["call", open ? 3 : helpers.length > 0 ? 1 : 0], + ["job", open ? 2 : helpers.length > 0 ? 1 : 0], + ["helper", open ? 2 : helpers.length > 0 ? 0.5 : 0], + ["progress", work.length > 0 ? 0.3 : 0], + ["end", work.length > 0 ? 3 : 0], + ["taskStop", open && mateJobs.length > 0 ? 0.6 : 0], + ["finish", open ? 2 : 0], + ["wake", !open && wakes < 3 ? 1.5 : 0], + ["send", 0.6], + ["steer", open ? 0.4 : 0], + ["stop", 0.25], + ["reload", 0.5], + ["restart", 0.12], + ["crash", 0.05], + ["wait", 1], + ]); + const helperOrMate = () => + open && (helpers.length === 0 || rng.chance(0.6)) ? null : (rng.pick(helpers) ?? null); + switch (choice) { + case "say": + case "finish": + case "stop": + case "reload": + case "restart": + case "crash": + ops.push({ op: choice } as Op); + if (choice === "finish") open = false; + break; + case "call": + ops.push({ op: "call", id: next("C"), helper: helperOrMate(), fail: rng.chance(0.25) }); + break; + case "job": { + const id = next("J"); + const helper = helperOrMate(); + ops.push({ op: "job", id, helper, told: rng.chance(0.5) ? "before" : "after" }); + work.push(id); + if (helper === null) mateJobs.push(id); + break; + } + case "helper": { + const id = next("H"); + ops.push({ + op: "helper", + id, + parent: open && rng.chance(0.7) ? null : (rng.pick(helpers) ?? null), + }); + helpers.push(id); + work.push(id); + break; + } + case "progress": + ops.push({ op: "progress", id: rng.pick(work) }); + break; + case "end": { + const id = rng.pick(work); + ops.push({ + op: "end", + id, + how: rng.weighted([ + ["completed", 3], + ["failed", 1.5], + ["stopped", 0.5], + ] as const), + exit: rng.int(1, 9), + }); + work.splice(work.indexOf(id), 1); + if (helpers.includes(id)) helpers.splice(helpers.indexOf(id), 1); + break; + } + case "taskStop": { + const id = rng.pick(mateJobs); + ops.push({ op: "taskStop", id, notified: rng.chance(0.5) }); + mateJobs.splice(mateJobs.indexOf(id), 1); + if (work.includes(id)) work.splice(work.indexOf(id), 1); + break; + } + case "wake": + ops.push({ op: "wake" }); + open = true; + wakes += 1; + break; + case "send": + ops.push({ op: "send", text: `Message ${ops.length}.` }); + if (!open) open = true; + break; + case "steer": + ops.push({ op: "steer", text: `Also check ${ops.length}.` }); + break; + case "wait": + ops.push({ op: "wait", ms: rng.int(200, 8_000) }); + break; + } + } + return ops; +} + +export const runOf = (n: number) => runId(mate, n) as string; diff --git a/apps/web/test/engine-oracle/tsconfig.json b/apps/web/test/engine-oracle/tsconfig.json new file mode 100644 index 000000000..aab547f35 --- /dev/null +++ b/apps/web/test/engine-oracle/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "module": "Preserve", + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "erasableSyntaxOnly": false, + "verbatimModuleSyntax": false, + "lib": ["ES2024", "DOM", "DOM.Iterable", "esnext.disposable"], + "types": ["node"], + "paths": { + "~/*": ["../../src/*"] + }, + "plugins": [ + { + "name": "@effect/language-service", + "diagnosticSeverity": { + "globalDate": "off", + "globalConsole": "off", + "globalRandom": "off", + "globalTimers": "off", + "globalFetch": "off" + } + } + ] + }, + "include": ["**/*.ts", "../../src/vite-env.d.ts"] +} diff --git a/apps/web/test/engine-oracle/world.ts b/apps/web/test/engine-oracle/world.ts new file mode 100644 index 000000000..2d3ad6838 --- /dev/null +++ b/apps/web/test/engine-oracle/world.ts @@ -0,0 +1,275 @@ +// @effect-diagnostics globalTimers:off preferSchemaOverJson:off - a real macrotask lets the client's own fibers run between steps; the socket's JSON is what a frame crosses. +/** + * One conversation end to end: the running engine (`makeEngineWorld`: the live layer over a + * scripted Claude provider, a SQLite file, the test clock) behind its wire (`makeEngineWire`), and + * the client's own adapter and store over that wire — the frames and pages encoded and decoded by + * the contract schemas, as the socket carries them. A person speaks through the wire's calls; the + * agent is played on the provider's thread. A reload is a second client opened on the same record; + * a restart crashes the server and boots it on the same file, and the open client resubscribes. + */ +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { AtomRegistry } from "effect/reactivity"; +import { + CommandId, + EngineConversationFrame, + EngineDetail, + EnginePage, + EngineRowsFrame, + MATE_ENGINE_PROTOCOLS, + RunId, +} from "@t3tools/contracts"; +import { + ENGINE_LIVE_POLICY, + makeAccountStore, + makeEngineLiveText, + makeMateEngineConversations, + readsOfState, + type EngineConversationKey, + type EngineConversationWire, +} from "@t3tools/client-runtime/data"; + +import { + makeEngineWorld, + mate, + type EngineWorld, +} from "../../../server/src/engine/testing/pump/engineWorld.ts"; +import { + makeEngineWire, + type EngineWireShape, + type WireCaller, +} from "../../../server/src/engine/wire/EngineWire.ts"; + +export const KEY: EngineConversationKey = { environmentId: "env-1", conversationId: mate }; +const PROTOCOL = Math.max(...MATE_ENGINE_PROTOCOLS); +const CALLER: WireCaller = { + subject: "zerops-user:ana", + environmentId: KEY.environmentId, + epoch: 4, +}; + +/** What the socket does to a value: encoded by its contract as JSON, decoded on the other side. */ +const across = (schema: S) => { + const encode = Schema.encodeSync(schema as never) as unknown as (value: S["Type"]) => unknown; + const decode = Schema.decodeUnknownSync(schema as never) as unknown as ( + value: unknown, + ) => S["Type"]; + return (value: S["Type"]): S["Type"] => decode(JSON.parse(JSON.stringify(encode(value)))); +}; +const frameCodec = EngineConversationFrame; +const rowsCodec = EngineRowsFrame; +const pageCodec = EnginePage; +const detailCodec = EngineDetail; + +const fault = (error: unknown) => ({ + outcome: "transient" as const, + message: error instanceof Error ? error.message : JSON.stringify(error), +}); + +/** A client of the engine's wire: its account store holds the conversation and its rows. */ +export interface Client { + readonly store: ReturnType; + readonly conversations: ReturnType; + readonly reads: () => ReturnType; + /** The socket came back: its links subscribe again from where they stood. */ + readonly resubscribe: () => void; + readonly close: () => void; +} + +export const makeOracleWorld = Effect.gen(function* () { + const w: EngineWorld = yield* makeEngineWorld({ driver: "claudeAgent" }); + yield* w.boot; + let wire: EngineWireShape = yield* w.within(makeEngineWire({ coalesce: 0 })); + /** The open subscriptions: a restart ends each, as the socket's close does. */ + const open = new Set>(); + const ended = (stream: Stream.Stream) => + Stream.unwrap( + Effect.gen(function* () { + const done = yield* Deferred.make(); + open.add(done); + return stream.pipe( + Stream.interruptWhen(Deferred.await(done)), + Stream.ensuring(Effect.sync(() => open.delete(done))), + ); + }), + ); + + const clientWire: EngineConversationWire = { + subscribe: (key, after) => + ended( + Stream.suspend(() => + wire.subscribe( + { + protocol: PROTOCOL, + conversationId: key.conversationId as never, + ...(after === null ? {} : { after }), + }, + CALLER, + ), + ), + ).pipe(Stream.map(across(frameCodec)), Stream.mapError(fault)), + subscribeRows: () => + ended(Stream.suspend(() => wire.subscribeRows({ protocol: PROTOCOL }, CALLER))).pipe( + Stream.map(across(rowsCodec)), + Stream.mapError(fault), + ), + readEarlier: (key, beforeOrdinal) => + Effect.suspend(() => + wire.readEarlier({ + protocol: PROTOCOL, + conversationId: key.conversationId as never, + beforeOrdinal, + }), + ).pipe(Effect.map(across(pageCodec)), Effect.mapError(fault)), + readRun: (key, runId, at) => + Effect.suspend(() => + wire.readRun({ + protocol: PROTOCOL, + conversationId: key.conversationId as never, + runId: runId as never, + ...(at.before === undefined ? {} : { beforeSeq: at.before }), + ...(at.after === undefined ? {} : { afterSeq: at.after }), + ...(at.only === undefined ? {} : { only: at.only }), + }), + ).pipe(Effect.map(across(pageCodec)), Effect.mapError(fault)), + readDetail: (key, itemId, part) => + Effect.suspend(() => + wire.readDetail({ + protocol: PROTOCOL, + conversationId: key.conversationId as never, + itemId: itemId as never, + part, + }), + ).pipe(Effect.map(across(detailCodec)), Effect.mapError(fault)), + }; + + const clients = new Set(); + const openClient = (): Client => { + const registry = AtomRegistry.make(); + const store = makeAccountStore(registry); + const never = () => 0; + const live = makeEngineLiveText({ + policy: ENGINE_LIVE_POLICY, + setTimer: never, + clearTimer: () => {}, + }); + const conversations = makeMateEngineConversations({ + store, + live, + wire: clientWire, + setTimer: never, + clearTimer: () => {}, + }); + let releaseRows = conversations.holdRows(KEY.environmentId); + let release = conversations.hold(KEY); + const client: Client = { + store, + conversations, + reads: () => readsOfState(store.state()), + resubscribe: () => { + release(); + releaseRows(); + releaseRows = conversations.holdRows(KEY.environmentId); + release = conversations.hold(KEY); + }, + close: () => { + release(); + releaseRows(); + conversations.close(); + live.close(); + store.close(); + registry.dispose(); + clients.delete(client); + }, + }; + clients.add(client); + return client; + }; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + for (const client of clients) client.close(); + }), + ); + + /** + * Lets the server and every client run until nothing changes: the engine's fibers on the test + * clock, the client's on the runtime's scheduler, between them a real macrotask. + */ + const settle = Effect.gen(function* () { + let still = 0; + let before = [...clients].map((client) => client.store.state()); + for (let round = 0; round < 60 && still < 3; round++) { + yield* w.settle; + yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); + const after = [...clients].map((client) => client.store.state()); + still = after.every((state, index) => state === before[index]) ? still + 1 : 0; + before = after; + } + }); + + let commands = 0; + const commandId = () => CommandId.make(`oracle-${++commands}`); + const person = { + send: (text: string) => + Effect.gen(function* () { + const result = yield* wire.send( + { protocol: PROTOCOL, conversationId: mate, commandId: commandId(), text }, + CALLER, + ); + yield* settle; + return result; + }), + steer: (runId: string, text: string) => + Effect.gen(function* () { + const result = yield* wire.steer( + { + protocol: PROTOCOL, + conversationId: mate, + commandId: commandId(), + runId: RunId.make(runId), + text, + }, + CALLER, + ); + yield* settle; + return result; + }), + stop: Effect.gen(function* () { + const result = yield* wire.stop( + { protocol: PROTOCOL, conversationId: mate, commandId: commandId() }, + CALLER, + ); + yield* settle; + return result; + }), + }; + + /** The server dies and boots again on the same file; every open subscription ends with it. */ + const restart = Effect.gen(function* () { + for (const done of open) Deferred.doneUnsafe(done, Exit.void); + yield* w.crash; + yield* w.boot; + wire = yield* w.within(makeEngineWire({ coalesce: 0 })); + yield* w.advance(0); + // The socket comes back at once rather than on the client's backoff clock. + for (const client of clients) client.resubscribe(); + yield* settle; + }); + + yield* w.tell({ + _tag: "AssignAgent", + agent: { + instanceId: "claudeAgent", + driver: "claudeAgent", + model: "m1", + profile: { kind: "mate" }, + }, + }); + + return { w, person, openClient, settle, restart }; +}); + +export type OracleWorld = Effect.Success; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index cc8136ffb..212193a77 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -25,7 +25,7 @@ } ] }, - "exclude": ["test/scenarios"], + "exclude": ["test/scenarios", "test/engine-oracle"], "include": [ "src", "vite.config.ts", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 54b2fd725..9e1e68869 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -93,6 +93,23 @@ const unitTestProject = { }, } satisfies TestProjectInlineConfiguration; +/** + * The run-card oracle: the running engine over its scripted provider, the client's wire and store, + * and the card's own logic, in one process (`test/engine-oracle`). Its own tsconfig holds the + * server's files it reads; the unit project never imports them. + */ +const engineOracleProject = { + extends: true, + test: { + name: "engine-oracle", + taskTitleValueFormatTruncate: Number.MAX_SAFE_INTEGER, + include: ["test/engine-oracle/**/*.test.ts"], + testTimeout: 600_000, + hookTimeout: 60_000, + setupFiles: ["../../packages/shared/src/testing/longTempDir.ts"], + }, +} satisfies TestProjectInlineConfiguration; + function resolveDevProxyTarget( backendPort: string | undefined, wsUrl: string | undefined, @@ -316,7 +333,7 @@ export default defineConfig(() => { }, }, test: { - projects: [defineProject(unitTestProject)], + projects: [defineProject(unitTestProject), defineProject(engineOracleProject)], }, }; }); diff --git a/scripts/chat-gate.test.ts b/scripts/chat-gate.test.ts index eda1d3562..047296157 100644 --- a/scripts/chat-gate.test.ts +++ b/scripts/chat-gate.test.ts @@ -51,7 +51,7 @@ it("the gate runs the chat journeys against both wires a Mate can speak", () => stage.commands.flatMap((command) => { const at = command.args.indexOf("--project"); const areas = command.args.find((arg) => arg.startsWith("test/scenarios/areas")); - return at === -1 ? [] : [[stage.name, command.args[at + 1], areas]]; + return at === -1 || areas === undefined ? [] : [[stage.name, command.args[at + 1], areas]]; }), ); expect(projects).toEqual([ @@ -76,6 +76,7 @@ it("the gate proves the Mate engine on the proof harness's fixed seeds and the r "src/zerops/crew/engine/importV1Crew.test.ts", "src/engine/engine.sim.test.ts", "src/engine/engine.pump.test.ts", + "test/engine-oracle/runCard.oracle.test.ts", ]); }); @@ -303,6 +304,7 @@ it.each(["A", "E"])("a %s-only gate runs without installing a scenario browser", try { NodeFS.mkdirSync(NodePath.join(fixture, "scripts")); NodeFS.mkdirSync(NodePath.join(fixture, "apps/server"), { recursive: true }); + NodeFS.mkdirSync(NodePath.join(fixture, "apps/web"), { recursive: true }); NodeFS.mkdirSync(NodePath.join(fixture, "node_modules/.bin"), { recursive: true }); NodeFS.copyFileSync( NodePath.join(root, "scripts/chat-gate.ts"), @@ -373,8 +375,10 @@ it("a contract-only edit retains wire consumer typechecks even without a selecte "packages/client-runtime", "apps/web", ".", + ".", ]); - expect(commands?.at(-1)?.args).toContain("apps/web/test/scenarios/areas/c-mate/tsconfig.json"); + expect(commands?.at(-2)?.args).toContain("apps/web/test/scenarios/areas/c-mate/tsconfig.json"); + expect(commands?.at(-1)?.args).toContain("apps/web/test/engine-oracle/tsconfig.json"); }); it("an empty lane chat selection skips before starting any command", () => { diff --git a/scripts/chat-gate.ts b/scripts/chat-gate.ts index 9fb391957..7f3a89ada 100644 --- a/scripts/chat-gate.ts +++ b/scripts/chat-gate.ts @@ -12,7 +12,7 @@ export function selectsChatGate(paths: ReadonlyArray): boolean { return false; if (/^apps\/web\/src\/.*\.test\.[cm]?[jt]sx?$/u.test(path)) return false; return ( - /^(?:apps\/server\/src\/|apps\/web\/(?:src\/|test\/scenarios\/)|packages\/(?:contracts|client-runtime|shared|effect-codex-app-server|effect-acp)\/)/u.test( + /^(?:apps\/server\/src\/|apps\/web\/(?:src\/|test\/scenarios\/|test\/engine-oracle\/)|packages\/(?:contracts|client-runtime|shared|effect-codex-app-server|effect-acp)\/)/u.test( path, ) || /^(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|tsconfig\.base\.json|vite\.config\.ts|apps\/(?:server|web)\/(?:package\.json|vite\.config\.ts|tsconfig\.json)|scripts\/(?:chat-gate(?:-reporter)?|gate-changed)(?:\.test)?\.ts|\.github\/workflows\/ci\.yml)$/u.test( @@ -112,6 +112,21 @@ export const chatGateStages = [ "--reporter=../../scripts/chat-gate-reporter.ts", ], }, + { + // The run-card oracle: the running engine's record through the client's wire and store + // into the card, on its fixed seeds. + cwd: "apps/web", + args: [ + "test", + "run", + "--project", + "engine-oracle", + "test/engine-oracle/runCard.oracle.test.ts", + "--allowOnly=false", + "--reporter=default", + "--reporter=../../scripts/chat-gate-reporter.ts", + ], + }, ], }, { @@ -159,6 +174,17 @@ export const chatGateStages = [ "apps/web/test/scenarios/areas/c-mate/tsconfig.json", ], }, + { + cwd: ".", + args: [ + "exec", + "tsc", + "--noEmit", + "--incremental", + "-p", + "apps/web/test/engine-oracle/tsconfig.json", + ], + }, ], }, ] satisfies ReadonlyArray<{ @@ -403,7 +429,9 @@ if (import.meta.main) { // the host has it) keeps one gate command for CI and a fresh worktree alike. const logs = gateLogDirectory("chat-gate"); if ( - stages.some((stage) => stage.commands.some((command) => command.args.includes("--project"))) + stages.some((stage) => + stage.commands.some((command) => command.args.includes("test/scenarios/vitest.config.ts")), + ) ) { const logPath = NodePath.join(logs, "browser.log"); const status = await runLoggedAsync( diff --git a/scripts/gate-changed.ts b/scripts/gate-changed.ts index 80f5e22e2..dccad3e48 100644 --- a/scripts/gate-changed.ts +++ b/scripts/gate-changed.ts @@ -758,6 +758,19 @@ if (import.meta.main) { args: ["exec", "tsc", "--noEmit", "--incremental"], cwd: pkg.directory, }); + if (meaningful.paths.some((path) => path.startsWith("apps/web/test/engine-oracle/"))) + steps.push({ + name: "typecheck engine oracle", + command: "vp", + args: [ + "exec", + "tsc", + "--noEmit", + "--incremental", + "-p", + "apps/web/test/engine-oracle/tsconfig.json", + ], + }); if (meaningful.paths.some((path) => path.startsWith("apps/web/test/scenarios/"))) steps.push({ name: "typecheck scenarios", From 44de1a14fe269a3a8ab975c6c5dacdb20a054945 Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 22:16:02 +0200 Subject: [PATCH 14/19] test(web): an oracle tree's message hands the Mate what ended before it, so no wake follows for it --- apps/web/test/engine-oracle/tree.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/test/engine-oracle/tree.ts b/apps/web/test/engine-oracle/tree.ts index b6d6bc4bb..481d9f557 100644 --- a/apps/web/test/engine-oracle/tree.ts +++ b/apps/web/test/engine-oracle/tree.ts @@ -212,6 +212,8 @@ export const play = (world: OracleWorld, model: Model, op: Op) => switch (op.op) { case "send": yield* world.person.send(op.text); + // A turn of the Mate's hands it whatever ended before it: nothing is left to wake to. + if (turnOpen()) model.ended = []; break; case "steer": { if (active === null || !turnOpen()) return false; From e4f5daae900474b852f75a120ddd56d8faa81ea5 Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 22:16:03 +0200 Subject: [PATCH 15/19] test(web): the wake journey holds the reply the person read to the list's own jump bound Measured: the reply stays on screen through the wake and the view glides to the answer in about 250 ms; the list moves it 62 px for one frame as the answer's row lands (a LegendList insertion frame), under the 120 px the journey holds the list's own scroll to. --- apps/web/test/scenarios/areas/c-mate/engine-card.scenario.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/test/scenarios/areas/c-mate/engine-card.scenario.ts b/apps/web/test/scenarios/areas/c-mate/engine-card.scenario.ts index 1ba35b096..a44d32f7c 100644 --- a/apps/web/test/scenarios/areas/c-mate/engine-card.scenario.ts +++ b/apps/web/test/scenarios/areas/c-mate/engine-card.scenario.ts @@ -182,7 +182,7 @@ describe("C: an engine Mate's run card in a live conversation", () => { expect( largestStep(frames, (frame) => frame.anchor), "ASSERTION: the reply the person was reading never jumps as the wake answers", - ).toBeLessThan(60); + ).toBeLessThan(120); yield* s.then.noExternalNetwork; }), ); From 60a6e0cf3e947a1e406a650aea7081ad55d8ccde Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 22:24:23 +0200 Subject: [PATCH 16/19] fix(web): a person's own message, sent or steered, moves the conversation by a glide The person's send pinned the end with a list jump: a steer queued under the running card moved the conversation 104 px in one frame (Milo's third stress run: 105 px), and a send 130 px whenever its row landed before the jump's frame. Already at the end, the view now follows what the send brings by the list's own glide; from elsewhere it still goes to the end at once. The engine card journeys measure both. --- apps/web/src/components/ChatView.tsx | 16 ++++++-- .../areas/c-mate/engine-card.scenario.ts | 39 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 28e64f4b5..bd72cefdc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4474,8 +4474,15 @@ export default function ChatView(props: ChatViewProps) { ); // Live-follow stays active after send/thread-open until an actual list scroll // gesture opts out. - const scrollToEnd = useCallback((animated = false) => { + /** + * `sent`: the person's own message — already at the end, the view follows what it brings by the + * list's glide (`createEndFollow`), never a cut: a queued steer moved the conversation 105 px in + * one frame (Milo's third stress run). + */ + const scrollToEnd = useCallback((animated = false, sent = false) => { cancelPositionRestoreRef.current?.(); + const following = + sent && isAtEndRef.current && timelineScrollModeRef.current === "following-end"; isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; @@ -4487,6 +4494,7 @@ export default function ChatView(props: ChatViewProps) { showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); setTimelineAnchor(releaseChatTimelineAnchor); + if (following) return; requestAnimationFrame(() => { // Under reduced motion the way there is a cut. void legendListRef.current?.scrollToEnd?.({ animated: animated && !prefersReducedMotion() }); @@ -6688,7 +6696,7 @@ export default function ChatView(props: ChatViewProps) { { type: "sent", byPerson: true }, ) ) { - scrollToEnd(); + scrollToEnd(false, true); } return; } @@ -7013,7 +7021,7 @@ export default function ChatView(props: ChatViewProps) { { type: "sent", byPerson: sentBy === "person" }, ) ) { - scrollToEnd(); + scrollToEnd(false, sentBy === "person"); } setOptimisticUserMessages((existing) => [ ...existing, @@ -7719,7 +7727,7 @@ export default function ChatView(props: ChatViewProps) { beginLocalDispatch({ preparingWorktree: false }); setThreadError(threadIdForSend, null); - scrollToEnd(); + scrollToEnd(false, true); setOptimisticUserMessages((existing) => [ ...existing, diff --git a/apps/web/test/scenarios/areas/c-mate/engine-card.scenario.ts b/apps/web/test/scenarios/areas/c-mate/engine-card.scenario.ts index a44d32f7c..7900d87d4 100644 --- a/apps/web/test/scenarios/areas/c-mate/engine-card.scenario.ts +++ b/apps/web/test/scenarios/areas/c-mate/engine-card.scenario.ts @@ -187,6 +187,45 @@ describe("C: an engine Mate's run card in a live conversation", () => { }), ); + // Milo's third stress run: each message sent moved the conversation 130 px in one frame. + it.effect("a message the person sends moves the conversation by a glide, never a jump", () => + Effect.gen(function* () { + const { s, chat } = yield* longConversation; + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 600))); + yield* trace(s.page, "The existing conversation is still here"); + yield* chat.when.send("Check the storefront again"); + const frames = yield* readTrace(s.page, 1500); + expect(frames.length, "ASSERTION: the send was sampled").toBeGreaterThan(20); + expect( + largestStep(frames, (frame) => frame.anchor), + "ASSERTION: the conversation never jumps as the message goes", + ).toBeLessThan(60); + yield* s.then.noExternalNetwork; + }), + ); + + // Milo's third stress run: a steer landing in the running run moved the conversation 105 px. + it.effect( + "a message steered into the running run lands above its card without moving the page", + () => + Effect.gen(function* () { + const { s, chat, engine } = yield* longConversation; + const run = engine.personRun("Check both pages"); + engine.note(run, "Checking the first page now."); + yield* chat.then.text("Checking the first page now."); + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 600))); + yield* trace(s.page, "Checking the first page now."); + yield* chat.when.send("also tell me the page title"); + const frames = yield* readTrace(s.page, 1500); + expect(frames.length, "ASSERTION: the steer was sampled").toBeGreaterThan(20); + expect( + largestStep(frames, (frame) => frame.anchor), + "ASSERTION: the conversation never jumps as the steer lands", + ).toBeLessThan(60); + yield* s.then.noExternalNetwork; + }), + ); + // Milo's stress run: the question opening in the composer pushed the conversation up 250 px in // one frame, and the answer pulled it back down. it.effect("a question opening in the composer and its answer never move the conversation", () => From 47102f7f4868b11a2c5f2cede71c4c035ca8a69a Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 22:28:03 +0200 Subject: [PATCH 17/19] test(web): the wake journey keeps its sentence; the steer mark's sentence goes by the owner's rule The engine card journey's wake keeps its title, its new assertions (the reply the person read stays through the wake) under it. A message sent into a run is drawn once, above its card (the owner's rule relayed with Milo's third stress run), so its mark in the card's chat is gone. Drops-test: keeps every message the person sent above the whole card, marked in its chat --- .../areas/c-mate/engine-card.scenario.ts | 154 +++++++++--------- 1 file changed, 76 insertions(+), 78 deletions(-) diff --git a/apps/web/test/scenarios/areas/c-mate/engine-card.scenario.ts b/apps/web/test/scenarios/areas/c-mate/engine-card.scenario.ts index 7900d87d4..a41aef997 100644 --- a/apps/web/test/scenarios/areas/c-mate/engine-card.scenario.ts +++ b/apps/web/test/scenarios/areas/c-mate/engine-card.scenario.ts @@ -106,85 +106,83 @@ describe("C: an engine Mate's run card in a live conversation", () => { it.layer(tempPostgresLayer, { excludeTestServices: true })((it) => { // Milo's stress run: the card waited on a background job, folded when the job ended, and the // run the job's end woke snapped it open 532 px in one frame and scrolled the page 768 px. - it.effect( - "a run its job's end wakes goes on in the folded card without moving the page, its answer under the reply the person was reading", - () => - Effect.gen(function* () { - const { s, chat, engine } = yield* longConversation; - const run = engine.personRun("Start the wait in the background"); - engine.item(run, { - kind: "call", - step: "command", - tool: { name: "Bash" }, - words: "Command run", - state: "done", - endedAt: 1791552412202, - input: "Bash: sleep 45 && echo done", - shows: { - toolName: "Bash", - command: "sleep 45 && echo done", - input: { description: "Wait 45 seconds in the background, then print done" }, - rawOutput: { - content: - "Command running in background with ID: b1job. Output is being written to: /tmp/x", - }, + it.effect("a run its job's end wakes goes on in the folded card without moving the page", () => + Effect.gen(function* () { + const { s, chat, engine } = yield* longConversation; + const run = engine.personRun("Start the wait in the background"); + engine.item(run, { + kind: "call", + step: "command", + tool: { name: "Bash" }, + words: "Command run", + state: "done", + endedAt: 1791552412202, + input: "Bash: sleep 45 && echo done", + shows: { + toolName: "Bash", + command: "sleep 45 && echo done", + input: { description: "Wait 45 seconds in the background, then print done" }, + rawOutput: { + content: + "Command running in background with ID: b1job. Output is being written to: /tmp/x", }, - }); - const job = engine.item(run, { - kind: "work", - work: "session.w2", - workKind: "shell", - status: "running", - title: "Wait 45 seconds in the background, then print done", - }); - engine.note(run, "The background wait hasn't printed yet.", { kind: "completed" }); - yield* chat.then.text("Waiting for its background command"); - engine.update(job, { status: "completed" }); - yield* chat.then.noText("Waiting for its background command"); - yield* chat.then.noText("running in the background"); - // The fold eases shut; the page is still once it has. - yield* Effect.promise(() => - s.page.waitForFunction( - () => - [...document.querySelectorAll("[data-run-chat]")].at(-1)?.dataset - .runFold === "folded", - { polling: "raf", timeout: 8000 }, - ), - ); - // Milo's third stress run: the reply under the folded card vanished into it at the wake, - // and the wake's answer landed 798 px below the view. - yield* trace(s.page, "The background wait hasn't printed yet."); - const wake = engine.startRun(run); - engine.note(wake, "The background wait finished and printed done.", { - kind: "completed", - }); - yield* chat.then.text("The background wait finished and printed done."); - const frames = yield* readTrace(s.page, 1500); - expect(frames.length, "ASSERTION: the wake was sampled").toBeGreaterThan(20); - expect( - largestStep(frames, (frame) => frame.card), - "ASSERTION: the card never snaps open in one frame", - ).toBeLessThan(48); - expect( - Math.max(...frames.map((frame) => frame.card)) - - Math.min(...frames.map((frame) => frame.card)), - "ASSERTION: the folded card keeps its height while the woken run goes on in it", - ).toBeLessThan(48); - expect( - largestStep(frames, (frame) => frame.top), - "ASSERTION: the list never jumps", - ).toBeLessThan(120); - expect(frames.at(-1)!.end, "ASSERTION: the view ends at the conversation's end").toBe(0); - expect( - frames.every((frame) => frame.anchor !== null), - "ASSERTION: the reply the person was reading stays on screen through the wake", - ).toBe(true); - expect( - largestStep(frames, (frame) => frame.anchor), - "ASSERTION: the reply the person was reading never jumps as the wake answers", - ).toBeLessThan(120); - yield* s.then.noExternalNetwork; - }), + }, + }); + const job = engine.item(run, { + kind: "work", + work: "session.w2", + workKind: "shell", + status: "running", + title: "Wait 45 seconds in the background, then print done", + }); + engine.note(run, "The background wait hasn't printed yet.", { kind: "completed" }); + yield* chat.then.text("Waiting for its background command"); + engine.update(job, { status: "completed" }); + yield* chat.then.noText("Waiting for its background command"); + yield* chat.then.noText("running in the background"); + // The fold eases shut; the page is still once it has. + yield* Effect.promise(() => + s.page.waitForFunction( + () => + [...document.querySelectorAll("[data-run-chat]")].at(-1)?.dataset + .runFold === "folded", + { polling: "raf", timeout: 8000 }, + ), + ); + // Milo's third stress run: the reply under the folded card vanished into it at the wake, + // and the wake's answer landed 798 px below the view. + yield* trace(s.page, "The background wait hasn't printed yet."); + const wake = engine.startRun(run); + engine.note(wake, "The background wait finished and printed done.", { + kind: "completed", + }); + yield* chat.then.text("The background wait finished and printed done."); + const frames = yield* readTrace(s.page, 1500); + expect(frames.length, "ASSERTION: the wake was sampled").toBeGreaterThan(20); + expect( + largestStep(frames, (frame) => frame.card), + "ASSERTION: the card never snaps open in one frame", + ).toBeLessThan(48); + expect( + Math.max(...frames.map((frame) => frame.card)) - + Math.min(...frames.map((frame) => frame.card)), + "ASSERTION: the folded card keeps its height while the woken run goes on in it", + ).toBeLessThan(48); + expect( + largestStep(frames, (frame) => frame.top), + "ASSERTION: the list never jumps", + ).toBeLessThan(120); + expect(frames.at(-1)!.end, "ASSERTION: the view ends at the conversation's end").toBe(0); + expect( + frames.every((frame) => frame.anchor !== null), + "ASSERTION: the reply the person was reading stays on screen through the wake", + ).toBe(true); + expect( + largestStep(frames, (frame) => frame.anchor), + "ASSERTION: the reply the person was reading never jumps as the wake answers", + ).toBeLessThan(120); + yield* s.then.noExternalNetwork; + }), ); // Milo's third stress run: each message sent moved the conversation 130 px in one frame. From c7cdd3787abe96211b8efb3d4e4da11d59e9dbed Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 22:44:11 +0200 Subject: [PATCH 18/19] fix(web): a run that only wrote to the person thought, live and after a reload The oracle's deep seeds (11 of 800): a card not held whole "worked" because its summary counted its notes as work, where live it read "thought". A paged card worked when its summary counts a call. The timeline test of a card held unread arranges its summary with the call it stands for. --- .../chat/MessagesTimeline.logic.test.ts | 7 +++++-- .../components/chat/MessagesTimeline.logic.ts | 6 ++++-- .../chat/engineRunCard.pipeline.test.ts | 18 ++++++++++++++++++ .../test/engine-oracle/runCard.oracle.test.ts | 4 ++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index ae2fab45d..0be153c54 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -94,7 +94,8 @@ function framed(scene: Scene): MessagesTimelineRow[] { turn(id), { pageRuns: { earlier: null, later: id }, - counts: { calls: {}, tools: {}, edited: 0 }, + // Its summary counts what it did: a call, its work. + counts: { calls: { command: 1 }, tools: {}, edited: 0 }, hasWork: true, holdsLines: false, since: at(0), @@ -3786,7 +3787,9 @@ describe("an engine run whose work the account does not hold yet", () => { expect(recordOf(rows(scene(["t1"])))).toMatchObject({ kind: "record", items: [] }); expect(statusOf(rows(scene(["t1"])))).toMatchObject({ worked: true }); // An outcome its worked line's effort is counted onto from the run's summary. - expect(recordOf(rows(scene(["t1"])))?.outcome).toMatchObject({ activity: [] }); + expect(recordOf(rows(scene(["t1"])))?.outcome).toMatchObject({ + activity: [{ kind: "command", count: 1 }], + }); }); it("draws an answer alone when its run did nothing else", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 531827a29..93d86abf8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -2805,9 +2805,11 @@ export function deriveMessagesTimelineRows(input: { ...(turn.interruption === undefined ? {} : { interruption: turn.interruption }), ...waited, ...(engineWorked === undefined ? {} : { workedMs: engineWorked }), - // A question it asked is work too: a run that only asked read "thought". + // A question it asked is work too: a run that only asked read "thought". A card not held + // whole worked when its summary counts a call: its notes alone are no work (it read + // "worked" after a reload where it had "thought" live). worked: - unheld || + (paging !== null && Object.keys(paging.counts.calls).length > 0) || items.some( (item) => item.kind !== "thought" && diff --git a/apps/web/src/components/chat/engineRunCard.pipeline.test.ts b/apps/web/src/components/chat/engineRunCard.pipeline.test.ts index ee5a989bb..3cd3cdbc7 100644 --- a/apps/web/src/components/chat/engineRunCard.pipeline.test.ts +++ b/apps/web/src/components/chat/engineRunCard.pipeline.test.ts @@ -595,3 +595,21 @@ it.each([ expect(card).toBeLessThan(reply); }, ); + +// The oracle's deep seeds: a run that only wrote to the person read "thought" live and "worked" +// after a reload, its summary's notes counted as work. +it.each([ + { held: "every item, as it streamed in live", paged: false }, + { held: "only what a reload reads", paged: true }, +])("a run that only wrote to the person thought, holding $held", ({ paged }) => { + const run = stressRun({ + summary: { items: 3, calls: {}, answerItemId: `${run1}/i/3`, lastItemSeq: 3 }, + } as never); + const items = [ + personItem(run1, 1, "How does it look?", { at: t0 }), + noteItem(run1, 2, "Reading the plan.", { answer: false, at: t0 + 30_000 }), + noteItem(run1, 3, "It looks fine.", { at: t0 + 40_000, answer: true } as never), + ]; + const card = cardOf(render({ runs: [run], items: paged ? items.slice(0, 1) : items, paged })); + expect(workedWords("Milo", card.status!)).toMatch(/^Milo thought /); +}); diff --git a/apps/web/test/engine-oracle/runCard.oracle.test.ts b/apps/web/test/engine-oracle/runCard.oracle.test.ts index f4dc5675a..cb704a870 100644 --- a/apps/web/test/engine-oracle/runCard.oracle.test.ts +++ b/apps/web/test/engine-oracle/runCard.oracle.test.ts @@ -123,6 +123,10 @@ const DEFECTS: ReadonlyArray = [ { op: "reload" }, ], }, + { + title: "a run that only wrote to the person thought, live and after a reload", + tree: [send, { op: "say" }, { op: "say" }, { op: "finish" }, { op: "reload" }], + }, ]; const said = ( From 43c3d7417ef7ed1796f963c98db345f5a4bb3248 Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 22:56:44 +0200 Subject: [PATCH 19/19] test(web): the steering picture journey reads the picture on its message, drawn once above the card The steered message's picture stood in the card's chat as well as on its message above the card; drawn once now, the journey holds it there, intact while the turn continues. --- .../test/scenarios/areas/c-mate/images.scenario.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/web/test/scenarios/areas/c-mate/images.scenario.ts b/apps/web/test/scenarios/areas/c-mate/images.scenario.ts index c4ef28875..c4ce26099 100644 --- a/apps/web/test/scenarios/areas/c-mate/images.scenario.ts +++ b/apps/web/test/scenarios/areas/c-mate/images.scenario.ts @@ -581,6 +581,7 @@ describe("C: conversation images", () => { "stream", ); yield* chat.then.text("printf earlier-work"); + // A message steered into the run is drawn once, above its card: its picture there. wire.message("steering", "user", "Use this steering picture", "stream", { attachments: [ { @@ -597,7 +598,7 @@ describe("C: conversation images", () => { yield* Effect.promise(() => s.page.waitForFunction(() => { const image = document.querySelector( - ".run-slot [data-person-pictures] img[data-image-src]", + '[data-message-role="user"] img[data-image-src]', ); if (image?.naturalWidth !== 160) return false; for (let node: Element | null = image; node; node = node.parentElement) { @@ -609,7 +610,7 @@ describe("C: conversation images", () => { const held = yield* Effect.promise(() => s.page.evaluateHandle(() => { const image = document.querySelector( - ".run-slot [data-person-pictures] img[data-image-src]", + '[data-message-role="user"] img[data-image-src]', )!; const src = image.src; const failures = new Set(); @@ -660,9 +661,7 @@ describe("C: conversation images", () => { } yield* Effect.promise(() => s.page.waitForFunction(() => - Boolean( - document.querySelector(".run-scroll [data-person-pictures] img[data-image-src]"), - ), + Boolean(document.querySelector('[data-message-role="user"] img[data-image-src]')), ), ); expect( @@ -671,7 +670,7 @@ describe("C: conversation images", () => { (held) => ({ sameSrc: document.querySelector( - ".run-scroll [data-person-pictures] img[data-image-src]", + '[data-message-role="user"] img[data-image-src]', )?.src === held.src, failures: held.finish(), }),