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 945a0b2eb..e83e0b985 100644 --- a/apps/server/src/engine/domain/decide.ts +++ b/apps/server/src/engine/domain/decide.ts @@ -1961,12 +1961,20 @@ const signalOne = (b: StepBuilder, sessionId: SessionId, signal: ProviderSignal) // session went) was stopped by the person, not lost. const byPerson = (signal.status === "lost" || signal.status === "stopped") && personStopped(b.state); + // 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: byPerson ? "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]; @@ -2005,7 +2013,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 8ee882f30..c362ed1a3 100644 --- a/apps/server/src/engine/engine.pump.test.ts +++ b/apps/server/src/engine/engine.pump.test.ts @@ -1274,3 +1274,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/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; 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/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); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ef56b2b14..54e0b4e61 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4486,8 +4486,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; @@ -4499,6 +4506,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() }); @@ -6706,7 +6714,7 @@ export default function ChatView(props: ChatViewProps) { { type: "sent", byPerson: true }, ) ) { - scrollToEnd(); + scrollToEnd(false, true); } return; } @@ -7031,7 +7039,7 @@ export default function ChatView(props: ChatViewProps) { { type: "sent", byPerson: sentBy === "person" }, ) ) { - scrollToEnd(); + scrollToEnd(false, sentBy === "person"); } setOptimisticUserMessages((existing) => [ ...existing, @@ -7737,7 +7745,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/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/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index bc1f69719..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), @@ -293,11 +294,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 +326,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 +1089,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 +1203,6 @@ describe("deriveMessagesTimelineRows", () => { "One question first.", "? Which accent colour do you prefer?", "> Green", - "> and make it bold", "· pnpm test", ]); }); @@ -2022,11 +2023,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 +2039,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({ @@ -3790,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 31843d6fd..93d86abf8 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; } @@ -1949,7 +1955,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); } /** @@ -2086,6 +2097,7 @@ function batchReads( function recordReads(input: { stretch: Stretch; answer: MessageEntry | null; + earlierAnswers: ReadonlyArray; writing: MessageEntry | null; brokeOffEntryId: string | null; tracked: TrackedCommands; @@ -2099,6 +2111,7 @@ function recordReads(input: { stretch.startedAt, stretch.endedAt, input.answer, + ...input.earlierAnswers, input.writing, input.brokeOffEntryId, input.until, @@ -2660,20 +2673,14 @@ 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, + earlierAnswers: turn.earlierAnswers, writing: turn.writing, brokeOffEntryId: turn.brokeOff?.entryId ?? null, tracked, @@ -2798,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" && @@ -2982,6 +2991,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/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.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 3c9208187..25121928a 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,10 +112,11 @@ 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, + browserPageWords, browserTakeState, formatWorkDuration, operationLineWords, @@ -2070,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 @@ -2276,50 +2275,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} @@ -2384,7 +2353,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, diff --git a/apps/web/src/components/chat/conversation.logic.ts b/apps/web/src/components/chat/conversation.logic.ts index a9852cf28..1d73cc66a 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, @@ -1261,6 +1281,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 +1300,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), @@ -1797,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/engineRunCard.pipeline.test.ts b/apps/web/src/components/chat/engineRunCard.pipeline.test.ts index 90ccde4b4..3cd3cdbc7 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", ); }); @@ -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 }), @@ -470,4 +472,144 @@ 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 }); + }, + ); +}); +// 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); + }, +); + +// 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/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/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); } 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" && 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"; } 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..cb704a870 --- /dev/null +++ b/apps/web/test/engine-oracle/runCard.oracle.test.ts @@ -0,0 +1,160 @@ +// @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" }, + ], + }, + { + 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 = ( + 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..481d9f557 --- /dev/null +++ b/apps/web/test/engine-oracle/tree.ts @@ -0,0 +1,572 @@ +/** + * 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); + // 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; + 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/test/scenarios/areas/c-mate/engine-card.scenario.ts b/apps/web/test/scenarios/areas/c-mate/engine-card.scenario.ts index 58b07ee9e..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 @@ -149,7 +149,9 @@ describe("C: an engine Mate's run card in a live conversation", () => { { polling: "raf", timeout: 8000 }, ), ); - yield* trace(s.page, "Start the wait in the background"); + // 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", @@ -171,10 +173,57 @@ describe("C: an engine Mate's run card in a live conversation", () => { "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. + 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", () => 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(), }), 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/packages/client-runtime/src/data/projections/mateEngine.ts b/packages/client-runtime/src/data/projections/mateEngine.ts index 4bfd00837..dcacf84b4 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); @@ -928,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) 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"], ]); 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, 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",