From 03479c34a1f6cba7c11e75a48f8e2c6690e368c1 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 21 Aug 2026 17:01:27 +0000 Subject: [PATCH] Delete the core tool-name tables; rows render from persisted data and structure (WS3 layer 5) Core no longer keys any projection on a tool name. The thread-view DELEGATION/READ/SEARCH/LIST name sets, the suppression name set, the TodoWrite-by-name todo reader and @bb/domain's claude-task-tools are gone; statusLabels leave the event, the timeline row and the daemon wire (HOST_DAEMON_PROTOCOL_VERSION 150 -> 151); the events.tool_name virtual column and its index are replaced by kind-based partial indexes (migration 0107); turn/plan/updated leaves the noise list and decodes at read time into a planSteps item so legacy codex plans still render. A persisted tool call that other rows name as their parentToolCallId becomes the delegation row structurally (same row id as before, label metadata from conventional argument keys), so old threads keep their nesting. Pi's read/grep/find/ls knowledge moves into its own translation as fileRead/search items. The corpus gate gains an identity-based row-class mode (BB_PROVIDER_CORPUS_ROW_CLASSES) because a change that adds, removes or moves rows cannot be expressed by pointer allowlist; this layer's nine classes live in allowlists/ws3-layer5-row-classes.json. Co-Authored-By: Claude --- .../interaction-request.test.ts | 8 +- .../src/screens/dev/work-row-fixtures.ts | 7 +- apps/server/src/internal/events.ts | 36 +- .../internal-events-tool-calls.test.ts | 15 +- .../test/provider-corpus/allowlists/README.md | 18 +- .../allowlists/ws3-layer5-row-classes.json | 133 + .../test/provider-corpus/corpus-harness.ts | 9 +- .../test/provider-corpus/env-file-path.ts | 29 + .../provider-corpus/row-diff-classes.test.ts | 173 + .../test/provider-corpus/row-diff-classes.ts | 514 +++ .../provider-corpus/row-snapshots.test.ts | 88 + .../threads/timeline-head-state.test.ts | 28 +- docs/api_to_audit.md | 13 +- docs/debugging-and-qa.md | 24 + .../src/pi/delta-translation.test.ts | 95 +- .../agent-runtime/src/pi/delta-translation.ts | 107 +- .../db/drizzle/0107_kind_based_indexes.sql | 5 + packages/db/drizzle/meta/0107_snapshot.json | 3722 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/data/events.ts | 102 +- packages/db/src/schema.ts | 33 +- packages/db/test/migrate.test.ts | 9 +- packages/db/test/query-plans.test.ts | 24 +- packages/domain/src/claude-task-tools.ts | 101 - packages/domain/src/index.ts | 1 - packages/domain/src/legacy-thread-events.ts | 50 + packages/domain/src/provider-event.ts | 7 +- packages/domain/src/stored-thread-event.ts | 9 +- packages/host-daemon-contract/src/protocol.ts | 9 +- packages/host-daemon-contract/src/session.ts | 16 - .../test/contract.test.ts | 64 +- packages/plugin-sdk/src/provider-bridge.ts | 3 - .../recordings/parity-allowlist.json | 8 + .../recordings/row-counts.json | 2 +- .../src/testing/parity.ts | 11 + .../provider-parity/src/parity.self.test.ts | 42 + .../server-contract/src/thread-timeline.ts | 8 - .../thread-view/src/build-event-projection.ts | 133 +- .../thread-view/src/build-thread-timeline.ts | 3 - .../src/event-projection-message.ts | 1 - packages/thread-view/src/exec-lifecycle.ts | 163 +- .../src/normalize-event-projection.ts | 68 + .../thread-view/src/timeline-noise-events.ts | 5 +- .../thread-view/src/timeline-row-title.ts | 18 - .../src/todo-snapshot-extraction.ts | 404 +- .../src/tool-activity-projection.ts | 11 +- packages/thread-view/src/tool-call-parsing.ts | 46 +- .../thread-view/src/tool-call-suppression.ts | 30 +- .../test/background-task-timeline.test.ts | 11 +- .../test/build-thread-timeline.test.ts | 241 +- .../timeline-cli-rendering.snapshots.test.ts | 118 +- .../test/timeline-row-title.test.ts | 62 +- .../test/todo-snapshot-extraction.test.ts | 785 +--- .../test/v3-item-projection.test.ts | 23 +- scripts/provider-corpus/classify-row-diff.ts | 80 + scripts/provider-corpus/snapshot-rows.sh | 3 + turbo.json | 3 +- 57 files changed, 5601 insertions(+), 2137 deletions(-) create mode 100644 apps/server/test/provider-corpus/allowlists/ws3-layer5-row-classes.json create mode 100644 apps/server/test/provider-corpus/env-file-path.ts create mode 100644 apps/server/test/provider-corpus/row-diff-classes.test.ts create mode 100644 apps/server/test/provider-corpus/row-diff-classes.ts create mode 100644 packages/db/drizzle/0107_kind_based_indexes.sql create mode 100644 packages/db/drizzle/meta/0107_snapshot.json delete mode 100644 packages/domain/src/claude-task-tools.ts create mode 100644 scripts/provider-corpus/classify-row-diff.ts diff --git a/apps/app/src/components/thread/pending-interactions/interaction-request.test.ts b/apps/app/src/components/thread/pending-interactions/interaction-request.test.ts index 74d802c764..1580cb4fb9 100644 --- a/apps/app/src/components/thread/pending-interactions/interaction-request.test.ts +++ b/apps/app/src/components/thread/pending-interactions/interaction-request.test.ts @@ -47,7 +47,7 @@ describe("classifyInteractionRequest", () => { }); it("lifts today's plan approval subject into a plan_review request that resolves as an approval", () => { - const payload = { + const payload: PendingInteraction["payload"] = { kind: "approval", reason: null, availableDecisions: ["allow_once", "deny"], @@ -57,7 +57,7 @@ describe("classifyInteractionRequest", () => { plan: "# Plan\n\n1. Do it", planFilePath: "/tmp/plan.md", }, - } as const; + }; expect(classifyInteractionRequest({ ...base, payload })).toEqual({ family: "request", kind: "plan_review", @@ -73,7 +73,9 @@ describe("classifyInteractionRequest", () => { }); it("classifies a user question and the target plan_review payload as requests", () => { - const questions = [{ id: "q1", prompt: "Which?", multiSelect: false }]; + const questions = [ + { id: "q1", prompt: "Which?", multiSelect: false, allowFreeText: true }, + ]; expect( classifyInteractionRequest({ payload: { kind: "user_question", questions }, diff --git a/apps/mobile/src/screens/dev/work-row-fixtures.ts b/apps/mobile/src/screens/dev/work-row-fixtures.ts index 7b8724bd54..92c60dbc06 100644 --- a/apps/mobile/src/screens/dev/work-row-fixtures.ts +++ b/apps/mobile/src/screens/dev/work-row-fixtures.ts @@ -690,9 +690,10 @@ export function buildWorkRowFixtureSections(): WorkRowFixtureSection[] { }), tool("tool-labels", { toolName: "deploy_preview", - statusLabels: { - pending: "Deploying preview", - completed: "Deployed preview", + presentation: { + label: { pending: "Deploying preview", completed: "Deployed preview" }, + icon: { glyph: "Globe" }, + title: "bb/mobile", }, toolArgs: { branch: "bb/mobile" }, output: "https://preview.example.com/bb-mobile", diff --git a/apps/server/src/internal/events.ts b/apps/server/src/internal/events.ts index eb7dd1e7c5..cef6b4919e 100644 --- a/apps/server/src/internal/events.ts +++ b/apps/server/src/internal/events.ts @@ -47,7 +47,6 @@ import { } from "../services/lib/error-log-fields.js"; import { applyLoggedThreadLifecycleEvent } from "../services/threads/lifecycle-outcome.js"; import { applyTurnCompletedEvent } from "./turn-completed-events.js"; -import { findPluginAgentTool } from "../services/plugins/plugin-agent-contributions.js"; import { getInactiveSessionLogFields, requireAuthenticatedDaemonSession, @@ -284,39 +283,6 @@ function toStoredEvent(args: ToStoredEventArgs): AppendDaemonEventInput { }; } -/** - * Plugin status labels are server-owned presentation metadata: providers do - * not know about them, and old daemon clients therefore need no protocol - * change. Persist the snapshot on both lifecycle events so historical rows - * remain readable if a plugin later reloads or disappears. - */ -function withPluginToolStatusLabels( - envelope: HostDaemonEventEnvelope, -): HostDaemonEventEnvelope { - const event = envelope.event; - if ( - (event.type !== "item/started" && event.type !== "item/completed") || - event.item.type !== "toolCall" || - event.item.server !== undefined - ) { - return envelope; - } - const statusLabels = findPluginAgentTool(event.item.tool)?.record - .experimentalStatusLabels; - if (statusLabels === null || statusLabels === undefined) return envelope; - - return { - ...envelope, - event: { - ...event, - item: { - ...event.item, - statusLabels, - }, - }, - }; -} - function notifyInsertedEventThreads( deps: NotifyInsertedEventThreadsDeps, args: NotifyInsertedEventThreadsArgs, @@ -925,7 +891,7 @@ export function registerInternalEventRoutes(app: Hono, deps: AppDeps): void { } return { ...entry, - envelope: withPluginToolStatusLabels(validated), + envelope: validated, }; }); const eventInputs = labelledEntries.map((entry) => { diff --git a/apps/server/test/internal/internal-events-tool-calls.test.ts b/apps/server/test/internal/internal-events-tool-calls.test.ts index 363eef4635..40e6860c10 100644 --- a/apps/server/test/internal/internal-events-tool-calls.test.ts +++ b/apps/server/test/internal/internal-events-tool-calls.test.ts @@ -172,7 +172,7 @@ describe("internal event and tool-call routes", () => { }); }); - it("snapshots native plugin status labels into tool-call events", async () => { + it("persists a native plugin tool call as the bridge sent it: no server-side label enrichment", async () => { await withTestHarness(async (harness) => { const statusLabels = { pending: "Reading project overview", @@ -255,11 +255,14 @@ describe("internal event and tool-call routes", () => { event.type === "item/started" || event.type === "item/completed", ); expect(storedToolEvents).toHaveLength(2); - expect( - storedToolEvents.map( - (event) => JSON.parse(event.data).item.statusLabels, - ), - ).toEqual([statusLabels, statusLabels]); + // The plugin's labels reach the row only through the presentation + // the bridge stamps on the item (resolved onto the tool definition + // it receives); the server no longer writes a `statusLabels` key. + for (const event of storedToolEvents) { + const item = JSON.parse(event.data).item; + expect(item.tool).toBe(record.name); + expect(item).not.toHaveProperty("statusLabels"); + } } finally { setPluginAgentContributions(undefined); } diff --git a/apps/server/test/provider-corpus/allowlists/README.md b/apps/server/test/provider-corpus/allowlists/README.md index 9ee1c35d07..c89524b289 100644 --- a/apps/server/test/provider-corpus/allowlists/README.md +++ b/apps/server/test/provider-corpus/allowlists/README.md @@ -11,8 +11,16 @@ BB_PROVIDER_CORPUS_ALLOWLIST=apps/server/test/provider-corpus/allowlists/.js ``` Entries use the same schema as `snapshots/allowlist.json` (scope, `path` -glob, `pr`, `reason`) and are merged after it. Never write a snapshot into -the shared `snapshots/rows` from a feature branch; point -`BB_PROVIDER_CORPUS_SNAPSHOT_DIR` at a shadow directory instead. When the -PR merges and `main` is re-minted, its entries go stale and the file is -deleted. +glob, `pr`, `reason`) and are merged after it. + +A change that adds, removes, or moves rows cannot be expressed by pointer: +carry a row-class file (`-row-classes.json`, schema in +`../row-diff-classes.ts`) and compare with +`BB_PROVIDER_CORPUS_ROW_CLASSES=apps/server/test/provider-corpus/allowlists/-row-classes.json` +instead. The gate matches rows by identity and requires every change to +fall into a named class; see docs/debugging-and-qa.md, "Provider Corpus". + +Never write a snapshot into the shared `snapshots/rows` from a feature +branch; point `BB_PROVIDER_CORPUS_SNAPSHOT_DIR` at a shadow directory +instead. When the PR merges and `main` is re-minted, its entries go stale +and the file is deleted. diff --git a/apps/server/test/provider-corpus/allowlists/ws3-layer5-row-classes.json b/apps/server/test/provider-corpus/allowlists/ws3-layer5-row-classes.json new file mode 100644 index 0000000000..f4e680c49d --- /dev/null +++ b/apps/server/test/provider-corpus/allowlists/ws3-layer5-row-classes.json @@ -0,0 +1,133 @@ +[ + { + "name": "legacy-plan-rows", + "reason": "#2232 (layer 5): persisted codex `turn/plan/updated` notifications decode into planSteps items at read time, so old codex threads show their plan snapshots (the window no longer excludes the event type).", + "match": { + "added": { + "kind": "work", + "workKind": "plan-steps" + } + } + }, + { + "name": "unsuppressed-by-name", + "reason": "#2232 (layer 5): the tool-name suppression set (TodoWrite, TodoRead, ToolSearch, Task*, AskUserQuestion) is deleted; a persisted call without a bridge `presentation.suppress` renders like any other tool call.", + "match": { + "added": { + "kind": "work", + "workKind": "tool" + } + } + }, + { + "name": "exploration-intent-by-name", + "reason": "#2232 (layer 5): the Read/Grep/Glob (and lowercase) name sets are deleted; a persisted bare tool call derives no read/search/list intent and titles from its name and arguments. Bridges emit fileRead/search items for new threads.", + "match": { + "changed": { + "kind": "work", + "workKind": "tool", + "fields": [ + "activityIntents" + ] + } + } + }, + { + "name": "delegation-from-children", + "reason": "#2232 (layer 5): the Agent/Task/spawnAgent/resumeAgent name set is deleted; a tool call that other rows name as their parentToolCallId becomes the delegation row structurally.", + "match": { + "reshaped": { + "from": { + "kind": "work", + "workKind": "tool" + }, + "to": { + "kind": "work", + "workKind": "delegation" + } + } + } + }, + { + "name": "delegation-rows-gain-v3-fields", + "reason": "#2192 (layer 1): every delegation row carries `childRef` and `background`; a persisted tool-call delegation has no child ref and is foreground, so both are null/false against the main baseline.", + "match": { + "changed": { + "kind": "work", + "workKind": "delegation", + "fields": [ + "background", + "childRef" + ] + } + } + }, + { + "name": "delegation-output-unstripped", + "reason": "#2232 (layer 5): core no longer strips `agentId:` / `` lines from a delegation's result by tool name; a persisted Agent result keeps those lines in the row's expanded output. The v3 delegation item carries a bridge-owned `summary` instead.", + "match": { + "changed": { + "kind": "work", + "workKind": "delegation", + "fields": [ + "background", + "childRef", + "output" + ] + } + } + }, + { + "name": "parented-rows-surface", + "reason": "#2232 (layer 5): a row whose parentToolCallId named a call outside the delegation name set (Claude task notifications under Monitor/TaskOutput) was dropped at the root; it now nests under that call, which the structural rule makes the delegation row.", + "match": { + "added": { + "kind": "work", + "workKind": "workflow", + "nested": true + } + } + }, + { + "name": "turn-segments-rejoined", + "reason": "#2232 (layer 5): a call the name set used to hide (ToolSearch, TodoWrite) now renders between two assistant texts, so the second text is no longer a visible response that splits the turn; the text folds into the single turn segment.", + "match": { + "resegmented": { + "kind": "turn" + } + } + }, + { + "name": "turn-segments-rejoined", + "reason": "#2232 (layer 5): the assistant text that used to split the turn moves from the root into the turn's children (see the resegmented turn).", + "match": { + "moved": { + "kind": "conversation", + "role": "assistant" + } + } + }, + { + "name": "unsuppressed-by-name", + "reason": "#2232 (layer 5): a turn whose only work was name-hidden calls (TaskUpdate, ToolSearch) projected no turn row; with the name set gone the turn renders with those tool rows.", + "match": { + "added": { + "kind": "turn" + } + } + }, + { + "name": "unsuppressed-by-name", + "reason": "#2232 (layer 5): a name-hidden call that ended in an error or interruption only got a row at its completion event; it now spans from its start event.", + "match": { + "changed": { + "kind": "work", + "workKind": "tool", + "fields": [ + "sourceSeqStart", + "startedAt" + ] + } + } + } +] diff --git a/apps/server/test/provider-corpus/corpus-harness.ts b/apps/server/test/provider-corpus/corpus-harness.ts index fd17b73a2d..f6e1dff5ac 100644 --- a/apps/server/test/provider-corpus/corpus-harness.ts +++ b/apps/server/test/provider-corpus/corpus-harness.ts @@ -28,6 +28,7 @@ import type { ThreadTimelineResponse } from "@bb/server-contract"; import type { CorpusThread } from "@bb/test-helpers"; import { sql } from "drizzle-orm"; import { z } from "zod"; +import { resolveRepoRelativeFile } from "./env-file-path.js"; import { THREAD_TIMELINE_DEFAULT_SEGMENT_LIMIT, buildThreadTimelineWithProfile, @@ -561,11 +562,9 @@ export function readAllowlist( } const extraPath = env[ALLOWLIST_FILE_ENV]; if (extraPath !== undefined && extraPath !== "") { - const resolved = path.resolve(extraPath); - if (!fs.existsSync(resolved)) { - throw new Error(`${ALLOWLIST_FILE_ENV} names a missing file: ${resolved}`); - } - entries.push(...readAllowlistFile(resolved)); + entries.push( + ...readAllowlistFile(resolveRepoRelativeFile(ALLOWLIST_FILE_ENV, extraPath)), + ); } return entries; } diff --git a/apps/server/test/provider-corpus/env-file-path.ts b/apps/server/test/provider-corpus/env-file-path.ts new file mode 100644 index 0000000000..469059b642 --- /dev/null +++ b/apps/server/test/provider-corpus/env-file-path.ts @@ -0,0 +1,29 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** + * Turbo runs the suite with `apps/server` as the working directory while the + * documented invocations name files relative to the repository root. A + * relative path is tried against the working directory and then each + * ancestor, so both spellings work; a file that exists nowhere is an error + * rather than a silently empty gate. + */ +export function resolveRepoRelativeFile(envName: string, value: string): string { + if (path.isAbsolute(value)) { + if (!fs.existsSync(value)) { + throw new Error(`${envName} names a missing file: ${value}`); + } + return value; + } + let dir = process.cwd(); + for (;;) { + const candidate = path.join(dir, value); + if (fs.existsSync(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + throw new Error( + `${envName} names a missing file: ${value} (tried ${process.cwd()} and its ancestors)`, + ); +} diff --git a/apps/server/test/provider-corpus/row-diff-classes.test.ts b/apps/server/test/provider-corpus/row-diff-classes.test.ts new file mode 100644 index 0000000000..a7c6252c39 --- /dev/null +++ b/apps/server/test/provider-corpus/row-diff-classes.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "vitest"; +import { + CONTAINER_BOUNDS_CLASS, + classifyRowSnapshotDiff, + createRowDiffReport, + describeRowChange, + idleRowDiffClasses, + type RowDiffClass, + type RowSnapshotVariants, + type SnapshotRow, +} from "./row-diff-classes.js"; + +function turn( + turnId: string, + segment: number | null, + children: SnapshotRow[] | null, + bounds: { summaryCount: number; sourceSeqStart: number; sourceSeqEnd: number }, +): SnapshotRow { + return { + kind: "turn", + id: segment === null ? `t:${turnId}:turn` : `t:${turnId}:turn:${segment}`, + turnId, + children, + status: "completed", + ...bounds, + }; +} + +function tool(callId: string, extra: Partial = {}): SnapshotRow { + return { + kind: "work", + workKind: "tool", + id: `t:tool:${callId}`, + callId, + toolName: "Read", + activityIntents: [], + output: "", + ...extra, + }; +} + +function assistant(itemId: string, text: string): SnapshotRow { + return { kind: "conversation", role: "assistant", id: `t:assistant:${itemId}`, text }; +} + +function snapshot(variants: Record): RowSnapshotVariants { + return { + variants: Object.fromEntries( + Object.entries(variants).map(([name, rows]) => [name, { pages: [{ rows }] }]), + ), + }; +} + +function run( + before: RowSnapshotVariants, + after: RowSnapshotVariants, + classes: RowDiffClass[], +) { + const report = createRowDiffReport(); + const changes = classifyRowSnapshotDiff("p/thr", before, after, classes, report); + return { changes, report }; +} + +describe("classifyRowSnapshotDiff", () => { + it("matches rows by identity, so an inserted sibling is one added change and the shifted rows are untouched", () => { + const before = snapshot({ + nested: [turn("t1", null, [tool("a"), tool("c")], { summaryCount: 2, sourceSeqStart: 1, sourceSeqEnd: 5 })], + default: [turn("t1", null, null, { summaryCount: 2, sourceSeqStart: 1, sourceSeqEnd: 5 })], + }); + const after = snapshot({ + nested: [turn("t1", null, [tool("a"), tool("b"), tool("c")], { summaryCount: 3, sourceSeqStart: 1, sourceSeqEnd: 5 })], + default: [turn("t1", null, null, { summaryCount: 3, sourceSeqStart: 1, sourceSeqEnd: 5 })], + }); + const { report } = run(before, after, [ + { name: "unhidden", reason: "r", match: { added: { kind: "work", workKind: "tool" } } }, + ]); + expect(report.unclassified).toEqual([]); + expect(report.claims.get("unhidden")).toBe(1); + // The turn's summaryCount follows its child in BOTH variants: the + // default variant's turn has no children, so it borrows the nested + // variant's verdict. + expect(report.claims.get(CONTAINER_BOUNDS_CLASS)).toBe(2); + }); + + it("reports a change no class claims, with the changed field set", () => { + const before = snapshot({ default: [tool("a", { output: "x" })] }); + const after = snapshot({ default: [tool("a", { output: "y", toolName: "Grep" })] }); + const { report } = run(before, after, [ + { name: "output-only", reason: "r", match: { changed: { workKind: "tool", fields: ["output"] } } }, + ]); + expect(report.unclassified.map(describeRowChange)).toEqual([ + "changed work/tool [output,toolName]", + ]); + expect(idleRowDiffClasses( + [{ name: "output-only", reason: "r", match: { changed: { workKind: "tool", fields: ["output"] } } }], + report, + )).toEqual(["output-only"]); + }); + + it("treats a turn that lost a segment as re-segmented and the row that folded into it as moved, not removed", () => { + const text = assistant("m18", "I'll wait for the agent."); + const before = snapshot({ + nested: [ + turn("t1", 0, [tool("a")], { summaryCount: 1, sourceSeqStart: 1, sourceSeqEnd: 3 }), + text, + turn("t1", 1, [tool("b")], { summaryCount: 1, sourceSeqStart: 5, sourceSeqEnd: 7 }), + ], + default: [ + turn("t1", 0, null, { summaryCount: 1, sourceSeqStart: 1, sourceSeqEnd: 3 }), + text, + turn("t1", 1, null, { summaryCount: 1, sourceSeqStart: 5, sourceSeqEnd: 7 }), + ], + }); + const after = snapshot({ + nested: [turn("t1", null, [tool("a"), text, tool("b")], { summaryCount: 3, sourceSeqStart: 1, sourceSeqEnd: 7 })], + default: [turn("t1", null, null, { summaryCount: 3, sourceSeqStart: 1, sourceSeqEnd: 7 })], + }); + const { report } = run(before, after, [ + { name: "rejoined", reason: "r", match: { resegmented: { kind: "turn" } } }, + { name: "rejoined", reason: "r", match: { moved: { kind: "conversation", role: "assistant" } } }, + ]); + expect(report.unclassified).toEqual([]); + // Two variants × (one resegmented turn + one moved text). + expect(report.claims.get("rejoined")).toBe(4); + }); + + it("recurses into a reshaped row's children and ignores a child's id prefix change", () => { + const child = (prefix: string) => ({ + kind: "conversation", + role: "assistant", + id: `${prefix}:child:t:assistant:c1`, + text: "hi", + }); + const before = snapshot({ + nested: [{ ...tool("agent"), childRows: undefined }], + }); + const after = snapshot({ + nested: [ + { + kind: "work", + workKind: "delegation", + id: "t:delegation:agent", + callId: "agent", + toolName: "Read", + output: "", + childRows: [child("t:delegation:agent")], + }, + ], + }); + const { report } = run(before, after, [ + { + name: "structural", + reason: "r", + match: { reshaped: { from: { workKind: "tool" }, to: { workKind: "delegation" } } }, + }, + { name: "surfaced", reason: "r", match: { added: { kind: "conversation", nested: true } } }, + ]); + expect(report.unclassified).toEqual([]); + expect(report.claims.get("structural")).toBe(1); + expect(report.claims.get("surfaced")).toBe(1); + }); + + it("does not let a bounds-only turn change hide behind children that did not change", () => { + const before = snapshot({ + nested: [turn("t1", null, [tool("a")], { summaryCount: 1, sourceSeqStart: 1, sourceSeqEnd: 3 })], + }); + const after = snapshot({ + nested: [turn("t1", null, [tool("a")], { summaryCount: 4, sourceSeqStart: 1, sourceSeqEnd: 3 })], + }); + const { report } = run(before, after, []); + expect(report.unclassified.map(describeRowChange)).toEqual(["changed turn [summaryCount]"]); + }); +}); diff --git a/apps/server/test/provider-corpus/row-diff-classes.ts b/apps/server/test/provider-corpus/row-diff-classes.ts new file mode 100644 index 0000000000..d3ae609c7b --- /dev/null +++ b/apps/server/test/provider-corpus/row-diff-classes.ts @@ -0,0 +1,514 @@ +/** + * Identity-based classification of row-snapshot changes. + * + * The pointer diff in `corpus-harness.ts` is exact for field-level changes + * but useless when a projection change ADDS or REMOVES rows: every later + * sibling shifts and the diff reports the whole turn. This engine matches + * rows by identity instead (`callId`, `itemId`, `interactionId`, the turn id, + * or the row id) and buckets each change into a named class from a JSON + * file the PR carries. A change no class claims fails the gate, so a PR + * that intentionally changes rows proves its change is exactly the classes + * it named and nothing else. + * + * Class file shape (see `allowlists/README.md`): + * { "name", "reason", "match": Matcher }[] + * where Matcher is exactly one of + * { "added": Shape } a row only the candidate has + * { "removed": Shape } a row only the baseline has + * { "changed": Shape & { "fields": string[] } } + * a matched row whose changed field set is within + * `fields` (`id:prefix` stands for an id whose + * only difference is the nesting prefix) + * { "reshaped": { "from": Shape, "to": Shape } } + * a matched row whose kind/workKind changed + * { "moved": Shape } a row that left one nesting level and appeared + * at another + * { "resegmented": Shape } an identity the two sides project a different + * number of times (a turn split into fewer + * visible segments) + * and Shape narrows by `kind`, `workKind`, `role` and `nested` (whether the + * row id carries a `:child:` prefix). + * + * Container fields (`children`, `childRows`) are recursed into, never + * compared as values. A turn whose only changed fields are its bounds + * (`summaryCount`, `sourceSeq*`, timestamps, status) is reported under the + * built-in `container-bounds` class when a child of that turn changed. + */ +import fs from "node:fs"; +import { z } from "zod"; +import { resolveRepoRelativeFile } from "./env-file-path.js"; + +const shapeSchema = z + .object({ + kind: z.string().optional(), + workKind: z.string().optional(), + role: z.string().optional(), + nested: z.boolean().optional(), + }) + .strict(); +export type RowShapeSpec = z.infer; + +const matcherSchema = z.union([ + z.object({ added: shapeSchema }).strict(), + z.object({ removed: shapeSchema }).strict(), + z + .object({ + changed: shapeSchema.extend({ fields: z.array(z.string()).min(1) }), + }) + .strict(), + z.object({ reshaped: z.object({ from: shapeSchema, to: shapeSchema }) }).strict(), + z.object({ moved: shapeSchema }).strict(), + z.object({ resegmented: shapeSchema }).strict(), +]); + +const rowClassSchema = z + .object({ + name: z.string().min(1), + reason: z.string().min(1), + match: matcherSchema, + }) + .strict(); +export type RowDiffClass = z.infer; + +export const ROW_CLASSES_FILE_ENV = "BB_PROVIDER_CORPUS_ROW_CLASSES"; + +export function readRowDiffClasses(filePath: string): RowDiffClass[] { + return z.array(rowClassSchema).parse(JSON.parse(fs.readFileSync(filePath, "utf8"))); +} + +export function resolveRowDiffClassesPath( + env: NodeJS.ProcessEnv = process.env, +): string | null { + const value = env[ROW_CLASSES_FILE_ENV]; + return value === undefined || value === "" + ? null + : resolveRepoRelativeFile(ROW_CLASSES_FILE_ENV, value); +} + +/** A timeline row as the snapshot stores it: a JSON object we read loosely. */ +export type SnapshotRow = Record; + +export interface RowSnapshotVariants { + variants?: Record; +} + +export type RowChange = + | { type: "added"; thread: string; id: string; row: SnapshotRow } + | { type: "removed"; thread: string; id: string; row: SnapshotRow } + | { + type: "changed"; + thread: string; + id: string; + before: SnapshotRow; + after: SnapshotRow; + fields: string[]; + } + | { + type: "reshaped"; + thread: string; + id: string; + before: SnapshotRow; + after: SnapshotRow; + } + | { + type: "moved"; + thread: string; + id: string; + before: SnapshotRow; + after: SnapshotRow; + } + | { + type: "resegmented"; + thread: string; + id: string; + before: SnapshotRow[]; + after: SnapshotRow[]; + }; + +export const CONTAINER_BOUNDS_CLASS = "container-bounds"; + +const CONTAINER_FIELDS = ["children", "childRows"] as const; +const CONTAINER_BOUND_FIELDS = new Set([ + "summaryCount", + "sourceSeqEnd", + "sourceSeqStart", + "completedAt", + "createdAt", + "startedAt", + "status", +]); + +function str(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +/** + * A row nested under a delegation carries its parent's id as a prefix + * (`:child:`); the own id is the stable part, + * because a change to how the parent row is identified re-prefixes every + * descendant. + */ +function ownRowId(id: string): string { + const marker = ":child:"; + const index = id.lastIndexOf(marker); + return index === -1 ? id : id.slice(index + marker.length); +} + +export function rowIdentity(row: SnapshotRow): string { + const id = str(row.id) ?? ""; + if (row.kind === "work") { + const key = str(row.callId) ?? str(row.itemId) ?? str(row.interactionId); + if (key !== undefined) return `work:${key}`; + } + if (row.kind === "turn") return `turn:${str(row.turnId) ?? id}`; + return `${String(row.kind)}:${ownRowId(id)}`; +} + +export function rowShape(row: SnapshotRow): string { + return row.kind === "work" + ? `${String(row.kind)}/${String(row.workKind)}` + : String(row.kind); +} + +function matchesShape(spec: RowShapeSpec | undefined, row: SnapshotRow): boolean { + if (!spec) return true; + if (spec.kind !== undefined && row.kind !== spec.kind) return false; + if (spec.workKind !== undefined && row.workKind !== spec.workKind) return false; + if (spec.role !== undefined && row.role !== spec.role) return false; + if ( + spec.nested !== undefined && + (str(row.id) ?? "").includes(":child:") !== spec.nested + ) { + return false; + } + return true; +} + +function classMatches(cls: RowDiffClass, change: RowChange): boolean { + const m = cls.match; + if ("added" in m) { + return change.type === "added" && matchesShape(m.added, change.row); + } + if ("removed" in m) { + return change.type === "removed" && matchesShape(m.removed, change.row); + } + if ("changed" in m) { + return ( + change.type === "changed" && + matchesShape(m.changed, change.after) && + change.fields.every((field) => m.changed.fields.includes(field)) + ); + } + if ("reshaped" in m) { + return ( + change.type === "reshaped" && + matchesShape(m.reshaped.from, change.before) && + matchesShape(m.reshaped.to, change.after) + ); + } + if ("moved" in m) { + return change.type === "moved" && matchesShape(m.moved, change.after); + } + return ( + change.type === "resegmented" && + change.after.length > 0 && + matchesShape(m.resegmented, change.after[0] as SnapshotRow) + ); +} + +export function describeRowChange(change: RowChange): string { + switch (change.type) { + case "changed": + return `changed ${rowShape(change.after)} [${change.fields.join(",")}]`; + case "reshaped": + return `reshaped ${rowShape(change.before)} → ${rowShape(change.after)}`; + case "resegmented": { + const sample = change.after[0] ?? change.before[0]; + return `resegmented ${sample ? rowShape(sample) : "?"} ${change.before.length}→${change.after.length}`; + } + default: + return `${change.type} ${rowShape(change.type === "moved" ? change.after : change.row)}`; + } +} + +export interface RowDiffReport { + /** Changes per class name, including the built-in `container-bounds`. */ + claims: Map; + /** One representative change per class, for the run log. */ + examples: Map; + unclassified: RowChange[]; +} + +export function createRowDiffReport(): RowDiffReport { + return { claims: new Map(), examples: new Map(), unclassified: [] }; +} + +interface SharedThreadState { + turnsWithChildChanges: Set; + movedRows: Map; +} + +interface VariantDiff { + thread: string; + classes: readonly RowDiffClass[]; + report: RowDiffReport; + removed: Map; + added: Map; + shared: SharedThreadState; +} + +function claim(diff: VariantDiff, name: string, change: RowChange): void { + diff.report.claims.set(name, (diff.report.claims.get(name) ?? 0) + 1); + if (!diff.report.examples.has(name)) diff.report.examples.set(name, change); +} + +function classify(diff: VariantDiff, change: RowChange): void { + const cls = diff.classes.find((candidate) => classMatches(candidate, change)); + if (cls) claim(diff, cls.name, change); + else diff.report.unclassified.push(change); +} + +function childRowsOf(rows: readonly SnapshotRow[]): SnapshotRow[] { + const children: SnapshotRow[] = []; + for (const row of rows) { + for (const key of CONTAINER_FIELDS) { + const value = row[key]; + if (Array.isArray(value)) children.push(...(value as SnapshotRow[])); + } + } + return children; +} + +function hasContainer(row: SnapshotRow): boolean { + return CONTAINER_FIELDS.some((key) => Array.isArray(row[key])); +} + +function pool(map: Map, id: string, row: SnapshotRow): void { + const rows = map.get(id); + if (rows) rows.push(row); + else map.set(id, [row]); +} + +function groupByIdentity(rows: readonly SnapshotRow[]): Map { + const groups = new Map(); + for (const row of rows) pool(groups, rowIdentity(row), row); + return groups; +} + +function diffRow(diff: VariantDiff, b: SnapshotRow, a: SnapshotRow, id: string): number { + const { thread } = diff; + const reshaped = rowShape(a) !== rowShape(b); + if (reshaped) { + classify(diff, { type: "reshaped", thread, id, before: b, after: a }); + } + const fields: string[] = []; + let boundsOnly = true; + for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) { + if ((CONTAINER_FIELDS as readonly string[]).includes(key)) continue; + if (JSON.stringify(a[key]) === JSON.stringify(b[key])) continue; + const aId = str(a.id); + const bId = str(b.id); + if (key === "id" && aId !== undefined && bId !== undefined && ownRowId(aId) === ownRowId(bId)) { + fields.push("id:prefix"); + continue; + } + fields.push(key); + if (!CONTAINER_BOUND_FIELDS.has(key)) boundsOnly = false; + } + fields.sort(); + const nestedChanges = diffRows(diff, childRowsOf([b]), childRowsOf([a])); + const turnId = b.kind === "turn" ? str(b.turnId) : undefined; + if (nestedChanges > 0 && turnId !== undefined) { + diff.shared.turnsWithChildChanges.add(turnId); + } + let own = reshaped ? 1 : 0; + if (fields.length > 0 && !reshaped) { + const explainedByChildren = + nestedChanges > 0 || + (turnId !== undefined && + !hasContainer(b) && + diff.shared.turnsWithChildChanges.has(turnId)); + if (boundsOnly && explainedByChildren) { + claim(diff, CONTAINER_BOUNDS_CLASS, { + type: "changed", + thread, + id, + before: b, + after: a, + fields, + }); + } else { + classify(diff, { type: "changed", thread, id, before: b, after: a, fields }); + own = 1; + } + } + return nestedChanges + own; +} + +function diffRows( + diff: VariantDiff, + before: readonly SnapshotRow[], + after: readonly SnapshotRow[], +): number { + const { thread } = diff; + const beforeById = groupByIdentity(before); + const afterById = groupByIdentity(after); + let changes = 0; + for (const [id, bs] of beforeById) { + const as = afterById.get(id); + if (as === undefined) { + for (const b of bs) pool(diff.removed, id, b); + changes += bs.length; + continue; + } + if (bs.length !== as.length) { + classify(diff, { type: "resegmented", thread, id, before: bs, after: as }); + changes += 1 + diffRows(diff, childRowsOf(bs), childRowsOf(as)); + const turnId = as[0]?.kind === "turn" ? str(as[0].turnId) : undefined; + if (turnId !== undefined) diff.shared.turnsWithChildChanges.add(turnId); + continue; + } + for (let index = 0; index < bs.length; index += 1) { + changes += diffRow(diff, bs[index] as SnapshotRow, as[index] as SnapshotRow, id); + } + } + for (const [id, as] of afterById) { + if (beforeById.has(id)) continue; + for (const a of as) pool(diff.added, id, a); + changes += as.length; + } + return changes; +} + +/** + * Pairs the variant's pooled removals and additions by identity: a pair is + * one "moved" change. The default variant carries no turn children, so a + * row the nested variant showed moving INTO a turn is simply absent there — + * the same move, looked up through the shared per-thread state. + */ +function settleVariantDiff(diff: VariantDiff): void { + const { thread, removed, added, shared } = diff; + for (const [id, removedRows] of removed) { + const addedRows = added.get(id); + if (addedRows) { + added.delete(id); + const pairs = Math.min(removedRows.length, addedRows.length); + for (let index = 0; index < pairs; index += 1) { + const b = removedRows[index] as SnapshotRow; + const a = addedRows[index] as SnapshotRow; + shared.movedRows.set(id, a); + classify(diff, { type: "moved", thread, id, before: b, after: a }); + diffRows(diff, childRowsOf([b]), childRowsOf([a])); + } + for (const b of removedRows.slice(pairs)) { + classify(diff, { type: "removed", thread, id, row: b }); + } + for (const a of addedRows.slice(pairs)) { + classify(diff, { type: "added", thread, id, row: a }); + } + continue; + } + const movedTo = shared.movedRows.get(id); + for (const b of removedRows) { + if (movedTo) { + classify(diff, { type: "moved", thread, id, before: b, after: movedTo }); + } else { + classify(diff, { type: "removed", thread, id, row: b }); + } + } + } + for (const [id, addedRows] of added) { + for (const a of addedRows) classify(diff, { type: "added", thread, id, row: a }); + } +} + +function variantRows( + snapshot: RowSnapshotVariants, + variant: string, +): SnapshotRow[] { + const rows: SnapshotRow[] = []; + for (const page of snapshot.variants?.[variant]?.pages ?? []) { + rows.push(...(page.rows ?? [])); + } + return rows; +} + +/** + * Classifies every change between two snapshots of one thread into + * `report`. Returns the number of changes found (classified or not). + */ +export function classifyRowSnapshotDiff( + thread: string, + before: RowSnapshotVariants, + after: RowSnapshotVariants, + classes: readonly RowDiffClass[], + report: RowDiffReport, +): number { + // The nested variant is walked first so a turn whose children changed + // there explains the bounds-only change of the same turn row in the + // default variant, where turn rows carry no children. + const variants = [ + ...new Set([ + ...Object.keys(before.variants ?? {}), + ...Object.keys(after.variants ?? {}), + ]), + ].sort((x, y) => (x === "nested" ? -1 : y === "nested" ? 1 : 0)); + const shared: SharedThreadState = { + turnsWithChildChanges: new Set(), + movedRows: new Map(), + }; + let changes = 0; + for (const variant of variants) { + const diff: VariantDiff = { + thread: `${thread}@${variant}`, + classes, + report, + removed: new Map(), + added: new Map(), + shared, + }; + changes += diffRows(diff, variantRows(before, variant), variantRows(after, variant)); + settleVariantDiff(diff); + } + return changes; +} + +/** Class names that claimed nothing: stale entries or wrong matchers. */ +export function idleRowDiffClasses( + classes: readonly RowDiffClass[], + report: RowDiffReport, +): string[] { + return [...new Set(classes.filter((cls) => !report.claims.has(cls.name)).map((cls) => cls.name))]; +} + +export function formatRowDiffReport( + classes: readonly RowDiffClass[], + report: RowDiffReport, + options: { examples?: boolean } = {}, +): string { + const lines: string[] = []; + for (const [name, count] of [...report.claims].sort((x, y) => y[1] - x[1])) { + const cls = classes.find((candidate) => candidate.name === name); + lines.push(` ${count.toString().padStart(6)} ${name}${cls ? ` — ${cls.reason}` : ""}`); + const example = report.examples.get(name); + if (options.examples && example) { + lines.push(` e.g. ${JSON.stringify(example).slice(0, 300)}`); + } + } + const idle = idleRowDiffClasses(classes, report); + if (idle.length > 0) { + lines.push(`classes that claimed nothing: ${idle.join(", ")}`); + } + if (report.unclassified.length > 0) { + lines.push(`UNCLASSIFIED: ${report.unclassified.length}`); + const byShape = new Map(); + for (const change of report.unclassified) { + const key = describeRowChange(change); + byShape.set(key, (byShape.get(key) ?? 0) + 1); + } + for (const [key, count] of [...byShape].sort((x, y) => y[1] - x[1]).slice(0, 40)) { + lines.push(` ${count.toString().padStart(6)} ${key}`); + } + } + return lines.join("\n"); +} diff --git a/apps/server/test/provider-corpus/row-snapshots.test.ts b/apps/server/test/provider-corpus/row-snapshots.test.ts index ba92220fa3..cfb92c0e13 100644 --- a/apps/server/test/provider-corpus/row-snapshots.test.ts +++ b/apps/server/test/provider-corpus/row-snapshots.test.ts @@ -35,6 +35,17 @@ import { type JsonValue, type LoadedCorpusThread, } from "./corpus-harness.js"; +import { + classifyRowSnapshotDiff, + createRowDiffReport, + describeRowChange, + formatRowDiffReport, + idleRowDiffClasses, + readRowDiffClasses, + resolveRowDiffClassesPath, + type RowDiffClass, + type RowSnapshotVariants, +} from "./row-diff-classes.js"; const PER_THREAD_TIMEOUT_MS = 5 * 60_000; const PRINTED_DIFF_THREAD_LIMIT = 3; @@ -120,6 +131,14 @@ function formatDiffs(diffs: readonly JsonDiff[], limit: number): string { const available = corpusAvailable(); const mode = resolveSnapshotMode(); const corpusThreads = available ? listCorpusThreads() : []; +/** + * With BB_PROVIDER_CORPUS_ROW_CLASSES set, compare mode matches rows by + * identity and requires every change to fall into a named class from that + * file, instead of allowlisting JSON pointers. A projection change that adds + * or removes rows shifts every later pointer; the class file is the only + * way to prove such a change is exactly what the PR intended. + */ +const rowClassesPath = resolveRowDiffClassesPath(); describe.skipIf(!available)("provider corpus row snapshots", () => { // The describe body still runs at collection time when the suite is skipped, @@ -129,6 +148,9 @@ describe.skipIf(!available)("provider corpus row snapshots", () => { const rowsDir = resolveSnapshotRowsDir(snapshotsDir); const allowlist = available ? readAllowlist(snapshotsDir) : []; const usedAllowlistEntries = new Set(); + const rowClasses: RowDiffClass[] = + available && rowClassesPath !== null ? readRowDiffClasses(rowClassesPath) : []; + const rowClassReport = createRowDiffReport(); let registry: ProviderRegistryService | null = null; const totals = { bytes: 0, @@ -183,6 +205,50 @@ describe.skipIf(!available)("provider corpus row snapshots", () => { const expected = normalizeJson( JSON.parse(fs.readFileSync(filePath, "utf8")), ); + if (rowClassesPath !== null) { + const report = createRowDiffReport(); + const changes = classifyRowSnapshotDiff( + `${provider}/${threadId}`, + expected as RowSnapshotVariants, + built.snapshot as RowSnapshotVariants, + rowClasses, + report, + ); + for (const [name, count] of report.claims) { + rowClassReport.claims.set( + name, + (rowClassReport.claims.get(name) ?? 0) + count, + ); + const example = report.examples.get(name); + if (example && !rowClassReport.examples.has(name)) { + rowClassReport.examples.set(name, example); + } + } + totals.allowedDiffs += changes - report.unclassified.length; + if (report.unclassified.length > 0) { + totals.diffThreads.push(threadId); + rowClassReport.unclassified.push(...report.unclassified); + if (totals.printedDiffThreads < PRINTED_DIFF_THREAD_LIMIT) { + totals.printedDiffThreads += 1; + console.log( + [ + `Row changes for ${threadId} (${provider}) outside every class in ${rowClassesPath}:`, + ...report.unclassified + .slice(0, 20) + .map( + (change) => + ` ${describeRowChange(change)} ${JSON.stringify(change).slice(0, 400)}`, + ), + ].join("\n"), + ); + } + const first = report.unclassified[0]; + throw new Error( + `${threadId} (${provider}) has ${report.unclassified.length} row change(s) no class claims; first: ${first ? describeRowChange(first) : "?"}`, + ); + } + return; + } const diffs = diffJson(expected, built.snapshot); const matched = applyAllowlist( allowlist, @@ -243,6 +309,13 @@ describe.skipIf(!available)("provider corpus row snapshots", () => { wallMs, diffThreads: totals.diffThreads, allowedDiffs: totals.allowedDiffs, + ...(rowClassesPath === null + ? {} + : { + rowClassesFile: rowClassesPath, + rowClasses: Object.fromEntries(rowClassReport.claims), + unclassified: rowClassReport.unclassified.length, + }), }, null, 2, @@ -256,6 +329,21 @@ describe.skipIf(!available)("provider corpus row snapshots", () => { `Row snapshot diffs in ${totals.diffThreads.length} thread(s): ${totals.diffThreads.join(", ")}`, ); } + if (rowClassesPath !== null) { + // stdout, like the summary line: the console is silenced on success + // and the class counts are the run's evidence. + process.stdout.write( + `Row change classes (${rowClassesPath}):\n${formatRowDiffReport(rowClasses, rowClassReport)}\n`, + ); + // A filtered run (`-t thr_…`) leaves most classes legitimately idle. + if (totals.threads === corpusThreads.length) { + expect( + idleRowDiffClasses(rowClasses, rowClassReport), + "every class in the row-classes file must claim at least one change", + ).toEqual([]); + } + return; + } const usedEntries = allowlist.filter((_, index) => usedAllowlistEntries.has(index), ); diff --git a/apps/server/test/services/threads/timeline-head-state.test.ts b/apps/server/test/services/threads/timeline-head-state.test.ts index d2d0c4273b..f9f4ee4064 100644 --- a/apps/server/test/services/threads/timeline-head-state.test.ts +++ b/apps/server/test/services/threads/timeline-head-state.test.ts @@ -125,32 +125,28 @@ function seedThreadWithEarlyHeadState( timeUsedSeconds: 45, }), }); + // The plan snapshot is a grammar v3 planSteps item (the bridge folds + // TodoWrite / update_plan into it); the head-state backfill finds it by + // kind through the plan-steps index, never by a tool name. events.push({ threadId: thread.id, sequence: (sequence += 1), type: "item/completed", scope: turnScope(turnId), providerThreadId, - itemId: "todo-1", - itemKind: "toolCall", + itemId: "plan-1", + itemKind: "planSteps", parentToolCallId: null, data: JSON.stringify({ + providerThreadId, item: { - type: "toolCall", - id: "todo-1", - tool: "TodoWrite", - arguments: { - todos: [ - { - content: "Ship the thing", - status: "in_progress", - activeForm: "Shipping the thing", - }, - { content: "Write the docs", status: "pending" }, - ], - }, + type: "planSteps", + id: "plan-1", + steps: [ + { step: "Shipping the thing", status: "active" }, + { step: "Write the docs", status: "pending" }, + ], status: "completed", - result: "ok", }, }), }); diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index ef00902220..c3e7953604 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -312,11 +312,14 @@ Before stabilization, audit: ## `bb.agents.registerTool({ experimental_statusLabels })` **What it does.** Lets a native plugin tool supply one short label while it is -pending and one after successful completion. BB snapshots the labels into the -tool-call event and renders them in its own timeline; a tool without the field -keeps the ordinary `Running tool …` / `Ran tool …` title. Approval, error, and -interruption states deliberately keep their standard titles so the raw tool -identity and failure state remain clear. +pending and one after successful completion. The server folds the pair into +the tool's resolved presentation (see `experimental_presentation` below) and +the bridge stamps that presentation on every call's `item.open`/`item.close`; +the timeline reads `presentation.label` and nothing else, so a tool without +the field keeps the ordinary `Running tool …` / `Ran tool …` title. Core no +longer copies the labels into the tool-call event as a separate field. +Approval, error, and interruption states deliberately keep their standard +titles so the raw tool identity and failure state remain clear. Each label is capped at 80 characters and rendered as a truncating segment. diff --git a/docs/debugging-and-qa.md b/docs/debugging-and-qa.md index 841b4a64c6..7c8e7c180a 100644 --- a/docs/debugging-and-qa.md +++ b/docs/debugging-and-qa.md @@ -163,6 +163,30 @@ rows goes to a shadow directory: `BB_PROVIDER_CORPUS_SNAPSHOT_DIR=` redirects both `write` and `compare`. Re-mint `snapshots/rows` from `main` after such a PR merges and delete the allowlist file it carried. +A pointer allowlist cannot describe a change that adds or removes rows: every +later sibling shifts and the diff reports the whole turn. For such a change, +carry a row-class file instead +(`apps/server/test/provider-corpus/allowlists/-row-classes.json`) and set +`BB_PROVIDER_CORPUS_ROW_CLASSES=` on the compare run. The gate then +matches rows by identity (`callId`, `itemId`, `interactionId`, turn id, or row +id), buckets every change into the first class whose matcher fits, and fails +on a change no class claims or a class that claims nothing. A class names a +`reason` and one matcher: `added`, `removed`, `moved` (the row left one +nesting level for another), `resegmented` (a turn shows a different number of +visible segments), `reshaped` (`from`/`to` kinds), or `changed` with the +`fields` it may touch; each narrows by `kind`, `workKind`, `role`, and +`nested`. Turn bounds that follow a changed child fall into the built-in +`container-bounds` class. The run prints the count per class and records them +in `rows-last-run.json`. To iterate on the classes without re-projecting the +corpus, mint the branch's rows once into a shadow directory and classify the +two directories offline: + +```bash +pnpm exec tsx scripts/provider-corpus/classify-row-diff.ts \ + ~/.bb/provider-corpus/snapshots/rows ~/.bb/provider-corpus/snapshots/rows. \ + --classes apps/server/test/provider-corpus/allowlists/-row-classes.json --verbose +``` + Perf compare mode passes when each thread's normalized cost is within 10% of the baseline (or within 5 ms of intrinsic cost for the small latest-page builds) and the median event size is within 15%. The normalized cost is the diff --git a/packages/agent-runtime/src/pi/delta-translation.test.ts b/packages/agent-runtime/src/pi/delta-translation.test.ts index 837b4afaae..1053f1a2f1 100644 --- a/packages/agent-runtime/src/pi/delta-translation.test.ts +++ b/packages/agent-runtime/src/pi/delta-translation.test.ts @@ -946,7 +946,7 @@ describe("pi delta translation equivalence", () => { expect(started.item.changes[0]?.diff).toContain("+++ b/src/app.ts"); }); - it("tool_execution_start with read args preserves structured tool arguments", () => { + it("maps Pi's read tool to a fileRead item with its presentation (grammar v3)", () => { const harness = createHarness(); harness.translate(loadFixture("agent-start.json")); @@ -957,19 +957,87 @@ describe("pi delta translation equivalence", () => { args: { path: "src/app.ts", offset: 1, limit: 20 }, } as AgentSessionEvent); + // Pi's tool names live in its translation, not in a core table: the + // read becomes the exploration kind every client renders as "Read file". expect(events).toContainEqual( expect.objectContaining({ type: "item/started", item: expect.objectContaining({ - type: "toolCall", - tool: "read", + type: "fileRead", + path: "src/app.ts", status: "pending", - arguments: expect.objectContaining({ - path: "src/app.ts", - offset: 1, - limit: 20, + presentation: expect.objectContaining({ + label: { pending: "Reading file", completed: "Read file" }, + icon: { glyph: "FileText" }, + title: "app.ts", + }), + }), + }), + ); + }); + + it.each([ + { + toolName: "grep", + args: { pattern: "TODO", path: "src" }, + item: { type: "search", mode: "content", query: "TODO", path: "src" }, + label: "Searched files", + }, + { + toolName: "find", + args: { pattern: "**/*.ts" }, + item: { type: "search", mode: "path", query: "**/*.ts" }, + label: "Found files", + }, + { + toolName: "ls", + args: { path: "src" }, + item: { type: "search", mode: "list", query: "", path: "src" }, + label: "Listed files", + }, + ])( + "maps Pi's $toolName tool to a search item", + ({ toolName, args, item, label }) => { + const harness = createHarness(); + harness.translate(loadFixture("agent-start.json")); + const events = harness.translate({ + type: "tool_execution_start", + toolCallId: `tool-${toolName}-1`, + toolName, + args, + } as AgentSessionEvent); + expect(events).toContainEqual( + expect.objectContaining({ + type: "item/started", + item: expect.objectContaining({ + ...item, + presentation: expect.objectContaining({ + label: expect.objectContaining({ completed: label }), + }), }), }), + ); + }, + ); + + it("keeps an unknown Pi tool as a generic tool item with its arguments", () => { + const harness = createHarness(); + harness.translate(loadFixture("agent-start.json")); + const events = harness.translate({ + type: "tool_execution_start", + toolCallId: "tool-think-1", + toolName: "think", + args: { depth: 3 }, + } as AgentSessionEvent); + expect(events).toContainEqual( + expect.objectContaining({ + type: "item/started", + item: expect.objectContaining({ + type: "toolCall", + tool: "think", + status: "pending", + arguments: expect.objectContaining({ depth: 3 }), + }), }), ); }); @@ -1052,18 +1120,17 @@ describe("pi delta translation equivalence", () => { result: "file contents", } as AgentSessionEvent); + // The close settles the opened fileRead item (the presentation echoes + // from the open); a read's result text is not part of the row. expect(events).toContainEqual( expect.objectContaining({ type: "item/completed", item: expect.objectContaining({ - type: "toolCall", - tool: "read", + type: "fileRead", + path: "src/app.ts", status: "completed", - result: "file contents", - arguments: expect.objectContaining({ - path: "src/app.ts", - offset: 1, - limit: 20, + presentation: expect.objectContaining({ + label: { pending: "Reading file", completed: "Read file" }, }), }), }), diff --git a/packages/agent-runtime/src/pi/delta-translation.ts b/packages/agent-runtime/src/pi/delta-translation.ts index 7a8988c11d..edb66c3484 100644 --- a/packages/agent-runtime/src/pi/delta-translation.ts +++ b/packages/agent-runtime/src/pi/delta-translation.ts @@ -25,6 +25,7 @@ import { providerRawEventSchema, toPositiveNumber } from "@bb/domain"; import type { DeltaItemShape, DeltaNoTurnFallback, + DeltaPresentation, ThreadDelta, } from "@bb/provider-bridge-protocol"; import { @@ -250,6 +251,104 @@ type PiToolExecutionUpdateEvent = z.infer< const PI_EMPTY_BASH_OUTPUT_PLACEHOLDERS = ["(no output)"] as const; const PI_COMMAND_TOOL_NAMES = new Set(["bash"]); const PI_FILE_CHANGE_TOOL_NAMES = new Set(["edit", "write"]); +// Pi's exploration built-ins, mapped to the grammar v3 exploration kinds so +// the timeline renders them as reads and searches without any tool-name +// table in core. This module is the one place Pi's tool names live. +const PI_FILE_READ_TOOL_NAME = "read"; +const PI_CONTENT_SEARCH_TOOL_NAME = "grep"; +const PI_PATH_SEARCH_TOOL_NAME = "find"; +const PI_LIST_TOOL_NAME = "ls"; + +const piExplorationArgsSchema = z + .object({ + path: z.string().optional(), + pattern: z.string().optional(), + query: z.string().optional(), + }) + .passthrough(); + +function fileNameOf(path: string): string { + const segments = path.split("/").filter((segment) => segment.length > 0); + return segments[segments.length - 1] ?? path; +} + +function presentationTitle(text: string | undefined): { title?: string } { + const firstLine = text?.trim().split("\n", 1)[0]?.trim() ?? ""; + return firstLine.length === 0 ? {} : { title: firstLine.slice(0, 160) }; +} + +/** + * The presentation for a Pi exploration tool (grammar v3): the label pair, + * a host glyph, and the path or pattern as the headline. + */ +function classifyPiExplorationToolUse( + toolName: string, + args: unknown, +): { shape: DeltaItemShape; presentation: DeltaPresentation } | null { + const parsed = piExplorationArgsSchema.safeParse(args); + const fields = parsed.success ? parsed.data : {}; + const path = toOptionalString(fields.path); + const pattern = + toOptionalString(fields.pattern) ?? toOptionalString(fields.query); + switch (toolName) { + case PI_FILE_READ_TOOL_NAME: + if (!path) return null; + return { + shape: { type: "fileRead", path }, + presentation: { + label: { pending: "Reading file", completed: "Read file" }, + icon: { glyph: "FileText" }, + ...presentationTitle(fileNameOf(path)), + }, + }; + case PI_CONTENT_SEARCH_TOOL_NAME: + if (!pattern) return null; + return { + shape: { + type: "search", + mode: "content", + query: pattern, + ...(path === undefined ? {} : { path }), + }, + presentation: { + label: { pending: "Searching files", completed: "Searched files" }, + icon: { glyph: "Search" }, + ...presentationTitle(pattern), + }, + }; + case PI_PATH_SEARCH_TOOL_NAME: + if (!pattern) return null; + return { + shape: { + type: "search", + mode: "path", + query: pattern, + ...(path === undefined ? {} : { path }), + }, + presentation: { + label: { pending: "Finding files", completed: "Found files" }, + icon: { glyph: "FolderOpen" }, + ...presentationTitle(pattern), + }, + }; + case PI_LIST_TOOL_NAME: + return { + shape: { + type: "search", + mode: "list", + query: "", + ...(path === undefined ? {} : { path }), + }, + presentation: { + label: { pending: "Listing files", completed: "Listed files" }, + icon: { glyph: "FolderOpen" }, + ...presentationTitle(path), + }, + }; + default: + return null; + } +} const ASSISTANT_STREAM_KEY = "assistant"; @@ -887,10 +986,13 @@ export function createPiDeltaTranslator( if (!piEvent.success) { return unexpectedSdkEventDeltas(event, context); } - const shape = classifyPiToolUse( + const exploration = classifyPiExplorationToolUse( piEvent.data.toolName, piEvent.data.args, ); + const shape = + exploration?.shape ?? + classifyPiToolUse(piEvent.data.toolName, piEvent.data.args); rememberStartedToolShape( toolShapeKey(context, piEvent.data.toolCallId), shape, @@ -903,6 +1005,9 @@ export function createPiDeltaTranslator( ...parentRefField, }, item: shape, + ...(exploration === null + ? {} + : { presentation: exploration.presentation }), noTurnFallback: noTurnFallbackFor(piEvent.data, context), }, ]; diff --git a/packages/db/drizzle/0107_kind_based_indexes.sql b/packages/db/drizzle/0107_kind_based_indexes.sql new file mode 100644 index 0000000000..58fb12a6c8 --- /dev/null +++ b/packages/db/drizzle/0107_kind_based_indexes.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS `events_tool_call_parent_lookup_idx`;--> statement-breakpoint +DROP INDEX IF EXISTS `events_todo_tool_call_thread_tool_sequence_idx`;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `events_delegating_item_lookup_idx` ON `events` (`thread_id`,`item_id`,`sequence`,`item_kind`) WHERE "events"."item_kind" IN ('toolCall', 'delegation');--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `events_plan_steps_thread_sequence_idx` ON `events` (`thread_id`,`sequence`) WHERE ("events"."item_kind" = 'planSteps' AND "events"."type" = 'item/completed') OR "events"."type" = 'turn/plan/updated';--> statement-breakpoint +ALTER TABLE `events` DROP COLUMN `tool_name`; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0107_snapshot.json b/packages/db/drizzle/meta/0107_snapshot.json new file mode 100644 index 0000000000..38b2159c06 --- /dev/null +++ b/packages/db/drizzle/meta/0107_snapshot.json @@ -0,0 +1,3722 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "1bb6a1f8-2148-49e9-8074-1e26f0978288", + "prevId": "382a9d2b-7e1a-4b0c-97df-7b6321009b60", + "tables": { + "app_settings": { + "name": "app_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "caffeinate": { + "name": "caffeinate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_keyboard_hints": { + "name": "show_keyboard_hints", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "steer_active_thread_on_enter": { + "name": "steer_active_thread_on_enter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_unhandled_provider_events": { + "name": "show_unhandled_provider_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "codex_memory_enabled": { + "name": "codex_memory_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "claude_code_memory_enabled": { + "name": "claude_code_memory_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "codex_subagents_disabled": { + "name": "codex_subagents_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "claude_code_subagents_disabled": { + "name": "claude_code_subagents_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "claude_code_workflows_disabled": { + "name": "claude_code_workflows_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "keybinding_overrides": { + "name": "keybinding_overrides", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_settings_values": { + "name": "app_settings_values", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_theme": { + "name": "app_theme", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "favicon_color": { + "name": "favicon_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referenceId": { + "name": "referenceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refillInterval": { + "name": "refillInterval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refillAmount": { + "name": "refillAmount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastRefillAt": { + "name": "lastRefillAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitEnabled": { + "name": "rateLimitEnabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitTimeWindow": { + "name": "rateLimitTimeWindow", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitMax": { + "name": "rateLimitMax", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestCount": { + "name": "requestCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastRequest": { + "name": "lastRequest", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "configId": { + "name": "configId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_unique": { + "name": "apikey_key_unique", + "columns": [ + "key" + ], + "isUnique": true + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": [ + "referenceId" + ], + "isUnique": false + }, + "apikey_config_id_idx": { + "name": "apikey_config_id_idx", + "columns": [ + "configId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "apikey_referenceId_user_id_fk": { + "name": "apikey_referenceId_user_id_fk", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": [ + "referenceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environments": { + "name": "environments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "managed": { + "name": "managed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_git_repo": { + "name": "is_git_repo", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_worktree": { + "name": "is_worktree", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merge_base_branch": { + "name": "merge_base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "destroy_attempt_id": { + "name": "destroy_attempt_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retire_requested_at": { + "name": "retire_requested_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_provision_type": { + "name": "workspace_provision_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'provisioning'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "environments_project_host_path_idx": { + "name": "environments_project_host_path_idx", + "columns": [ + "project_id", + "host_id", + "path" + ], + "isUnique": true + }, + "environments_host_path_lookup_idx": { + "name": "environments_host_path_lookup_idx", + "columns": [ + "host_id", + "path" + ], + "isUnique": false + }, + "environments_project_idx": { + "name": "environments_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "environments_status_idx": { + "name": "environments_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "environments_project_id_projects_id_fk": { + "name": "environments_project_id_projects_id_fk", + "tableFrom": "environments", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environments_host_id_hosts_id_fk": { + "name": "environments_host_id_hosts_id_fk", + "tableFrom": "environments", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_tool_call_id": { + "name": "parent_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "events_thread_sequence_idx": { + "name": "events_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": true + }, + "events_delegating_item_lookup_idx": { + "name": "events_delegating_item_lookup_idx", + "columns": [ + "thread_id", + "item_id", + "sequence", + "item_kind" + ], + "isUnique": false, + "where": "\"events\".\"item_kind\" IN ('toolCall', 'delegation')" + }, + "events_plan_steps_thread_sequence_idx": { + "name": "events_plan_steps_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": false, + "where": "(\"events\".\"item_kind\" = 'planSteps' AND \"events\".\"type\" = 'item/completed') OR \"events\".\"type\" = 'turn/plan/updated'" + }, + "events_parent_tool_call_thread_parent_sequence_idx": { + "name": "events_parent_tool_call_thread_parent_sequence_idx", + "columns": [ + "thread_id", + "parent_tool_call_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"parent_tool_call_id\" IS NOT NULL" + }, + "events_thread_type_item_kind_sequence_idx": { + "name": "events_thread_type_item_kind_sequence_idx", + "columns": [ + "thread_id", + "type", + "item_kind", + "sequence" + ], + "isUnique": false + }, + "events_background_task_thread_type_item_sequence_idx": { + "name": "events_background_task_thread_type_item_sequence_idx", + "columns": [ + "thread_id", + "type", + "item_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"item_kind\" = 'backgroundTask'" + }, + "events_thread_type_sequence_idx": { + "name": "events_thread_type_sequence_idx", + "columns": [ + "thread_id", + "type", + "sequence" + ], + "isUnique": false + }, + "events_thread_turn_type_item_sequence_idx": { + "name": "events_thread_turn_type_item_sequence_idx", + "columns": [ + "thread_id", + "turn_id", + "type", + "item_id", + "sequence" + ], + "isUnique": false + }, + "events_item_lifecycle_thread_item_sequence_idx": { + "name": "events_item_lifecycle_thread_item_sequence_idx", + "columns": [ + "thread_id", + "item_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"type\" IN ('item/started', 'item/completed', 'item/backgroundTask/completed')" + }, + "events_environment_idx": { + "name": "events_environment_idx", + "columns": [ + "environment_id" + ], + "isUnique": false + }, + "events_completed_item_truncation_idx": { + "name": "events_completed_item_truncation_idx", + "columns": [ + "item_kind", + "created_at", + "id" + ], + "isUnique": false, + "where": "\"events\".\"type\" = 'item/completed'" + }, + "events_thread_state_thread_sequence_idx": { + "name": "events_thread_state_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"type\" IN ('thread/goal/updated', 'thread/goal/cleared', 'thread/extensionState/updated')" + } + }, + "foreignKeys": { + "events_thread_id_threads_id_fk": { + "name": "events_thread_id_threads_id_fk", + "tableFrom": "events", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "events_environment_id_environments_id_fk": { + "name": "events_environment_id_environments_id_fk", + "tableFrom": "events", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "events_scope_shape_check": { + "name": "events_scope_shape_check", + "value": "(\n (\"events\".\"scope_kind\" = 'turn' AND \"events\".\"turn_id\" IS NOT NULL)\n OR\n (\"events\".\"scope_kind\" = 'thread' AND \"events\".\"turn_id\" IS NULL)\n )" + } + } + }, + "host_daemon_sessions": { + "name": "host_daemon_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_type": { + "name": "host_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_dir": { + "name": "data_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "heartbeat_interval_ms": { + "name": "heartbeat_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lease_timeout_ms": { + "name": "lease_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "host_daemon_sessions_host_status_idx": { + "name": "host_daemon_sessions_host_status_idx", + "columns": [ + "host_id", + "status" + ], + "isUnique": false + }, + "host_daemon_sessions_host_latest_idx": { + "name": "host_daemon_sessions_host_latest_idx", + "columns": [ + "host_id", + "updated_at", + "created_at", + "id" + ], + "isUnique": false + }, + "host_daemon_sessions_closed_prune_idx": { + "name": "host_daemon_sessions_closed_prune_idx", + "columns": [ + "status", + "closed_at", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_daemon_sessions_host_id_hosts_id_fk": { + "name": "host_daemon_sessions_host_id_hosts_id_fk", + "tableFrom": "host_daemon_sessions", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hosts": { + "name": "hosts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connect_machine_id": { + "name": "connect_machine_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_permission_mode": { + "name": "max_permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'full'" + }, + "destroyed_at": { + "name": "destroyed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_rejected_protocol_version": { + "name": "last_rejected_protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "hosts_last_seen_idx": { + "name": "hosts_last_seen_idx", + "columns": [ + "last_seen_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugins": { + "name": "plugins", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'direct'" + }, + "catalog_entry_id": { + "name": "catalog_entry_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "catalog_marketplace_name": { + "name": "catalog_marketplace_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'path'" + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_builtin_name": { + "name": "source_builtin_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_package": { + "name": "source_npm_package", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_registry": { + "name": "source_npm_registry", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_requested_spec": { + "name": "source_npm_requested_spec", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_spec_kind": { + "name": "source_npm_spec_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_url": { + "name": "source_git_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_subdirectory": { + "name": "source_git_subdirectory", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_requested_ref": { + "name": "source_git_requested_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_ref_kind": { + "name": "source_git_ref_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_range": { + "name": "source_git_range", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_tag_prefix": { + "name": "source_git_tag_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_resolved_tag": { + "name": "source_git_resolved_tag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "npm_resolved_version": { + "name": "npm_resolved_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "npm_integrity": { + "name": "npm_integrity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_resolved_commit": { + "name": "git_resolved_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_update_check_at": { + "name": "last_update_check_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "available_compatible_version": { + "name": "available_compatible_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "newest_incompatible_version": { + "name": "newest_incompatible_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "update_status_detail": { + "name": "update_status_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_version": { + "name": "last_failure_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_at": { + "name": "last_failure_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_detail": { + "name": "last_failure_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_artifact_id": { + "name": "active_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "normalization_version": { + "name": "normalization_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "root_dir": { + "name": "root_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "plugins_active_artifact_id_plugin_artifacts_id_fk": { + "name": "plugins_active_artifact_id_plugin_artifacts_id_fk", + "tableFrom": "plugins", + "tableTo": "plugin_artifacts", + "columnsFrom": [ + "active_artifact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_scan_cursors": { + "name": "maintenance_scan_cursors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_path": { + "name": "output_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_created_at": { + "name": "last_created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_event_id": { + "name": "last_event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "maintenance_scan_cursors_path_idx": { + "name": "maintenance_scan_cursors_path_idx", + "columns": [ + "policy", + "version", + "item_kind", + "output_path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pending_interactions": { + "name": "pending_interactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'provider'" + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "renderer_id": { + "name": "renderer_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pending_interactions_provider_request_idx": { + "name": "pending_interactions_provider_request_idx", + "columns": [ + "provider_id", + "provider_thread_id", + "provider_request_id" + ], + "isUnique": true + }, + "pending_interactions_thread_created_idx": { + "name": "pending_interactions_thread_created_idx", + "columns": [ + "thread_id", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_thread_status_created_idx": { + "name": "pending_interactions_thread_status_created_idx", + "columns": [ + "thread_id", + "status", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_status_created_idx": { + "name": "pending_interactions_status_created_idx", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_plugin_status_created_idx": { + "name": "pending_interactions_plugin_status_created_idx", + "columns": [ + "plugin_id", + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "pending_interactions_thread_id_threads_id_fk": { + "name": "pending_interactions_thread_id_threads_id_fk", + "tableFrom": "pending_interactions", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_artifacts": { + "name": "plugin_artifacts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "npm_resolved_version": { + "name": "npm_resolved_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_resolved_commit": { + "name": "git_resolved_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_checkout_root": { + "name": "git_checkout_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integrity": { + "name": "integrity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "validation_result": { + "name": "validation_result", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validated_at": { + "name": "validated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "plugin_artifacts_plugin_idx": { + "name": "plugin_artifacts_plugin_idx", + "columns": [ + "plugin_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_kv": { + "name": "plugin_kv", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_kv_plugin_id_key_pk": { + "columns": [ + "plugin_id", + "key" + ], + "name": "plugin_kv_plugin_id_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_marketplace_icons": { + "name": "plugin_marketplace_icons", + "columns": { + "marketplace_name": { + "name": "marketplace_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entry_id": { + "name": "entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_marketplace_icons_marketplace_name_entry_id_pk": { + "columns": [ + "marketplace_name", + "entry_id" + ], + "name": "plugin_marketplace_icons_marketplace_name_entry_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_marketplaces": { + "name": "plugin_marketplaces", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'https'" + }, + "manifest_url": { + "name": "manifest_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_git_ref": { + "name": "source_git_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_commit": { + "name": "source_git_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_modified": { + "name": "last_modified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_successful_refresh_at": { + "name": "last_successful_refresh_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_attempted_refresh_at": { + "name": "last_attempted_refresh_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_schedules": { + "name": "plugin_schedules", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_schedules_plugin_id_name_pk": { + "columns": [ + "plugin_id", + "name" + ], + "name": "plugin_schedules_plugin_id_name_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_settings": { + "name": "plugin_settings", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_settings_plugin_id_key_pk": { + "columns": [ + "plugin_id", + "key" + ], + "name": "plugin_settings_plugin_id_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_state_snapshots": { + "name": "plugin_state_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_artifact_id": { + "name": "from_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "to_artifact_id": { + "name": "to_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_path": { + "name": "snapshot_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "database_path": { + "name": "database_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state_path": { + "name": "state_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secrets_path": { + "name": "secrets_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registration_path": { + "name": "registration_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rollback_candidate_version": { + "name": "rollback_candidate_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_source_fingerprint": { + "name": "rollback_source_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_bb_version": { + "name": "rollback_bb_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_sdk_version": { + "name": "rollback_sdk_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_detail": { + "name": "rollback_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retained_until": { + "name": "retained_until", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "plugin_state_snapshots_plugin_idx": { + "name": "plugin_state_snapshots_plugin_idx", + "columns": [ + "plugin_id" + ], + "isUnique": false + }, + "plugin_state_snapshots_retention_idx": { + "name": "plugin_state_snapshots_retention_idx", + "columns": [ + "retained_until" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_execution_defaults": { + "name": "project_execution_defaults", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_tier": { + "name": "service_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_execution_defaults_project_idx": { + "name": "project_execution_defaults_project_idx", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_execution_defaults_project_id_projects_id_fk": { + "name": "project_execution_defaults_project_id_projects_id_fk", + "tableFrom": "project_execution_defaults", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_sources": { + "name": "project_sources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_sources_project_idx": { + "name": "project_sources_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "project_sources_host_idx": { + "name": "project_sources_host_idx", + "columns": [ + "host_id" + ], + "isUnique": false + }, + "project_sources_project_host_idx": { + "name": "project_sources_project_host_idx", + "columns": [ + "project_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_sources_project_id_projects_id_fk": { + "name": "project_sources_project_id_projects_id_fk", + "tableFrom": "project_sources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_sources_host_id_hosts_id_fk": { + "name": "project_sources_host_id_hosts_id_fk", + "tableFrom": "project_sources", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_sources_shape_check": { + "name": "project_sources_shape_check", + "value": "(\n \"project_sources\".\"type\" = 'local_path' AND \"project_sources\".\"host_id\" IS NOT NULL AND \"project_sources\".\"path\" IS NOT NULL\n )" + } + } + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'standard'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_remote_url": { + "name": "git_remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'V'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_updated_idx": { + "name": "projects_updated_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "projects_deleted_idx": { + "name": "projects_deleted_idx", + "columns": [ + "deleted_at" + ], + "isUnique": false + }, + "projects_sort_idx": { + "name": "projects_sort_idx", + "columns": [ + "sort_key", + "id" + ], + "isUnique": false + }, + "projects_personal_singleton_idx": { + "name": "projects_personal_singleton_idx", + "columns": [ + "kind" + ], + "isUnique": true, + "where": "\"projects\".\"kind\" = 'personal'" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "prompt_history_entries": { + "name": "prompt_history_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "prompt_history_entries_thread_request_idx": { + "name": "prompt_history_entries_thread_request_idx", + "columns": [ + "thread_id", + "request_sequence" + ], + "isUnique": true + }, + "prompt_history_entries_project_scope_created_idx": { + "name": "prompt_history_entries_project_scope_created_idx", + "columns": [ + "project_id", + "scope", + "created_at", + "request_sequence", + "id" + ], + "isUnique": false + }, + "prompt_history_entries_thread_scope_created_idx": { + "name": "prompt_history_entries_thread_scope_created_idx", + "columns": [ + "thread_id", + "scope", + "created_at", + "request_sequence", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "prompt_history_entries_project_id_projects_id_fk": { + "name": "prompt_history_entries_project_id_projects_id_fk", + "tableFrom": "prompt_history_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "prompt_history_entries_thread_id_threads_id_fk": { + "name": "prompt_history_entries_thread_id_threads_id_fk", + "tableFrom": "prompt_history_entries", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "queued_thread_messages": { + "name": "queued_thread_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_thread_id": { + "name": "sender_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_tier": { + "name": "service_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_with_next": { + "name": "group_with_next", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "queued_thread_messages_thread_created_idx": { + "name": "queued_thread_messages_thread_created_idx", + "columns": [ + "thread_id", + "created_at", + "id" + ], + "isUnique": false + }, + "queued_thread_messages_thread_sort_idx": { + "name": "queued_thread_messages_thread_sort_idx", + "columns": [ + "thread_id", + "sort_key", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "queued_thread_messages_thread_id_threads_id_fk": { + "name": "queued_thread_messages_thread_id_threads_id_fk", + "tableFrom": "queued_thread_messages", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_experiments": { + "name": "system_experiments", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_sessions": { + "name": "terminal_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "daemon_session_id": { + "name": "daemon_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initial_cwd": { + "name": "initial_cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cols": { + "name": "cols", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rows": { + "name": "rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_user_input_at": { + "name": "last_user_input_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "terminal_sessions_thread_status_updated_idx": { + "name": "terminal_sessions_thread_status_updated_idx", + "columns": [ + "thread_id", + "status", + "updated_at" + ], + "isUnique": false + }, + "terminal_sessions_environment_status_idx": { + "name": "terminal_sessions_environment_status_idx", + "columns": [ + "environment_id", + "status" + ], + "isUnique": false + }, + "terminal_sessions_host_status_idx": { + "name": "terminal_sessions_host_status_idx", + "columns": [ + "host_id", + "status" + ], + "isUnique": false + }, + "terminal_sessions_daemon_session_idx": { + "name": "terminal_sessions_daemon_session_idx", + "columns": [ + "daemon_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_sessions_thread_id_threads_id_fk": { + "name": "terminal_sessions_thread_id_threads_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_environment_id_environments_id_fk": { + "name": "terminal_sessions_environment_id_environments_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_host_id_hosts_id_fk": { + "name": "terminal_sessions_host_id_hosts_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk": { + "name": "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "host_daemon_sessions", + "columnsFrom": [ + "daemon_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_dynamic_context_file_states": { + "name": "thread_dynamic_context_file_states", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_key": { + "name": "file_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_status": { + "name": "content_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shown_at": { + "name": "shown_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_dynamic_context_file_states_thread_file_idx": { + "name": "thread_dynamic_context_file_states_thread_file_idx", + "columns": [ + "thread_id", + "file_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "thread_dynamic_context_file_states_thread_id_threads_id_fk": { + "name": "thread_dynamic_context_file_states_thread_id_threads_id_fk", + "tableFrom": "thread_dynamic_context_file_states", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_search_segments": { + "name": "thread_search_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_seq": { + "name": "source_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_search_segments_source_idx": { + "name": "thread_search_segments_source_idx", + "columns": [ + "thread_id", + "source_kind", + "source_key" + ], + "isUnique": true + }, + "thread_search_segments_thread_source_seq_idx": { + "name": "thread_search_segments_thread_source_seq_idx", + "columns": [ + "thread_id", + "source_seq" + ], + "isUnique": false + } + }, + "foreignKeys": { + "thread_search_segments_thread_id_threads_id_fk": { + "name": "thread_search_segments_thread_id_threads_id_fk", + "tableFrom": "thread_search_segments", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_sections": { + "name": "thread_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_sections_name_idx": { + "name": "thread_sections_name_idx", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_tabs": { + "name": "thread_tabs", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tabs_json": { + "name": "tabs_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_tabs_thread_id_threads_id_fk": { + "name": "thread_tabs_thread_id_threads_id_fk", + "tableFrom": "thread_tabs", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "threads": { + "name": "threads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_override": { + "name": "model_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_level_override": { + "name": "reasoning_level_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title_fallback": { + "name": "title_fallback", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'starting'" + }, + "parent_thread_id": { + "name": "parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_thread_id": { + "name": "source_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_plugin_id": { + "name": "origin_plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'visible'" + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_sort_key": { + "name": "pin_sort_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latest_attention_at": { + "name": "latest_attention_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "threads_project_updated_idx": { + "name": "threads_project_updated_idx", + "columns": [ + "project_id", + "updated_at" + ], + "isUnique": false + }, + "threads_project_archived_deleted_idx": { + "name": "threads_project_archived_deleted_idx", + "columns": [ + "project_id", + "archived_at", + "deleted_at", + "id" + ], + "isUnique": false + }, + "threads_pin_sort_idx": { + "name": "threads_pin_sort_idx", + "columns": [ + "archived_at", + "deleted_at", + "pin_sort_key", + "id" + ], + "isUnique": false, + "where": "\"threads\".\"pinned_at\" IS NOT NULL" + }, + "threads_environment_idx": { + "name": "threads_environment_idx", + "columns": [ + "environment_id" + ], + "isUnique": false + }, + "threads_parent_idx": { + "name": "threads_parent_idx", + "columns": [ + "parent_thread_id" + ], + "isUnique": false + }, + "threads_source_origin_idx": { + "name": "threads_source_origin_idx", + "columns": [ + "source_thread_id", + "origin_kind" + ], + "isUnique": false + }, + "threads_origin_plugin_archived_idx": { + "name": "threads_origin_plugin_archived_idx", + "columns": [ + "origin_plugin_id", + "archived_at" + ], + "isUnique": false + }, + "threads_section_archived_deleted_idx": { + "name": "threads_section_archived_deleted_idx", + "columns": [ + "section_id", + "archived_at", + "deleted_at", + "id" + ], + "isUnique": false + }, + "threads_archived_status_idx": { + "name": "threads_archived_status_idx", + "columns": [ + "archived_at", + "status" + ], + "isUnique": false + }, + "threads_environment_archived_deleted_idx": { + "name": "threads_environment_archived_deleted_idx", + "columns": [ + "environment_id", + "archived_at", + "deleted_at" + ], + "isUnique": false + }, + "threads_active_maintenance_idx": { + "name": "threads_active_maintenance_idx", + "columns": [ + "status" + ], + "isUnique": false, + "where": "\"threads\".\"deleted_at\" IS NULL" + } + }, + "foreignKeys": { + "threads_project_id_projects_id_fk": { + "name": "threads_project_id_projects_id_fk", + "tableFrom": "threads", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "threads_environment_id_environments_id_fk": { + "name": "threads_environment_id_environments_id_fk", + "tableFrom": "threads", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_section_id_thread_sections_id_fk": { + "name": "threads_section_id_thread_sections_id_fk", + "tableFrom": "threads", + "tableTo": "thread_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_parent_thread_id_threads_id_fk": { + "name": "threads_parent_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "parent_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_source_thread_id_threads_id_fk": { + "name": "threads_source_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "source_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index e2b047dd29..9019d0e750 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -750,6 +750,13 @@ "when": 1787305850786, "tag": "0106_thread_state_index", "breakpoints": true + }, + { + "idx": 107, + "version": "6", + "when": 1787331095369, + "tag": "0107_kind_based_indexes", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/data/events.ts b/packages/db/src/data/events.ts index 202417be1e..24abb9e59a 100644 --- a/packages/db/src/data/events.ts +++ b/packages/db/src/data/events.ts @@ -1536,12 +1536,9 @@ export function getStoredEventRowsByParentToolCallIdsDataBytes( /** * Lifecycle rows of the items that parent other events: tool calls and - * grammar v3 `delegation` items. Two queries rather than one `IN` on the - * kind: the tool-call branch keeps the partial - * `events_tool_call_parent_lookup_idx` (SQLite cannot prove an `IN` implies - * its `item_kind = 'toolCall'` predicate), and the delegation branch walks - * the thread/type/item-kind index over the handful of delegation rows a - * thread has. + * grammar v3 `delegation` items, served by the partial + * `events_delegating_item_lookup_idx`. The kind predicate is spelled + * literally so SQLite can match it to the index's WHERE clause. */ export function listStoredDelegatingItemRowsByItemIds( db: DbConnection, @@ -1554,37 +1551,19 @@ export function listStoredDelegatingItemRowsByItemIds( return []; } - const fields = storedEventRowFieldsWithInlineOutputLimit( - args.maxInlineOutputChars, - ); - const lifecycleTypes = ["item/started", "item/completed"] as const; - const toolCallRows = db - .select(fields) - .from(events) - .where( - and( - eq(events.threadId, args.threadId), - inArray(events.itemId, itemIds), - eq(events.itemKind, "toolCall"), - inArray(events.type, [...lifecycleTypes]), - ), - ) - .all(); - const delegationRows = db - .select(fields) + return db + .select(storedEventRowFieldsWithInlineOutputLimit(args.maxInlineOutputChars)) .from(events) .where( and( eq(events.threadId, args.threadId), - inArray(events.type, [...lifecycleTypes]), - eq(events.itemKind, "delegation"), inArray(events.itemId, itemIds), + sql`${events.itemKind} IN ('toolCall', 'delegation')`, + inArray(events.type, ["item/started", "item/completed"]), ), ) + .orderBy(events.sequence) .all(); - return [...toolCallRows, ...delegationRows].sort( - (left, right) => left.sequence - right.sequence, - ); } /** Whether the thread still has an event at exactly this sequence. */ @@ -2044,67 +2023,36 @@ export interface ListTodoSnapshotEventRowsForThreadArgs { } /** - * Tool-call rows that can carry the pending-todo snapshot, oldest first. + * The newest plan snapshot row of a thread: a `planSteps` item (grammar v3) + * or a persisted codex `turn/plan/updated` notification, which decodes into + * the same item at read time. Each snapshot is complete and the newest wins, + * so one row is all the todo banner needs. * - * The snapshot is not a single row: `TodoWrite` carries a complete list, but - * the Claude task tools carry deltas that only resolve by replaying every task - * row in order. So this returns all of them rather than just the newest. - * - * Needed because the todo banner is extracted from whatever events the timeline - * window happens to contain. An event-budgeted window can start after the turn - * that wrote the todos, which silently drops the banner mid-session — the same - * failure mode `listLatestOpenBackgroundTaskStateRowsForThread` already - * prevents for background tasks. Bounded in practice (tens of rows per thread, - * not thousands) and served by the thread/type/item-kind index. + * Needed because the banner is extracted from whatever events the timeline + * window happens to contain. An event-budgeted window can start after the + * turn that wrote the plan, which silently drops the banner mid-session — + * the same failure mode `listLatestOpenBackgroundTaskStateRowsForThread` + * already prevents for background tasks. Served by the partial + * `events_plan_steps_thread_sequence_idx`; the predicate is spelled + * literally so SQLite can match it to the index's WHERE clause. */ export function listTodoSnapshotEventRowsForThread( db: DbConnection, args: ListTodoSnapshotEventRowsForThreadArgs, ): StoredEventRow[] { - const legacyRows = db - .select(storedEventRowFields) - .from(events) - .where( - and( - eq(events.threadId, args.threadId), - // Keep the partial-index predicates literal. SQLite cannot prove that - // bound parameters imply the index WHERE clause at prepare time. - sql`${events.type} IN ('item/started', 'item/completed')`, - sql`${events.itemKind} = 'toolCall'`, - inArray(events.toolName, [ - "TodoWrite", - "TaskCreate", - "TaskUpdate", - "TaskList", - "TaskGet", - ]), - ), - ) - .all(); - // A grammar v3 `planSteps` snapshot is keyed by its kind, not a tool name; - // the thread/type/item-kind index serves it directly. The newest snapshot - // wins, and the projection picks it by sequence, so the latest row is all - // the banner needs from this kind. - const planStepsRow = db + const row = db .select(storedEventRowFields) .from(events) .where( and( eq(events.threadId, args.threadId), - eq(events.type, "item/completed"), - eq(events.itemKind, "planSteps"), + sql`((${events.itemKind} = 'planSteps' AND ${events.type} = 'item/completed') OR ${events.type} = 'turn/plan/updated')`, ), ) .orderBy(desc(events.sequence)) .limit(1) .get(); - const rows = planStepsRow ? [...legacyRows, planStepsRow] : legacyRows; - - // Ordering in SQL makes SQLite prefer the thread/sequence index and read every - // event in the thread to satisfy the sort; the type/item-kind index visits - // only tool-call rows. Todo snapshots are a small slice of those, so ordering - // after selection is cheaper than widening the scan. - return rows.sort((left, right) => left.sequence - right.sequence); + return row ? [row] : []; } export interface ListActiveBackgroundTaskCountsByThreadIdsArgs { @@ -2686,8 +2634,8 @@ export interface TimelineTurnBoundaryLookupArgs { } /** - * Whether an event at or above `sequence` belongs under a tool call whose own - * item began below it. + * Whether an event at or above `sequence` belongs under a delegating item (a + * tool call or a grammar v3 `delegation`) whose own item began below it. * * Delegation children project into their parent row rather than independent * top-level rows. Splitting that aggregate across two timeline pages is not @@ -2713,7 +2661,7 @@ export function hasParentedEventCrossingSequence( FROM events AS parent_event WHERE parent_event.thread_id = ${events.threadId} AND parent_event.item_id = ${events.parentToolCallId} - AND parent_event.item_kind = 'toolCall' + AND parent_event.item_kind IN ('toolCall', 'delegation') AND parent_event.sequence < ${args.sequence} )`, ), diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index a59e78f51a..367b5e7112 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -739,10 +739,6 @@ export const events = sqliteTable( itemKind: text("item_kind").$type(), parentToolCallId: text("parent_tool_call_id"), data: text("data").notNull().default("{}"), - toolName: text("tool_name").generatedAlwaysAs( - sql`CASE WHEN json_valid(data) THEN json_extract(data, '$.item.tool') END`, - { mode: "virtual" }, - ), createdAt: integer("created_at").notNull(), }, (table) => [ @@ -751,19 +747,24 @@ export const events = sqliteTable( table.sequence, ), // Timeline in-turn pagination checks whether a delegated child above a - // candidate cut belongs to a tool call below it. Keep that parent probe on - // the small tool-call subset rather than walking the thread/sequence index - // and fetching scattered event payload rows. - index("events_tool_call_parent_lookup_idx") - .on(table.threadId, table.itemId, table.sequence) - .where(sql`${table.itemKind} = 'toolCall'`), - // The latest timeline page restores todo/task head state by tool name. - // Keep that lookup on a tiny generated-column index instead of parsing every - // tool-call payload in a long-running thread on every timeline refresh. - index("events_todo_tool_call_thread_tool_sequence_idx") - .on(table.threadId, table.toolName, table.sequence) + // candidate cut belongs to a delegating item below it, and parent + // closure fetches the parent's own rows. A delegating item is a tool + // call or a grammar v3 `delegation` item; keep that probe on their small + // subset rather than walking the thread/sequence index and fetching + // scattered event payload rows. + index("events_delegating_item_lookup_idx") + // `item_kind` trails so the parent probe's EXISTS stays a covering + // lookup: the kind predicate is answered from the index entry. + .on(table.threadId, table.itemId, table.sequence, table.itemKind) + .where(sql`${table.itemKind} IN ('toolCall', 'delegation')`), + // The latest timeline page restores the plan head state (the todo banner) + // from the newest planSteps snapshot, keyed by kind — never by a tool + // name. Persisted codex plan notifications convert to the same item at + // read time, so their type sits beside it. + index("events_plan_steps_thread_sequence_idx") + .on(table.threadId, table.sequence) .where( - sql`${table.itemKind} = 'toolCall' AND ${table.type} IN ('item/started', 'item/completed')`, + sql`(${table.itemKind} = 'planSteps' AND ${table.type} = 'item/completed') OR ${table.type} = 'turn/plan/updated'`, ), index("events_parent_tool_call_thread_parent_sequence_idx") .on(table.threadId, table.parentToolCallId, table.sequence) diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index 06472b80a8..6e6c69866d 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -684,6 +684,11 @@ function dropMarketplaceCatalogSchema(db: DbConnection): void { } function dropEventToolNameColumn(db: DbConnection): void { + // A rewind before 0104 leaves neither the generated tool-name column nor + // the kind-based indexes 0107 replaced it with, so 0104 → 0107 replay from + // the same starting point a real database had. + db.$client.exec("DROP INDEX IF EXISTS events_delegating_item_lookup_idx"); + db.$client.exec("DROP INDEX IF EXISTS events_plan_steps_thread_sequence_idx"); // Generated columns are omitted from table_info but included in table_xinfo. const columns = db.$client .prepare<[], TableInfoRow>("PRAGMA table_xinfo(events)") @@ -4060,16 +4065,16 @@ describe("migrate", () => { expect(eventIndexNames).toEqual([ "events_background_task_thread_type_item_sequence_idx", "events_completed_item_truncation_idx", + "events_delegating_item_lookup_idx", "events_environment_idx", "events_item_lifecycle_thread_item_sequence_idx", "events_parent_tool_call_thread_parent_sequence_idx", + "events_plan_steps_thread_sequence_idx", "events_thread_sequence_idx", "events_thread_state_thread_sequence_idx", "events_thread_turn_type_item_sequence_idx", "events_thread_type_item_kind_sequence_idx", "events_thread_type_sequence_idx", - "events_todo_tool_call_thread_tool_sequence_idx", - "events_tool_call_parent_lookup_idx", ]); const migrationCreatedAts = db.$client diff --git a/packages/db/test/query-plans.test.ts b/packages/db/test/query-plans.test.ts index a017403396..82f65145b9 100644 --- a/packages/db/test/query-plans.test.ts +++ b/packages/db/test/query-plans.test.ts @@ -327,7 +327,7 @@ describe("slow query index plans", () => { db.$client.close(); }); - it("resolves parent crossings through the covering tool-call index", () => { + it("resolves parent crossings through the covering delegating-item index", () => { const { db, thread } = setup(); const captured = captureStatements(db, () => { @@ -350,7 +350,7 @@ describe("slow query index plans", () => { sql: query.sql, }); expect(details).toMatch( - /SEARCH parent_event .*USING COVERING INDEX events_tool_call_parent_lookup_idx/u, + /SEARCH parent_event .*USING COVERING INDEX events_delegating_item_lookup_idx/u, ); db.$client.close(); @@ -467,7 +467,7 @@ describe("slow query index plans", () => { db.$client.close(); }); - it("loads todo tool calls through the generated tool-name index", () => { + it("loads the newest plan snapshot through the kind-based plan-steps index", () => { const { db, thread } = setup(); const captured = captureStatements(db, () => { @@ -475,22 +475,18 @@ describe("slow query index plans", () => { listTodoSnapshotEventRowsForThread(db, { threadId: thread.id }), ).toEqual([]); }); - const query = captured.find((entry) => entry.sql.includes('"tool_name"')); + const query = captured.find((entry) => entry.sql.includes("planSteps")); if (!query) { - throw new Error("Expected the todo snapshot SQL"); + throw new Error("Expected the plan snapshot SQL"); } + // Keyed by item kind (and the legacy notification type), never by a + // tool name or a payload parse. expect(query.sql).not.toContain("json_extract"); - expect(query.params).toEqual([ - thread.id, - "TodoWrite", - "TaskCreate", - "TaskUpdate", - "TaskList", - "TaskGet", - ]); + expect(query.sql).not.toContain("tool_name"); + expect(query.params).toEqual([thread.id, 1]); // LIMIT 1 expect( queryPlanDetails({ db, params: query.params, sql: query.sql }), - ).toContain("events_todo_tool_call_thread_tool_sequence_idx"); + ).toContain("events_plan_steps_thread_sequence_idx"); db.$client.close(); }); diff --git a/packages/domain/src/claude-task-tools.ts b/packages/domain/src/claude-task-tools.ts deleted file mode 100644 index 4b9c6654f4..0000000000 --- a/packages/domain/src/claude-task-tools.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { z } from "zod"; - -export const claudeTaskToolNameValues = [ - "TaskCreate", - "TaskGet", - "TaskList", - "TaskUpdate", -] as const; -export const claudeTaskToolNameSchema = z.enum(claudeTaskToolNameValues); -export type ClaudeTaskToolName = z.infer; - -const claudeTaskStatusValues = ["pending", "in_progress", "completed"] as const; -const claudeTaskStatusSchema = z.enum(claudeTaskStatusValues); - -const claudeTaskUpdateStatusValues = [ - ...claudeTaskStatusValues, - "deleted", -] as const; -const claudeTaskUpdateStatusSchema = z.enum(claudeTaskUpdateStatusValues); - -const claudeTaskListStatusValues = [ - ...claudeTaskStatusValues, - "deleted", -] as const; -const claudeTaskListStatusSchema = z.enum(claudeTaskListStatusValues); - -export const claudeTaskCreateArgsSchema = z - .object({ - activeForm: z.string().optional(), - subject: z.string(), - }) - .passthrough(); - -export const claudeTaskGetArgsSchema = z - .object({ - taskId: z.string(), - }) - .passthrough(); - -export const claudeTaskUpdateArgsSchema = z - .object({ - activeForm: z.string().optional(), - status: claudeTaskUpdateStatusSchema.optional(), - subject: z.string().optional(), - taskId: z.string(), - }) - .passthrough(); - -export const claudeTaskCreateOutputSchema = z - .object({ - task: z - .object({ - id: z.string(), - subject: z.string(), - }) - .passthrough(), - }) - .passthrough(); - -const claudeTaskGetOutputTaskSchema = z - .object({ - id: z.string(), - status: claudeTaskStatusSchema, - subject: z.string(), - }) - .passthrough(); - -export const claudeTaskGetOutputSchema = z - .object({ - task: claudeTaskGetOutputTaskSchema.nullable(), - }) - .passthrough(); - -export const claudeTaskUpdateOutputSchema = z - .object({ - success: z.boolean(), - taskId: z.string(), - }) - .passthrough(); - -export const claudeTaskListItemSchema = z - .object({ - id: z.string(), - status: claudeTaskListStatusSchema, - subject: z.string(), - }) - .passthrough(); - -export const claudeTaskListOutputSchema = z - .object({ - tasks: z.array(z.unknown()), - }) - .passthrough(); - -export const claudeTaskToolOutputSchema = z.union([ - claudeTaskCreateOutputSchema, - claudeTaskGetOutputSchema, - claudeTaskListOutputSchema, - claudeTaskUpdateOutputSchema, -]); -export type ClaudeTaskToolOutput = z.infer; diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 8031e4b5eb..dc598b6255 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -10,7 +10,6 @@ export * from "./app-theme.js"; export * from "./background-task.js"; export * from "./change-kinds.js"; export * from "./code-theme.js"; -export * from "./claude-task-tools.js"; export * from "./debounced-callback-scheduler.js"; export * from "./environment-lifecycle.js"; export * from "./environment.js"; diff --git a/packages/domain/src/legacy-thread-events.ts b/packages/domain/src/legacy-thread-events.ts index 5a8a86479e..c028d83fb7 100644 --- a/packages/domain/src/legacy-thread-events.ts +++ b/packages/domain/src/legacy-thread-events.ts @@ -25,6 +25,7 @@ export const LEGACY_CODEX_GOAL_EXTENSION_KIND = "provider-codex/goal"; export const LEGACY_THREAD_EVENT_TYPES = [ "thread/goal/updated", "thread/goal/cleared", + "turn/plan/updated", ] as const satisfies readonly ThreadEventType[]; export type LegacyThreadEventType = (typeof LEGACY_THREAD_EVENT_TYPES)[number]; @@ -44,6 +45,28 @@ export interface StoredThreadEventShape { data: Record; } +/** + * A stable id for an item a legacy event converts into. The event row carries + * no item id, so the id is derived from the turn and the payload: two + * identical snapshots in one turn fold into one item, which is what a + * superseding snapshot means anyway. + */ +function legacyItemId(prefix: string, turnId: string | null, payload: unknown): string { + const text = JSON.stringify(payload); + // djb2 — deterministic, dependency-free, good enough to key a few + // snapshots per turn. + let hash = 5381; + for (let index = 0; index < text.length; index += 1) { + hash = (hash * 33) ^ text.charCodeAt(index); + } + return `${prefix}:${turnId ?? "thread"}:${(hash >>> 0).toString(36)}`; +} + +/** The scope a converter may key a derived item by. */ +export interface StoredThreadEventConversionScope { + turnId: string | null; +} + const GOAL_FIELDS = [ "objective", "status", @@ -61,8 +84,35 @@ const GOAL_FIELDS = [ */ export function convertLegacyStoredThreadEvent( stored: StoredThreadEventShape, + scope: StoredThreadEventConversionScope = { turnId: null }, ): StoredThreadEventShape { switch (stored.type) { + case "turn/plan/updated": { + // Codex `update_plan` used to reach the timeline as a turn-level + // notification the UI discarded; the codex bridge now emits each + // update as a settled `planSteps` snapshot. Persisted notifications + // decode into the same item so old threads show their plans and feed + // the todo banner. No presentation: the row renders through the core + // plan-steps fallback like every pre-presentation row. + const { plan, explanation, ...rest } = stored.data; + const steps = Array.isArray(plan) ? plan : []; + return { + type: "item/completed", + data: { + ...rest, + item: { + type: "planSteps", + id: legacyItemId("legacy-plan", scope.turnId, { + steps, + explanation, + }), + steps, + ...(typeof explanation === "string" ? { explanation } : {}), + status: "completed", + }, + }, + }; + } case "thread/goal/updated": { const payload: Record = {}; for (const field of GOAL_FIELDS) { diff --git a/packages/domain/src/provider-event.ts b/packages/domain/src/provider-event.ts index b1741684a1..f47940bd90 100644 --- a/packages/domain/src/provider-event.ts +++ b/packages/domain/src/provider-event.ts @@ -499,10 +499,6 @@ export const threadEventItemSchema = z.discriminatedUnion("type", [ server: z.string().optional(), tool: z.string(), arguments: z.record(z.string(), z.unknown()).optional(), - /** Server-enriched labels for a native plugin tool's timeline row. */ - statusLabels: z - .object({ pending: z.string(), completed: z.string() }) - .optional(), status: threadEventItemStatusSchema, result: z.unknown().optional(), error: z.string().optional(), @@ -510,8 +506,7 @@ export const threadEventItemSchema = z.discriminatedUnion("type", [ truncation: threadEventItemTruncationSchema.optional(), /** * The escape hatch for tools with no core kind: the bridge says how the - * row reads. Supersedes the server-enriched `statusLabels` when both are - * present (WS3 deletes `statusLabels`). + * row reads (label, glyph, headline, suppression). */ ...itemPresentationField, parentToolCallId: z.string().optional(), diff --git a/packages/domain/src/stored-thread-event.ts b/packages/domain/src/stored-thread-event.ts index 448c50f546..ddf9f43ac3 100644 --- a/packages/domain/src/stored-thread-event.ts +++ b/packages/domain/src/stored-thread-event.ts @@ -10,6 +10,7 @@ import { import { threadEventScopeSchema, type ThreadEventScope, + getThreadEventScopeTurnId, } from "./thread-event-scope.js"; import type { ThreadEvent, ThreadEventType } from "./provider-event.js"; import type { TurnRequestTarget } from "./thread-events.js"; @@ -133,10 +134,10 @@ export function parseStoredThreadEvent( // Read-time conversion: a row persisted under a vocabulary that has since // moved (codex goals → the plugin's extension state) decodes into its // current shape here, so no consumer ever sees the legacy type. - const stored = convertLegacyStoredThreadEvent({ - type: args.type, - data: args.data, - }); + const stored = convertLegacyStoredThreadEvent( + { type: args.type, data: args.data }, + { turnId: getThreadEventScopeTurnId(scope) ?? null }, + ); const eventData = storedTurnRequestTypeSet.has(stored.type) ? parseStoredTurnRequestEventData({ ...args, data: stored.data }) : stored.data; diff --git a/packages/host-daemon-contract/src/protocol.ts b/packages/host-daemon-contract/src/protocol.ts index ee0275bb1c..5613c39ac4 100644 --- a/packages/host-daemon-contract/src/protocol.ts +++ b/packages/host-daemon-contract/src/protocol.ts @@ -152,9 +152,16 @@ // provider `rootPath`, and the old vocabulary could not name a plugin // provider. An old daemon rejects the new scope values. // +// 151 (WS3 layer 5): the server no longer enriches tool-call events with the +// plugin `statusLabels` pair — the bridge's persisted `presentation` is the +// only label source — so the `item.statusLabels` key left the toolCall item +// schema and the daemon-wire guard that rejected a daemon-supplied one is +// gone. A daemon never sent the key, so the bytes on the wire are unchanged; +// the bump records that the wire's acceptance rules moved. +// // The version mismatch is what triggers the enrolled daemon's automatic update // instead of an `invalid-message` reconnect loop. -export const HOST_DAEMON_PROTOCOL_VERSION = 150 as const; +export const HOST_DAEMON_PROTOCOL_VERSION = 151 as const; /** * Absolute ceiling for any executable artifact delivered to a host daemon — diff --git a/packages/host-daemon-contract/src/session.ts b/packages/host-daemon-contract/src/session.ts index dfea877ac2..0c1fa58fd4 100644 --- a/packages/host-daemon-contract/src/session.ts +++ b/packages/host-daemon-contract/src/session.ts @@ -224,22 +224,6 @@ const hostDaemonWireEventSchema = z path: ["sequence"], }); } - // Plugin status labels are server-owned presentation metadata, snapshotted - // during ingest. Without this guard a daemon could set them directly on - // MCP, unknown, and unlabeled tool calls, which the enrichment step leaves - // untouched. - const item: unknown = (value as { item?: unknown }).item; - if ( - typeof item === "object" && - item !== null && - Object.hasOwn(item, "statusLabels") - ) { - context.addIssue({ - code: "custom", - message: "Daemon events must not provide server-owned status labels", - path: ["item", "statusLabels"], - }); - } }) .pipe(threadEventSchema); diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index 429806005f..240e4097f9 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -1128,7 +1128,7 @@ describe("host-daemon command schemas", () => { // mixed version. Version 113 carried the Devin Desktop open target rename // and remains part of the protocol lineage. it("uses the current host-daemon protocol version", () => { - expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(150); + expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(151); expect(HOST_ARTIFACT_MAX_BYTES).toBe(256 * 1024 * 1024); }); @@ -3429,48 +3429,34 @@ describe("host-daemon session schemas", () => { }), ).toThrow(); - // Status labels are server-owned: the ingest enrichment leaves MCP, - // unknown, and unlabeled tool calls untouched, so a daemon that supplied - // its own labels would otherwise have them persisted and rendered. - for (const item of [ - // MCP tool call — enrichment skips these on `server`. - { - type: "toolCall" as const, - id: "tool-1", - server: "some-mcp-server", - tool: "search", - status: "pending" as const, - statusLabels: { pending: "Spoofed", completed: "Spoofed" }, - }, - // Native tool with no registered plugin labels. - { - type: "toolCall" as const, - id: "tool-2", - tool: "Read", - status: "pending" as const, - statusLabels: { pending: "Spoofed", completed: "Spoofed" }, - }, - ]) { - expect(() => - hostDaemonEventBatchRequestSchema.parse({ - sessionId: "session_123", - eventGroups: [ + // A `statusLabels` key on an item is not part of the wire any more (the + // bridge's presentation is the only label source); a daemon that sends + // one has it dropped rather than persisted. + const parsed = hostDaemonEventBatchRequestSchema.parse({ + sessionId: "session_123", + eventGroups: [ + { + threadId: "thr_123", + events: [ { + type: "item/started", threadId: "thr_123", - events: [ - { - type: "item/started", - threadId: "thr_123", - providerThreadId: "provider-1", - scope: threadScope(), - item, - }, - ], + providerThreadId: "provider-1", + scope: turnScope("turn-1"), + item: { + type: "toolCall", + id: "tool-2", + tool: "Read", + status: "pending", + statusLabels: { pending: "Spoofed", completed: "Spoofed" }, + }, }, ], - }), - ).toThrow(); - } + }, + ], + }); + const [group] = parsed.eventGroups; + expect(group?.events[0]?.item).not.toHaveProperty("statusLabels"); expect(() => hostDaemonEventBatchResponseSchema.parse({ diff --git a/packages/plugin-sdk/src/provider-bridge.ts b/packages/plugin-sdk/src/provider-bridge.ts index 59e4664da7..2468f7d16d 100644 --- a/packages/plugin-sdk/src/provider-bridge.ts +++ b/packages/plugin-sdk/src/provider-bridge.ts @@ -254,8 +254,6 @@ export { acpPermissionCliSchema, acpReasoningCliSchema, backgroundTaskItemStatus, - claudeTaskToolNameSchema, - claudeTaskToolOutputSchema, dynamicToolSchema, instructionModeValues, isApprovalPendingInteractionPayload, @@ -293,7 +291,6 @@ export type { AvailableModel, BackgroundTaskStatus, BackgroundTaskUsage, - ClaudeTaskToolOutput, ClientTurnRequestId, DynamicTool, ExtensionKind, diff --git a/packages/provider-bridge-protocol/recordings/parity-allowlist.json b/packages/provider-bridge-protocol/recordings/parity-allowlist.json index f722a98a93..b26ed2353c 100644 --- a/packages/provider-bridge-protocol/recordings/parity-allowlist.json +++ b/packages/provider-bridge-protocol/recordings/parity-allowlist.json @@ -790,5 +790,13 @@ "path": "/0/children/0/presentation", "pr": "#2192", "reason": "WS3 layer 1: every work row projected from a provider item carries the bridge's persisted presentation (label/icon/title/detail/suppress/tint); the old leg projected none onto rows." + }, + { + "provider": "codex", + "cell": "user-question", + "layer": "rows", + "path": "/", + "pr": "#2232", + "reason": "Layer 5 deleted core's tool-name suppression set; a bb-injected tool row hides only through the `suppress` the server resolves onto the tool definition. This immutable v2 recording's thread/start predates that field, so the replayed AskUserQuestion call renders as a generic tool row inside a turn (the old leg hid it by name) and the rows layer is not comparable by pointer. Re-record the cell to drop this entry." } ] diff --git a/packages/provider-bridge-protocol/recordings/row-counts.json b/packages/provider-bridge-protocol/recordings/row-counts.json index c4b55f7af2..e0d238b401 100644 --- a/packages/provider-bridge-protocol/recordings/row-counts.json +++ b/packages/provider-bridge-protocol/recordings/row-counts.json @@ -241,7 +241,7 @@ }, "codex/user-question": { "events": 18, - "rows": 1, + "rows": 2, "unhandled": 0, "grammarDrops": 0 }, diff --git a/packages/provider-bridge-protocol/src/testing/parity.ts b/packages/provider-bridge-protocol/src/testing/parity.ts index 643677b3ae..230f4c8767 100644 --- a/packages/provider-bridge-protocol/src/testing/parity.ts +++ b/packages/provider-bridge-protocol/src/testing/parity.ts @@ -990,9 +990,20 @@ function pointerSegments(path: string): string[] { * Delete every value under a wildcard JSON pointer. Returns how many values * the mask removed, so an allowlist entry that touches nothing is reported * stale. + * + * The root pointer (`/`) empties the whole layer. A pointer cannot describe + * a change that inserts or removes a list entry (every later index shifts), + * so an entry that needs this must say in its reason why the layer is not + * comparable for that cell and what re-records it out of the allowlist. */ export function maskPath(value: unknown, path: string): number { const segments = pointerSegments(path); + if (segments.length === 0) { + if (!Array.isArray(value)) return 0; + const removed = value.length; + value.length = 0; + return removed; + } let removed = 0; const visit = (node: unknown, index: number): void => { if (index >= segments.length || node === null || typeof node !== "object") { diff --git a/packages/provider-parity/src/parity.self.test.ts b/packages/provider-parity/src/parity.self.test.ts index 5ac4997d6c..8c7c012ea9 100644 --- a/packages/provider-parity/src/parity.self.test.ts +++ b/packages/provider-parity/src/parity.self.test.ts @@ -143,6 +143,48 @@ describe("allowlist", () => { expect(comparison.staleAllowlist.map((entry) => entry.reason)).toEqual(["stale"]); expect(comparison.passed).toBe(false); }); + + it("lets the root pointer empty one layer of one cell, and reports it stale when the layer is already empty", () => { + const oldRows = [{ kind: "conversation", id: "#1", text: "answer" }]; + const newRows = [ + { kind: "turn", id: "#1", children: [{ kind: "work", id: "#3", toolName: "bb:AskUserQuestion" }] }, + { kind: "conversation", id: "#5", text: "answer" }, + ]; + const entry: ParityAllowlistEntry = { + provider: "codex", + cell: "user-question", + layer: "rows", + path: "/", + pr: "#0", + reason: "rows are not comparable for this cell", + }; + const masked = compareParity( + { events: [], rows: oldRows }, + { events: [], rows: newRows }, + [entry], + { provider: "codex", cell: "user-question" }, + ); + expect(masked.rows).toEqual({ onlyInOld: [], onlyInNew: [] }); + expect(masked.staleAllowlist).toEqual([]); + expect(masked.passed).toBe(true); + + // The events layer of the same cell is untouched by a rows entry. + const unmasked = compareParity( + { events: [], rows: oldRows }, + { events: [], rows: newRows }, + [], + { provider: "codex", cell: "user-question" }, + ); + expect(unmasked.passed).toBe(false); + + const stale = compareParity( + { events: [], rows: [] }, + { events: [], rows: [] }, + [entry], + { provider: "codex", cell: "user-question" }, + ); + expect(stale.staleAllowlist).toEqual([entry]); + }); }); describe("replay through the current bridge", () => { diff --git a/packages/server-contract/src/thread-timeline.ts b/packages/server-contract/src/thread-timeline.ts index fffa5f5298..55e2b8dab9 100644 --- a/packages/server-contract/src/thread-timeline.ts +++ b/packages/server-contract/src/thread-timeline.ts @@ -315,14 +315,6 @@ export const timelineToolWorkRowSchema = timelineWorkRowBaseSchema.extend({ callId: z.string(), toolName: z.string(), toolArgs: z.record(z.string(), jsonValueSchema).nullable(), - /** - * Plugin-supplied labels for the native pending/completed title, persisted - * on rows projected before `presentation` carried them. `presentation` - * supersedes these when both are present. - */ - statusLabels: z - .object({ pending: z.string(), completed: z.string() }) - .optional(), output: z.string(), outputPreview: timelineOutputPreviewSchema.optional(), completedAt: z.number().nullable(), diff --git a/packages/thread-view/src/build-event-projection.ts b/packages/thread-view/src/build-event-projection.ts index 3560ac8a56..9ba0618a09 100644 --- a/packages/thread-view/src/build-event-projection.ts +++ b/packages/thread-view/src/build-event-projection.ts @@ -127,16 +127,6 @@ interface BuildDetailedProjectionArgs { turnMessageDetail: BuildEventProjectionOptions["turnMessageDetail"]; } -const PROVIDER_THREAD_DELEGATION_TOOL_NAMES = new Set([ - "spawnAgent", - "resumeAgent", -]); -const PROVIDER_THREAD_CHILD_INTERACTION_TOOL_NAMES = new Set([ - "sendInput", - "wait", - "closeAgent", -]); - /** * Every workflow currently running in the thread, newest start first. A thread * can drive several workflows at once, so this is a list rather than a single @@ -462,67 +452,19 @@ function appendProjectedUserMessage( state.messages.push(projectedClientUser); } -function getToolCallName(decoded: ThreadEvent): string | undefined { - if ( - (decoded.type !== "item/started" && decoded.type !== "item/completed") || - decoded.item.type !== "toolCall" - ) { - return undefined; - } - - return decoded.item.tool; -} - -/** A grammar v3 `delegation` item lifecycle event (turn-scoped or background). */ -function isDelegationItemEvent(decoded: ThreadEvent): boolean { - return ( - (decoded.type === "item/started" || - decoded.type === "item/completed" || - decoded.type === "item/delegation/completed") && - decoded.item.type === "delegation" - ); -} - -function getToolCallReceiverThreadIds(decoded: ThreadEvent): string[] { - if ( - (decoded.type === "item/started" || - decoded.type === "item/completed" || - decoded.type === "item/delegation/completed") && +/** + * The provider-native child a `delegation` item names (grammar v3). That + * child's turns, which carry its provider thread id, map to this call. A + * generic tool call names no child: the persisted `parentToolCallId` on its + * children is the only link, never its name or arguments. + */ +function getDelegationChildRef(decoded: ThreadEvent): string | undefined { + return (decoded.type === "item/started" || + decoded.type === "item/completed" || + decoded.type === "item/delegation/progress" || + decoded.type === "item/delegation/completed") && decoded.item.type === "delegation" - ) { - // The delegation names its child directly; that child's turns map to - // this call exactly as a spawnAgent receiver would. - return [decoded.item.childRef]; - } - if ( - (decoded.type !== "item/started" && decoded.type !== "item/completed") || - decoded.item.type !== "toolCall" - ) { - return []; - } - - const receiverThreadIds = decoded.item.arguments?.receiverThreadIds; - if (!Array.isArray(receiverThreadIds)) { - return []; - } - - return receiverThreadIds.filter( - (receiverThreadId): receiverThreadId is string => - typeof receiverThreadId === "string" && receiverThreadId.length > 0, - ); -} - -function getToolCallSenderThreadId(decoded: ThreadEvent): string | undefined { - if ( - (decoded.type !== "item/started" && decoded.type !== "item/completed") || - decoded.item.type !== "toolCall" - ) { - return undefined; - } - - const senderThreadId = decoded.item.arguments?.senderThreadId; - return typeof senderThreadId === "string" && senderThreadId.length > 0 - ? senderThreadId + ? decoded.item.childRef : undefined; } @@ -907,9 +849,7 @@ function buildFlatProjectionData( eventParentToolCallId, ); if (toolCallEvent) { - const toolCallName = getToolCallName(decoded); - const toolCallReceiverThreadIds = getToolCallReceiverThreadIds(decoded); - const toolCallSenderThreadId = getToolCallSenderThreadId(decoded); + const delegationChildRef = getDelegationChildRef(decoded); if (toolCallEvent.kind !== "output") { if (toolCallEvent.call.kind === "delegation" && eventTurnId) { state.delegationTurnIdsByCallId.set( @@ -917,57 +857,22 @@ function buildFlatProjectionData( eventTurnId, ); } - if ( - !toolCallEvent.call.parentToolCallId && - toolCallName && - PROVIDER_THREAD_CHILD_INTERACTION_TOOL_NAMES.has(toolCallName) - ) { - const inferredParentToolCallId = toolCallReceiverThreadIds - .map((receiverThreadId) => - state.delegationParentToolCallIdsByProviderThreadId.get( - receiverThreadId, - ), - ) - .find( - (parentToolCallId): parentToolCallId is string => - typeof parentToolCallId === "string" && - parentToolCallId.length > 0, - ); - if (inferredParentToolCallId) { - toolCallEvent.call.parentToolCallId = inferredParentToolCallId; - } - } - if ( - (toolCallName && - PROVIDER_THREAD_DELEGATION_TOOL_NAMES.has(toolCallName)) || - isDelegationItemEvent(decoded) - ) { + if (delegationChildRef !== undefined) { if ( - toolCallReceiverThreadIds.length === 0 || + delegationChildRef === eventProviderThreadId || state.delegatedTurnLinkCallIds.has(toolCallEvent.call.callId) ) { + // The child runs in the spawning provider thread (a follow-up + // turn of the same session): link the next turn started there. enqueuePendingDelegationTurnLink( state, eventProviderThreadId, eventTurnId, toolCallEvent.call.callId, ); - } - for (const receiverThreadId of toolCallReceiverThreadIds) { - if ( - receiverThreadId === eventProviderThreadId || - receiverThreadId === toolCallSenderThreadId - ) { - enqueuePendingDelegationTurnLink( - state, - eventProviderThreadId, - eventTurnId, - toolCallEvent.call.callId, - ); - continue; - } + } else { state.delegationParentToolCallIdsByProviderThreadId.set( - receiverThreadId, + delegationChildRef, toolCallEvent.call.callId, ); } diff --git a/packages/thread-view/src/build-thread-timeline.ts b/packages/thread-view/src/build-thread-timeline.ts index 72620c4286..41407e2266 100644 --- a/packages/thread-view/src/build-thread-timeline.ts +++ b/packages/thread-view/src/build-thread-timeline.ts @@ -624,9 +624,6 @@ function convertMessage( callId: message.callId, toolName: message.toolName, toolArgs: message.toolArgs, - ...(message.statusLabels - ? { statusLabels: message.statusLabels } - : {}), output: message.output, completedAt: message.completedAt, approvalStatus: message.approvalStatus, diff --git a/packages/thread-view/src/event-projection-message.ts b/packages/thread-view/src/event-projection-message.ts index 033d9f4cd2..58a9eb31fc 100644 --- a/packages/thread-view/src/event-projection-message.ts +++ b/packages/thread-view/src/event-projection-message.ts @@ -158,7 +158,6 @@ export interface EventProjectionToolCallMessage kind: "tool-call"; toolName: string; toolArgs: JsonObject | null; - statusLabels?: { pending: string; completed: string }; callId: string; parsedIntents: EventProjectionToolParsedIntent[]; output: string; diff --git a/packages/thread-view/src/exec-lifecycle.ts b/packages/thread-view/src/exec-lifecycle.ts index 9bfebd1a1b..5536098ebf 100644 --- a/packages/thread-view/src/exec-lifecycle.ts +++ b/packages/thread-view/src/exec-lifecycle.ts @@ -12,17 +12,9 @@ import type { EventProjectionToolCallMessage, EventProjectionToolParsedIntent, } from "./event-projection-types.js"; -import { getFirstStringField } from "./format-helpers.js"; import { - baseToolName, extractShellCommandFromString, - formatToolCallCommand, - isDelegationToolName, - isStructuredListToolName, - isStructuredReadToolName, - isStructuredSearchToolName, parseShellCommandIntents, - stripAgentOutputMetadata, } from "./tool-call-parsing.js"; interface DelegationMetadata { @@ -104,7 +96,6 @@ export interface ToolCallExecutionUpdate extends ExecutionUpdateBase { kind: "tool-call"; toolName?: string; toolArgs?: JsonObject | null; - statusLabels?: { pending: string; completed: string }; parsedIntents?: EventProjectionToolParsedIntent[]; approvalStatus?: EventProjectionApprovalLifecycleStatus | null; } @@ -143,107 +134,6 @@ type ExecLifecycleEvent = replaceOutput?: boolean; }; -function buildStructuredReadIntents( - toolName: string, - args: Record | null, -): EventProjectionToolParsedIntent[] { - const path = getFirstStringField(args, ["file_path", "file", "path"]); - if (!path) { - return []; - } - - return [ - { - type: "read", - cmd: formatToolCallCommand(toolName, args), - name: baseToolName(toolName), - path, - }, - ]; -} - -function buildStructuredSearchIntents( - toolName: string, - args: Record | null, -): EventProjectionToolParsedIntent[] { - const query = getFirstStringField(args, ["pattern", "query"]); - if (!query) { - return []; - } - - return [ - { - type: "search", - cmd: formatToolCallCommand(toolName, args), - query, - path: getFirstStringField(args, ["path"]) ?? null, - }, - ]; -} - -function buildStructuredListIntents( - toolName: string, - args: Record | null, -): EventProjectionToolParsedIntent[] { - const path = getFirstStringField(args, ["path", "pattern"]); - if (!path) { - return []; - } - - return [ - { - type: "list_files", - cmd: formatToolCallCommand(toolName, args), - path, - }, - ]; -} - -function getStructuredToolParsedIntents( - toolName: string, - args: Record | null, -): EventProjectionToolParsedIntent[] { - const baseName = baseToolName(toolName); - if (isStructuredReadToolName(baseName)) { - return buildStructuredReadIntents(toolName, args); - } - if (isStructuredSearchToolName(baseName)) { - return buildStructuredSearchIntents(toolName, args); - } - if (isStructuredListToolName(baseName)) { - return buildStructuredListIntents(toolName, args); - } - return []; -} - -function getDelegationMetadata( - toolName: string, - args: Record | null, -): DelegationMetadata { - if (!isDelegationToolName(toolName)) { - return {}; - } - - const subagentType = getFirstStringField(args, [ - "subagent_type", - "subagentType", - ]); - const description = getFirstStringField(args, ["description", "prompt"]); - const model = getFirstStringField(args, ["model"]); - return { - ...(subagentType ? { subagentType } : {}), - ...(description ? { description } : {}), - ...(model ? { model } : {}), - }; -} - -function formatToolCallResultOutput(toolName: string, output: string): string { - if (baseToolName(toolName) === "Agent") { - return stripAgentOutputMetadata(output); - } - return output; -} - export function parseExecLifecycleEvent( decoded: ThreadEvent, meta: EventMeta, @@ -401,59 +291,32 @@ export function parseToolCallLifecycleEvent( kind === "end" ? itemStatusToExecStatus(decoded.item.status) : "pending"; const completedAt = kind === "end" ? meta.createdAt : null; const result = decoded.item.result; - const rawOutput = + const output = typeof result === "string" ? result : result !== undefined ? JSON.stringify(result) : undefined; - const output = - rawOutput !== undefined - ? formatToolCallResultOutput(fullToolName, rawOutput) - : undefined; const errorField = decoded.item.error; - const parsedIntents = getStructuredToolParsedIntents( - fullToolName, - parsedArgs, - ); - const executionKind = isDelegationToolName(fullToolName) - ? "delegation" - : "tool-call"; - const delegationMetadata = getDelegationMetadata(fullToolName, parsedArgs); const toolArgs = parseToolArgs(parsedArgs); - const statusLabels = decoded.item.statusLabels; const presentation = decoded.item.presentation; - const baseCall = { - callId, - toolName: fullToolName, - output: kind === "end" ? (output ?? errorField) : undefined, - completedAt, - status, - ...(presentation ? { presentation } : {}), - ...(parentToolCallId ? { parentToolCallId } : {}), - }; - - if (executionKind === "delegation") { - return { - kind, - call: { - ...baseCall, - kind: executionKind, - ...delegationMetadata, - }, - }; - } - + // A generic tool call. Its kind, its label and whether it delegated work + // come from the persisted item (the bridge's presentation, the v3 item + // kinds, child turns linked by `parentToolCallId`), never from its name. return { kind, call: { - ...baseCall, - kind: executionKind, + kind: "tool-call", + callId, + toolName: fullToolName, + output: kind === "end" ? (output ?? errorField) : undefined, + completedAt, + status, toolArgs, - ...(statusLabels ? { statusLabels } : {}), - parsedIntents, - ...delegationMetadata, + parsedIntents: [], + ...(presentation ? { presentation } : {}), + ...(parentToolCallId ? { parentToolCallId } : {}), }, }; } diff --git a/packages/thread-view/src/normalize-event-projection.ts b/packages/thread-view/src/normalize-event-projection.ts index 4b09291967..9c56e52c95 100644 --- a/packages/thread-view/src/normalize-event-projection.ts +++ b/packages/thread-view/src/normalize-event-projection.ts @@ -1,8 +1,10 @@ +import { getFirstStringField, messageId } from "./format-helpers.js"; import type { EventProjectionDelegationMessage, EventProjectionMessage, EventProjection, EventProjectionEntry, + EventProjectionToolCallMessage, EventProjectionTurn, } from "./event-projection-types.js"; import { findLastTerminalTimelineMessage } from "./timeline-message-helpers.js"; @@ -68,6 +70,59 @@ function isDelegationSourceMessage( return message.kind === "delegation"; } +/** + * A generic tool call that other messages name as their `parentToolCallId` + * delegated work, whatever the tool was called: its children are the proof. + * Persisted before the `delegation` item kind existed (a Claude `Agent` + * call with nested sub-agent turns), it becomes the delegation row those + * children nest under. No tool-name table decides this. + */ +/** + * A persisted tool call that other rows name as their parent is a delegation + * whatever the bridge called the tool. Its label metadata comes from the + * conventional argument keys a delegating tool carries (`description` or + * `prompt`, `subagent_type`, `model`) — argument shape, never a tool name. A grammar v3 `delegation` item carries these fields + * explicitly and does not pass through here. + */ +function toolCallAsDelegationMessage( + message: EventProjectionToolCallMessage, +): EventProjectionDelegationMessage { + const { + kind: _kind, + toolArgs, + parsedIntents: _parsedIntents, + approvalStatus: _approvalStatus, + ...shared + } = message; + const subagentType = getFirstStringField(toolArgs, [ + "subagent_type", + "subagentType", + ]); + const description = getFirstStringField(toolArgs, ["description", "prompt"]); + const model = getFirstStringField(toolArgs, ["model"]); + return { + ...shared, + // Re-mint the row id under the delegation kind so a persisted thread + // keeps the ids it had when the bridge (or a name table) marked the call + // as a delegation; nested rows inherit this id as their prefix. + id: messageId(message.threadId, "delegation", message.callId), + kind: "delegation", + ...(subagentType ? { subagentType } : {}), + ...(description ? { description } : {}), + ...(model ? { model } : {}), + childRef: null, + background: false, + childProjection: { + state: { + activeThinking: null, + activeWorkflows: [], + activeBackgroundCommands: [], + }, + entries: [], + }, + }; +} + function maybeStartedAt( message: MessageTimingSource, childBounds: ProjectionMessageBounds | null, @@ -261,6 +316,19 @@ class SemanticProjectionBuilder { ) { this.contextOnlyToolCallIds = options.contextOnlyToolCallIds ?? new Set(); + const referencedParentCallIds = new Set( + contexts + .map((context) => context.message.parentToolCallId) + .filter((id): id is string => id !== undefined), + ); + for (const context of contexts) { + if ( + context.message.kind === "tool-call" && + referencedParentCallIds.has(context.message.callId) + ) { + context.message = toolCallAsDelegationMessage(context.message); + } + } const delegationCallIds = new Set( contexts .map((context) => context.message) diff --git a/packages/thread-view/src/timeline-noise-events.ts b/packages/thread-view/src/timeline-noise-events.ts index 762d68a1f8..a1a0d864dc 100644 --- a/packages/thread-view/src/timeline-noise-events.ts +++ b/packages/thread-view/src/timeline-noise-events.ts @@ -8,6 +8,10 @@ import type { ThreadEventType } from "@bb/domain"; * full workspace diff — one observed thread held 11.3 MB of them across 144 * rows, more than 90 % of everything its window read — and the projection * classifies it as a duplicate event and drops it. + * + * `turn/plan/updated` is NOT here: persisted codex plan notifications decode + * into `planSteps` items at read time (legacy-thread-events.ts), so a window + * must read them to show old threads' plans. */ export const THREAD_TIMELINE_EXCLUDED_EVENT_TYPES = [ "thread/started", @@ -15,5 +19,4 @@ export const THREAD_TIMELINE_EXCLUDED_EVENT_TYPES = [ "thread/contextWindowUsage/updated", "thread/tokenUsage/updated", "turn/diff/updated", - "turn/plan/updated", ] as const satisfies readonly ThreadEventType[]; diff --git a/packages/thread-view/src/timeline-row-title.ts b/packages/thread-view/src/timeline-row-title.ts index 81a44db2f6..9ba92f5eb9 100644 --- a/packages/thread-view/src/timeline-row-title.ts +++ b/packages/thread-view/src/timeline-row-title.ts @@ -527,24 +527,6 @@ function mapExecutionTitle(row: TimelineExecutionWorkRow): TimelineTitle { const content = isCommand ? row.command : formatToolCallCommand(row.toolName, row.toolArgs); - // Keyed by BB's own row status, so a state with no plugin label (error, - // interrupted, waiting, denied) falls through to the standard rendering - // and the failing tool stays identifiable. - const statusLabels = isCommand ? undefined : row.statusLabels; - const label = - statusLabels && (status === "pending" || status === "completed") - ? statusLabels[status] - : null; - if (label !== null) { - return makeTitle({ - segments: [ - segment(label, { shimmer: status === "pending", truncate: true }), - ], - decorations: filterNull([ - durationDecoration(row.startedAt, row.completedAt), - ]), - }); - } const explorationTitle = mapSingleExplorationIntentTitle(row); if (explorationTitle !== null) { return explorationTitle; diff --git a/packages/thread-view/src/todo-snapshot-extraction.ts b/packages/thread-view/src/todo-snapshot-extraction.ts index 29d9cef44d..2898cf4765 100644 --- a/packages/thread-view/src/todo-snapshot-extraction.ts +++ b/packages/thread-view/src/todo-snapshot-extraction.ts @@ -1,4 +1,3 @@ -import { z } from "zod"; import type { Thread, ThreadEvent, @@ -7,219 +6,32 @@ import type { ThreadTimelinePendingTodoItemStatus, ThreadTimelinePendingTodos, } from "@bb/domain"; -import { - claudeTaskCreateArgsSchema, - claudeTaskCreateOutputSchema, - claudeTaskGetArgsSchema, - claudeTaskGetOutputSchema, - claudeTaskListItemSchema, - claudeTaskListOutputSchema, - claudeTaskUpdateArgsSchema, - claudeTaskUpdateOutputSchema, -} from "@bb/domain"; import type { ThreadEventWithMeta } from "./build-event-projection.js"; import { getOrderedThreadEvents } from "./group-event-projection-turns.js"; const TODO_TEXT_MAX_LENGTH = 240; -const KNOWN_TODO_WRITE_STATUSES: ReadonlySet = - new Set(["pending", "in_progress", "completed"]); - -// Tolerant at the provider boundary: each item is shape-checked but unknown -// status values drop the *item*, not the whole payload. The whole-payload -// reject only fires when the args don't have a usable `todos` array at all -// (truly malformed input from a provider) — losing the entire snapshot for a -// single new status (e.g. provider adds "cancelled") would silently kill the -// banner. -const todoWriteTodoSchema = z - .object({ - activeForm: z.string().optional(), - content: z.string(), - status: z.string(), - }) - .passthrough(); - -const todoWriteArgsSchema = z.object({ - todos: z.array(z.unknown()), -}); - -interface ParsedTodoWriteTodo { - activeForm?: string; - content: string; - status: ThreadTimelinePendingTodoItemStatus; -} - -interface ParsedTodoWriteArgs { - todos: ParsedTodoWriteTodo[]; -} - function trimAndTruncate(value: string): string { const trimmed = value.trim(); if (trimmed.length <= TODO_TEXT_MAX_LENGTH) return trimmed; return trimmed.slice(0, TODO_TEXT_MAX_LENGTH); } -function isKnownTodoStatus( - value: string, -): value is ThreadTimelinePendingTodoItemStatus { - switch (value) { - case "pending": - case "in_progress": - case "completed": - return KNOWN_TODO_WRITE_STATUSES.has(value); - default: - return false; - } -} - -/** - * Canonical TodoWrite arguments parser. Returns null only when the args don't - * have a `todos` array at all. Items with unknown status values, missing - * content, or shape mismatches are dropped individually so the snapshot - * survives partial provider drift. - */ -export function parseTodoWriteTodos( - rawArgs: unknown, -): ParsedTodoWriteArgs | null { - const result = todoWriteArgsSchema.safeParse(rawArgs); - if (!result.success) return null; - const todos: ParsedTodoWriteTodo[] = []; - for (const rawTodo of result.data.todos) { - const itemResult = todoWriteTodoSchema.safeParse(rawTodo); - if (!itemResult.success) continue; - const todo = itemResult.data; - if (!isKnownTodoStatus(todo.status)) continue; - const content = trimAndTruncate(todo.content); - if (content.length === 0) continue; - const activeForm = - todo.activeForm !== undefined ? trimAndTruncate(todo.activeForm) : null; - todos.push({ - ...(activeForm && activeForm.length > 0 ? { activeForm } : {}), - content, - status: todo.status, - }); - } - return { todos }; -} - -function todoWriteText(todo: ParsedTodoWriteTodo): string { - if (todo.status === "in_progress" && todo.activeForm !== undefined) { - return todo.activeForm; - } - return todo.content; -} - -function parseMaybeJson(value: unknown): unknown { - if (typeof value !== "string") return value; - try { - const parsed: unknown = JSON.parse(value); - return parsed; - } catch { - return value; - } -} - -function taskText(task: ClaudeTaskTodoItem): string | null { - const text = - task.status === "in_progress" && task.activeForm !== null - ? task.activeForm - : task.subject; - const trimmed = trimAndTruncate(text); - return trimmed.length > 0 ? trimmed : null; -} - -function taskStateSnapshotCandidate( - state: ClaudeTaskTodoState, - meta: SnapshotCandidateMeta, -): SnapshotCandidate { - const items: ThreadTimelinePendingTodoItem[] = []; - for (const task of state.tasks.values()) { - const text = taskText(task); - if (text === null) continue; - items.push({ - id: `task:${task.id}`, - text, - status: task.status, - }); - } - return { - seq: meta.seq, - createdAt: meta.createdAt, - items, - }; -} - -function isSameTask( - left: ClaudeTaskTodoItem, - right: ClaudeTaskTodoItem, -): boolean { - return ( - left.activeForm === right.activeForm && - left.id === right.id && - left.status === right.status && - left.subject === right.subject - ); -} - interface SnapshotCandidate { seq: number; createdAt: number; - /** null indicates an unparseable candidate. */ - items: ThreadTimelinePendingTodoItem[] | null; + items: ThreadTimelinePendingTodoItem[]; } interface SnapshotCandidateMeta { - createdAt: number; seq: number; -} - -interface ClaudeTaskTodoItem { - activeForm: string | null; - id: string; - status: ThreadTimelinePendingTodoItemStatus; - subject: string; -} - -interface ClaudeTaskTodoState { - tasks: Map; + createdAt: number; } function todoIdFor(seq: number, index: number): string { return `seq:${seq}:${index}`; } -function extractTodoWriteCandidate( - event: ThreadEvent, - meta: { seq: number; createdAt: number }, -): SnapshotCandidate | null { - if (event.type !== "item/started" && event.type !== "item/completed") { - return null; - } - if (event.item.type !== "toolCall" || event.item.tool !== "TodoWrite") { - return null; - } - const parsed = parseTodoWriteTodos(event.item.arguments); - if (!parsed) { - return { - seq: meta.seq, - createdAt: meta.createdAt, - items: null, - }; - } - const items: ThreadTimelinePendingTodoItem[] = parsed.todos.map( - (todo, index) => ({ - id: todoIdFor(meta.seq, index), - text: todoWriteText(todo), - status: todo.status, - }), - ); - return { - seq: meta.seq, - createdAt: meta.createdAt, - items, - }; -} - const PLAN_STEP_TODO_STATUSES: Readonly< Record< NonNullable, @@ -235,8 +47,10 @@ const PLAN_STEP_TODO_STATUSES: Readonly< /** * A grammar v3 `planSteps` snapshot (Claude TodoWrite and the folded - * task-list tools, codex update_plan): the bridge already reduced the plan - * to its full step list, so the snapshot is the candidate as-is. + * task-list tools, codex `update_plan`, the ACP plan): the bridge already + * reduced the plan to its full step list, so the snapshot is the candidate + * as-is. The only source the banner reads — core keeps no table of the tool + * names that used to carry a plan. */ function extractPlanStepsCandidate( event: ThreadEvent, @@ -258,199 +72,11 @@ function extractPlanStepsCandidate( return { seq: meta.seq, createdAt: meta.createdAt, items }; } -function extractTaskCreateCandidate( - event: ThreadEvent, - meta: SnapshotCandidateMeta, - state: ClaudeTaskTodoState, -): SnapshotCandidate | null { - if (event.type !== "item/completed") return null; - if ( - event.item.type !== "toolCall" || - event.item.tool !== "TaskCreate" || - event.item.status !== "completed" - ) { - return null; - } - - const parsedArgs = claudeTaskCreateArgsSchema.safeParse(event.item.arguments); - if (!parsedArgs.success) return null; - const parsedResult = claudeTaskCreateOutputSchema.safeParse( - parseMaybeJson(event.item.result), - ); - if (!parsedResult.success) return null; - - const activeForm = - parsedArgs.data.activeForm !== undefined - ? trimAndTruncate(parsedArgs.data.activeForm) - : null; - const subject = trimAndTruncate( - parsedArgs.data.subject.length > 0 - ? parsedArgs.data.subject - : parsedResult.data.task.subject, - ); - state.tasks.set(parsedResult.data.task.id, { - activeForm: activeForm && activeForm.length > 0 ? activeForm : null, - id: parsedResult.data.task.id, - status: "pending", - subject, - }); - - return taskStateSnapshotCandidate(state, meta); -} - -function extractTaskUpdateCandidate( - event: ThreadEvent, - meta: SnapshotCandidateMeta, - state: ClaudeTaskTodoState, -): SnapshotCandidate | null { - if (event.type !== "item/completed") return null; - if ( - event.item.type !== "toolCall" || - event.item.tool !== "TaskUpdate" || - event.item.status !== "completed" - ) { - return null; - } - - const parsedArgs = claudeTaskUpdateArgsSchema.safeParse(event.item.arguments); - if (!parsedArgs.success) return null; - - const parsedResult = claudeTaskUpdateOutputSchema.safeParse( - parseMaybeJson(event.item.result), - ); - if (!parsedResult.success || !parsedResult.data.success) return null; - - const update = parsedArgs.data; - if (update.status === "deleted") { - if (!state.tasks.delete(update.taskId)) return null; - return taskStateSnapshotCandidate(state, meta); - } - - const existing = state.tasks.get(update.taskId); - if (!existing) return null; - - const activeForm = - update.activeForm !== undefined - ? trimAndTruncate(update.activeForm) - : existing.activeForm; - const subject = - update.subject !== undefined - ? trimAndTruncate(update.subject) - : existing.subject; - const nextTask: ClaudeTaskTodoItem = { - activeForm: activeForm && activeForm.length > 0 ? activeForm : null, - id: update.taskId, - status: update.status ?? existing.status, - subject, - }; - if (isSameTask(existing, nextTask)) return null; - state.tasks.set(update.taskId, nextTask); - - return taskStateSnapshotCandidate(state, meta); -} - -function extractTaskListCandidate( - event: ThreadEvent, - meta: SnapshotCandidateMeta, - state: ClaudeTaskTodoState, -): SnapshotCandidate | null { - if (event.type !== "item/completed") return null; - if ( - event.item.type !== "toolCall" || - event.item.tool !== "TaskList" || - event.item.status !== "completed" - ) { - return null; - } - - const parsedResult = claudeTaskListOutputSchema.safeParse( - parseMaybeJson(event.item.result), - ); - if (!parsedResult.success) return null; - - state.tasks.clear(); - for (const rawTask of parsedResult.data.tasks) { - const parsedTask = claudeTaskListItemSchema.safeParse(rawTask); - if (!parsedTask.success) continue; - const task = parsedTask.data; - // TaskList is treated as a snapshot of visible tasks; deleted entries are - // explicit tombstones and should not drop valid siblings. - if (task.status === "deleted") continue; - state.tasks.set(task.id, { - activeForm: null, - id: task.id, - status: task.status, - subject: task.subject, - }); - } - - return taskStateSnapshotCandidate(state, meta); -} - -function extractTaskGetCandidate( - event: ThreadEvent, - meta: SnapshotCandidateMeta, - state: ClaudeTaskTodoState, -): SnapshotCandidate | null { - if (event.type !== "item/completed") return null; - if ( - event.item.type !== "toolCall" || - event.item.tool !== "TaskGet" || - event.item.status !== "completed" - ) { - return null; - } - - const parsedArgs = claudeTaskGetArgsSchema.safeParse(event.item.arguments); - if (!parsedArgs.success) return null; - const parsedResult = claudeTaskGetOutputSchema.safeParse( - parseMaybeJson(event.item.result), - ); - if (!parsedResult.success) return null; - - if (parsedResult.data.task === null) { - if (!state.tasks.delete(parsedArgs.data.taskId)) return null; - return taskStateSnapshotCandidate(state, meta); - } - - const task = parsedResult.data.task; - const existing = state.tasks.get(task.id); - const nextTask: ClaudeTaskTodoItem = { - activeForm: existing?.activeForm ?? null, - id: task.id, - status: task.status, - subject: task.subject, - }; - if (existing && isSameTask(existing, nextTask)) return null; - state.tasks.set(task.id, nextTask); - - return taskStateSnapshotCandidate(state, meta); -} - -function extractTaskCandidate( - event: ThreadEvent, - meta: SnapshotCandidateMeta, - state: ClaudeTaskTodoState, -): SnapshotCandidate | null { - return ( - extractTaskCreateCandidate(event, meta, state) ?? - extractTaskUpdateCandidate(event, meta, state) ?? - extractTaskListCandidate(event, meta, state) ?? - extractTaskGetCandidate(event, meta, state) - ); -} - /** - * Walks decoded thread events and emits the latest valid TODO snapshot. - * Treated like `activeThinking`: only meaningful while the thread has an - * active turn. Returns null when the thread is idle/errored/etc., when no - * candidate event was observed, or when every candidate failed to parse. - * - * A grammar v3 `planSteps` item is a complete snapshot the bridge already - * reduced. For events persisted before it existed: TodoWrite carries complete - * legacy snapshots, and the Claude Task tools carry deltas or snapshots, so - * this walks ordered events and reduces TaskCreate/TaskUpdate/TaskList/ - * TaskGet into a current snapshot. + * Walks decoded thread events and emits the latest plan snapshot. Treated + * like `activeThinking`: only meaningful while the thread has an active + * turn. Returns null when the thread is idle/errored/etc. or when no + * snapshot was observed. A later snapshot supersedes an earlier one. */ export function extractThreadTimelinePendingTodos( threadStatus: Thread["status"], @@ -459,18 +85,14 @@ export function extractThreadTimelinePendingTodos( if (threadStatus !== "active") return null; let best: SnapshotCandidate | null = null; - const taskState: ClaudeTaskTodoState = { tasks: new Map() }; for (const { event, meta } of getOrderedThreadEvents(events)) { - const candidate = - extractPlanStepsCandidate(event, meta) ?? - extractTodoWriteCandidate(event, meta) ?? - extractTaskCandidate(event, meta, taskState); - if (!candidate || candidate.items === null) continue; + const candidate = extractPlanStepsCandidate(event, meta); + if (!candidate) continue; if (best === null || candidate.seq > best.seq) { best = candidate; } } - if (best === null || best.items === null) return null; + if (best === null) return null; return { sourceSeq: best.seq, updatedAt: best.createdAt, diff --git a/packages/thread-view/src/tool-activity-projection.ts b/packages/thread-view/src/tool-activity-projection.ts index bec4fb73e1..d6c3886f2e 100644 --- a/packages/thread-view/src/tool-activity-projection.ts +++ b/packages/thread-view/src/tool-activity-projection.ts @@ -107,7 +107,6 @@ interface RunningToolCallExecution extends RunningExecutionBase { kind: "tool-call"; toolName: string | null; toolArgs: JsonObject | null; - statusLabels?: { pending: string; completed: string }; parsedIntents: EventProjectionToolParsedIntent[]; approvalStatus: EventProjectionApprovalLifecycleStatus | null; } @@ -347,9 +346,6 @@ function createRunningExecCall( kind: "tool-call", toolName: incoming.toolName ?? null, toolArgs: incoming.toolArgs ?? null, - ...(incoming.statusLabels - ? { statusLabels: incoming.statusLabels } - : {}), parsedIntents: incoming.parsedIntents ?? [], approvalStatus: incoming.approvalStatus ?? null, }; @@ -391,7 +387,6 @@ interface ToolCallExecutionFieldsTarget { parsedIntents: EventProjectionToolParsedIntent[]; toolArgs: JsonObject | null; toolName: string | null; - statusLabels?: { pending: string; completed: string }; } interface ToolCallExecutionFieldsSource { @@ -400,7 +395,6 @@ interface ToolCallExecutionFieldsSource { status?: EventProjectionToolCallMessage["status"]; toolArgs?: JsonObject | null; toolName?: string | null; - statusLabels?: { pending: string; completed: string }; } interface DelegationExecutionFieldsTarget { @@ -475,8 +469,6 @@ function mergeToolCallExecutionFields( if (incoming.toolArgs && !target.toolArgs) { target.toolArgs = incoming.toolArgs; } - if (incoming.statusLabels && !target.statusLabels) - target.statusLabels = incoming.statusLabels; target.parsedIntents = chooseParsedIntents( target.parsedIntents, incoming.parsedIntents ?? [], @@ -983,7 +975,7 @@ function createExecMessage( return { ...base, kind: "delegation", - toolName: call.toolName ?? "Agent", + toolName: call.toolName ?? "delegation", childRef: call.childRef, background: call.background, subagentType: call.subagentType, @@ -998,7 +990,6 @@ function createExecMessage( kind: "tool-call", toolName: call.toolName ?? "tool", toolArgs: call.toolArgs, - ...(call.statusLabels ? { statusLabels: call.statusLabels } : {}), parsedIntents: call.parsedIntents, approvalStatus: call.approvalStatus, }; diff --git a/packages/thread-view/src/tool-call-parsing.ts b/packages/thread-view/src/tool-call-parsing.ts index cdbd8507b0..a85c9a4bd5 100644 --- a/packages/thread-view/src/tool-call-parsing.ts +++ b/packages/thread-view/src/tool-call-parsing.ts @@ -1,19 +1,8 @@ import type { EventProjectionToolParsedIntent } from "./event-projection-types.js"; +// Shell wrappers are not provider tool names: a `bash -lc ''` wrapper +// is stripped from every provider's commands so the row shows what ran. const SHELL_WRAPPER_NAMES = new Set(["sh", "bash", "zsh"]); -const DELEGATION_TOOL_NAMES = new Set([ - "Agent", - "Task", - "spawnAgent", - "resumeAgent", -]); -// Claude names these built-ins with title case while Pi uses lowercase names. -// Keep this as an explicit alias list instead of normalizing every tool name: -// plugin-contributed tool names are case-sensitive and may not share the -// built-ins' semantics. -const STRUCTURED_READ_TOOL_NAMES = new Set(["Read", "read"]); -const STRUCTURED_SEARCH_TOOL_NAMES = new Set(["Grep", "grep"]); -const STRUCTURED_LIST_TOOL_NAMES = new Set(["Glob", "glob"]); const SHELL_SEGMENT_BREAK_TOKENS = new Set(["&&", "||", "|", ";", "\n"]); @@ -77,37 +66,6 @@ export function extractShellCommandFromString( return unwrapQuotedShellArg(commandArg.trim()); } -export function baseToolName(toolName: string): string { - const segments = toolName.split(":"); - return segments[segments.length - 1] ?? toolName; -} - -export function isStructuredReadToolName(toolName: string): boolean { - return STRUCTURED_READ_TOOL_NAMES.has(baseToolName(toolName)); -} - -export function isStructuredSearchToolName(toolName: string): boolean { - return STRUCTURED_SEARCH_TOOL_NAMES.has(baseToolName(toolName)); -} - -export function isStructuredListToolName(toolName: string): boolean { - return STRUCTURED_LIST_TOOL_NAMES.has(baseToolName(toolName)); -} - -export function isDelegationToolName(toolName: string): boolean { - return DELEGATION_TOOL_NAMES.has(baseToolName(toolName)); -} - -export function stripAgentOutputMetadata(output: string): string { - const lines = output - .split("\n") - .map((line) => line.trimEnd()) - .filter( - (line) => !line.startsWith("agentId:") && !line.startsWith(""), - ); - return lines.join("\n").trim(); -} - // Characters that a backslash may escape inside double quotes, per POSIX shell // semantics. A backslash before any other character is preserved literally. const DOUBLE_QUOTE_ESCAPABLE = new Set(["$", "`", '"', "\\", "\n"]); diff --git a/packages/thread-view/src/tool-call-suppression.ts b/packages/thread-view/src/tool-call-suppression.ts index 5a4989c253..e9228ab360 100644 --- a/packages/thread-view/src/tool-call-suppression.ts +++ b/packages/thread-view/src/tool-call-suppression.ts @@ -1,25 +1,12 @@ -import { claudeTaskToolNameValues } from "@bb/domain"; import type { ThreadEvent } from "@bb/domain"; -const SUPPRESSED_TIMELINE_TOOL_NAMES = new Set([ - ...claudeTaskToolNameValues, - "TodoRead", - "TodoWrite", - "ToolSearch", - // AskUserQuestion is fully represented by its dedicated user-question - // lifecycle row. Keeping the generic tool-call row too produces a confusing - // duplicate ("Running tool: AskUserQuestion …" plus "Waiting for approval" - // alongside the question's own "Waiting for answer" row). - "AskUserQuestion", -]); - /** * A low-value item row the timeline drops: one the bridge marked `suppress` * in its presentation (grammar v3 — the bridge owns its items' presentation; * a planSteps snapshot still feeds the todo banner because that extraction - * reads the events, not the rows), or, for tool calls persisted before - * presentation existed, one of the legacy names above. Failed and - * interrupted items always render. + * reads the events, not the rows). Failed and interrupted items always + * render. Core keeps no list of tool names to hide: an item persisted + * before presentation existed renders. */ export function shouldSuppressLowValueToolCall(decoded: ThreadEvent): boolean { if (decoded.type !== "item/started" && decoded.type !== "item/completed") { @@ -28,13 +15,6 @@ export function shouldSuppressLowValueToolCall(decoded: ThreadEvent): boolean { const item = decoded.item; switch (item.type) { case "toolCall": - if ( - item.presentation?.suppress !== true && - !SUPPRESSED_TIMELINE_TOOL_NAMES.has(item.tool) - ) { - return false; - } - break; case "fileRead": case "search": case "planSteps": @@ -44,10 +24,8 @@ export function shouldSuppressLowValueToolCall(decoded: ThreadEvent): boolean { if (item.presentation?.suppress !== true) { return false; } - break; + return item.status === "pending" || item.status === "completed"; default: return false; } - - return item.status === "pending" || item.status === "completed"; } diff --git a/packages/thread-view/test/background-task-timeline.test.ts b/packages/thread-view/test/background-task-timeline.test.ts index 0cfb844689..b598e32c44 100644 --- a/packages/thread-view/test/background-task-timeline.test.ts +++ b/packages/thread-view/test/background-task-timeline.test.ts @@ -797,10 +797,13 @@ describe("background task timeline projection", () => { { includeNestedRows: false, turnMessageDetail: "summary" }, ); + // The requested model lived in the spawning Agent call's arguments, which + // core no longer reads by tool name; the v3 delegation carries it in its + // presentation detail, so the structured field stays null either way. expect(timeline.activeBackgroundCommands).toMatchObject([ { description: "Inspect the mobile banner", - model: "haiku", + model: null, taskType: "local_agent", }, ]); @@ -847,7 +850,7 @@ describe("background task timeline projection", () => { expect(timeline.activeBackgroundCommands).toMatchObject([ { itemId: "task:agent-restart#2", - model: "haiku", + model: null, status: "pending", taskType: "local_agent", }, @@ -902,7 +905,7 @@ describe("background task timeline projection", () => { expect(timeline.activeBackgroundCommands).toMatchObject([ { itemId: "abc-i9", - model: "haiku", + model: null, status: "pending", taskType: "local_agent", }, @@ -958,7 +961,7 @@ describe("background task timeline projection", () => { (row) => row.itemId === "task:agent-restart#2", ), ).toMatchObject({ - model: "haiku", + model: null, status: "completed", taskType: "local_agent", }); diff --git a/packages/thread-view/test/build-thread-timeline.test.ts b/packages/thread-view/test/build-thread-timeline.test.ts index b5205edd19..76c8a472b4 100644 --- a/packages/thread-view/test/build-thread-timeline.test.ts +++ b/packages/thread-view/test/build-thread-timeline.test.ts @@ -62,13 +62,6 @@ interface ToolCallItemEventArgs { type: "item/completed" | "item/started"; } -interface LowercaseStructuredToolCase { - expectedIntent: JsonObject; - expectedTitle: string; - tool: string; - toolArgs: JsonObject; -} - interface ImageViewItemEventArgs { itemId?: string; path?: string; @@ -966,49 +959,17 @@ describe("buildThreadTimelineFromEvents", () => { expect(collectToolRows(rows)).toHaveLength(1); }); - const lowercaseStructuredToolCases: LowercaseStructuredToolCase[] = [ - { - expectedIntent: { - type: "read", - name: "read", - path: "src/app.ts", - }, - expectedTitle: "Read src/app.ts", - tool: "read", - toolArgs: { path: "src/app.ts", offset: 1, limit: 20 }, - }, - { - expectedIntent: { - type: "search", - query: "TODO", - path: "src", - }, - expectedTitle: "Searched for TODO in src", - tool: "grep", - toolArgs: { pattern: "TODO", path: "src" }, - }, - { - expectedIntent: { - type: "list_files", - path: "src/**/*.ts", - }, - expectedTitle: "Listed files in src/**/*.ts", - tool: "glob", - toolArgs: { pattern: "src/**/*.ts" }, - }, - ]; - - it.each(lowercaseStructuredToolCases)( - "humanizes Pi's lowercase $tool tool calls", - ({ expectedIntent, expectedTitle, tool, toolArgs }) => { + it.each(["read", "grep", "glob", "Read", "Grep", "Glob"])( + "renders a %s tool call as a generic tool row: no tool-name table derives an intent", + (tool) => { + // The provider's bridge emits fileRead/search items for its reads and + // searches (Pi's translation maps read/grep/find/ls; Claude's maps + // Read/Grep/Glob). A bare tool call persisted before that, or from a + // bridge that has not migrated, is a tool row titled by its name. + const toolArgs = { path: "src/app.ts", pattern: "TODO" }; const rows = buildTimelineRows([ turnStartedEvent({ seq: 1 }), - toolCallItemEvent({ - seq: 2, - tool, - toolArgs, - type: "item/started", - }), + toolCallItemEvent({ seq: 2, tool, toolArgs, type: "item/started" }), toolCallItemEvent({ result: "ok", seq: 3, @@ -1018,51 +979,38 @@ describe("buildThreadTimelineFromEvents", () => { }), ]); const [row] = collectToolRows(rows); - expect(row).toBeDefined(); if (!row) { throw new Error(`Expected a projected ${tool} tool row`); } - expect(row.activityIntents).toEqual([ - expect.objectContaining(expectedIntent), - ]); + expect(row.activityIntents).toEqual([]); expect( buildTimelineRowTitle(row, { summaryStyle: "bundle", workStyle: "default", }).plain, - ).toBe(expectedTitle); + ).toBe(`Ran tool ${tool} { path: src/app.ts, pattern: TODO }`); }, ); - it("preserves server-enriched plugin status labels on a tool row", () => { - const statusLabels = { - pending: "Reading project overview", - completed: "Read project overview", - }; + it("drops a statusLabels key a row persisted before the field was deleted", () => { + // Rows enriched by the old server keep decoding; the key is stripped and + // the row titles from its name (the bridge's presentation is the only + // label source now). const rows = buildTimelineRows([ turnStartedEvent({ seq: 1 }), toolCallItemEvent({ - statusLabels, + statusLabels: { pending: "Reading", completed: "Read" }, seq: 2, tool: "repository_context", - type: "item/started", - }), - toolCallItemEvent({ - statusLabels, - seq: 3, - tool: "repository_context", type: "item/completed", }), ]); - - expect(collectToolRows(rows)).toEqual([ - expect.objectContaining({ - statusLabels, - status: "completed", - toolName: "repository_context", - }), - ]); + const [row] = collectToolRows(rows); + expect(row).toEqual( + expect.objectContaining({ status: "completed", toolName: "repository_context" }), + ); + expect(row).not.toHaveProperty("statusLabels"); }); it("extracts the exact active Plan turn id from the accepted input scope", () => { @@ -1271,6 +1219,74 @@ describe("buildThreadTimelineFromEvents", () => { expect(timeline.activePromptMode).toBeNull(); }); + it("makes a persisted call a delegation when rows name it as their parent, whatever its tool name", () => { + const event = createTimelineEventFactory({ + threadId: "thread-1", + turnId: "turn-1", + }); + const parentToolCallId = "call-helper-1"; + const rows = buildTimelineRows( + fromRows([ + event.turnStarted({ seq: 1 }), + event.toolCallStarted({ + seq: 2, + itemId: parentToolCallId, + tool: "spawn_helper", + arguments: { + description: "Audit the docs", + subagent_type: "reviewer", + model: "fast", + }, + }), + event.assistantCompleted({ + seq: 3, + itemId: "helper-progress", + parentToolCallId, + text: "Read 3 files", + }), + event.toolCallCompleted({ + seq: 4, + itemId: parentToolCallId, + tool: "spawn_helper", + result: "done", + }), + ]), + ); + + const [delegation] = collectDelegationRows(rows); + expect(delegation).toMatchObject({ + // The row id is minted under the delegation kind, as a bridge-declared + // delegation would be, so persisted threads keep stable row ids. + id: "thread-1:delegation:call-helper-1", + toolName: "spawn_helper", + description: "Audit the docs", + subagentType: "reviewer", + childRef: null, + background: false, + status: "completed", + }); + expect(delegation?.childRows).toEqual([ + expect.objectContaining({ + kind: "conversation", + role: "assistant", + text: "Read 3 files", + }), + ]); + // A call nothing refers to stays a plain tool row. + expect( + buildTimelineRows( + fromRows([ + event.turnStarted({ seq: 1 }), + event.toolCallCompleted({ + seq: 2, + itemId: "lonely", + tool: "spawn_helper", + }), + ]), + ).some((row) => row.kind === "work" && row.workKind === "delegation"), + ).toBe(false); + }); + it("omits duplicated background-agent lifecycle rows from delegation children", () => { const event = createTimelineEventFactory({ threadId: "thread-1", @@ -2375,97 +2391,34 @@ describe("buildThreadTimelineFromEvents", () => { }, ); - it("suppresses low-value ToolSearch rows", () => { - const rows = buildTimelineRows([ - turnStartedEvent({ seq: 0 }), - toolCallItemEvent({ - seq: 1, - tool: "ToolSearch", - toolArgs: { query: "select:TodoWrite", max_results: 1 }, - type: "item/started", - }), - toolCallItemEvent({ - result: "Matched tools: TodoWrite", - seq: 2, - tool: "ToolSearch", - toolArgs: { query: "select:TodoWrite", max_results: 1 }, - type: "item/completed", - }), - ]); - - expect(collectToolRows(rows)).toEqual([]); - }); - - it.each(["TaskCreate", "TaskGet", "TaskList", "TaskUpdate"])( - "suppresses pending and completed %s rows", + it.each(["ToolSearch", "TaskCreate", "TaskUpdate", "AskUserQuestion"])( + "keeps a bare %s tool row: suppression comes from the bridge's presentation, not a name table", (tool) => { + // The bridges mark these low-value calls `suppress` in their + // presentation (see plugins/provider-claude-code/src/presentation.ts); + // a row persisted without one renders like any other tool call. const rows = buildTimelineRows([ turnStartedEvent({ seq: 0 }), toolCallItemEvent({ seq: 1, tool, - toolArgs: { subject: "Hidden task tool" }, + toolArgs: { subject: "Visible without presentation" }, type: "item/started", }), toolCallItemEvent({ result: "ok", seq: 2, tool, - toolArgs: { subject: "Hidden task tool" }, - type: "item/completed", - }), - ]); - - expect(collectToolRows(rows)).toEqual([]); - }, - ); - - it.each(["TaskCreate", "TaskGet", "TaskList", "TaskUpdate"])( - "keeps failed %s rows visible", - (tool) => { - const rows = buildTimelineRows([ - turnStartedEvent({ seq: 0 }), - toolCallItemEvent({ - result: "Task tool failed", - seq: 1, - status: "failed", - tool, - toolArgs: { subject: "Failed task tool" }, + toolArgs: { subject: "Visible without presentation" }, type: "item/completed", }), ]); - expect(collectToolRows(rows)).toEqual([ - expect.objectContaining({ - output: "Task tool failed", - status: "error", - toolName: tool, - }), + expect.objectContaining({ status: "completed", toolName: tool }), ]); }, ); - it("suppresses the generic AskUserQuestion tool row in favor of the question row", () => { - const rows = buildTimelineRows([ - turnStartedEvent({ seq: 0 }), - toolCallItemEvent({ - seq: 1, - tool: "AskUserQuestion", - toolArgs: { questions: [{ question: "Which path?" }] }, - type: "item/started", - }), - toolCallItemEvent({ - result: "ok", - seq: 2, - tool: "AskUserQuestion", - toolArgs: { questions: [{ question: "Which path?" }] }, - type: "item/completed", - }), - ]); - - expect(collectToolRows(rows)).toEqual([]); - }); - it("extracts context-window usage from ordered events", () => { expect( buildContextWindowUsage([ diff --git a/packages/thread-view/test/timeline-cli-rendering.snapshots.test.ts b/packages/thread-view/test/timeline-cli-rendering.snapshots.test.ts index a470fb9c24..dee4e730f7 100644 --- a/packages/thread-view/test/timeline-cli-rendering.snapshots.test.ts +++ b/packages/thread-view/test/timeline-cli-rendering.snapshots.test.ts @@ -1087,14 +1087,11 @@ describe("timeline CLI rendering snapshots", () => { }); const timeline = renderIdleTimeline([ event.turnStarted(), - event.toolCallCompleted({ + event.delegationCompleted({ itemId: "delegation-1", - tool: "spawnAgent", - arguments: { - prompt: "Review the branch", - receiverThreadIds: ["child-provider"], - }, - result: "Child result", + childRef: "child-provider", + label: "Review the branch", + summary: "Child result", }), event.commandCompleted({ providerThreadId: "child-provider", @@ -1150,38 +1147,28 @@ describe("timeline CLI rendering snapshots", () => { }); const timeline = renderIdleTimeline([ event.turnStarted(), - event.toolCallStarted({ + // A same-provider child (the delegation's child runs in the spawning + // provider thread): the next turn started there is the child's. + event.delegationStarted({ itemId: "delegation-1", - tool: "spawnAgent", - arguments: { - prompt: "Review architecture", - receiverThreadIds: [], - }, + childRef: "root-provider", + label: "Review architecture", }), - event.toolCallCompleted({ + event.delegationCompleted({ itemId: "delegation-1", - tool: "spawnAgent", - arguments: { - prompt: "Review architecture", - receiverThreadIds: ["receiver-1"], - }, + childRef: "root-provider", + label: "Review architecture", }), - event.toolCallStarted({ + event.delegationStarted({ itemId: "delegation-2", - tool: "spawnAgent", - arguments: { - prompt: "Review UI", - receiverThreadIds: [], - }, + childRef: "root-provider", + label: "Review UI", }), event.turnStarted({ turnId: "child-turn-1" }), - event.toolCallCompleted({ + event.delegationCompleted({ itemId: "delegation-2", - tool: "spawnAgent", - arguments: { - prompt: "Review UI", - receiverThreadIds: ["receiver-2"], - }, + childRef: "root-provider", + label: "Review UI", }), event.turnStarted({ turnId: "child-turn-2" }), event.commandCompleted({ @@ -1356,13 +1343,10 @@ describe("timeline CLI rendering snapshots", () => { }); const timeline = renderActiveTimeline([ event.turnStarted(), - event.toolCallStarted({ + event.delegationStarted({ itemId: "delegation-1", - tool: "spawnAgent", - arguments: { - prompt: "Review with a child provider thread", - receiverThreadIds: ["child-provider"], - }, + childRef: "child-provider", + label: "Review with a child provider thread", }), event.commandStarted({ providerThreadId: "child-provider", @@ -1819,13 +1803,10 @@ describe("timeline CLI rendering snapshots", () => { }); const timeline = renderActiveTimeline([ event.turnStarted(), - event.toolCallStarted({ + event.delegationStarted({ itemId: "delegation-1", - tool: "spawnAgent", - arguments: { - prompt: "Keep reviewing", - receiverThreadIds: [], - }, + childRef: "root-provider", + label: "Keep reviewing", }), event.turnStarted({ turnId: "child-turn-1" }), event.commandStarted({ @@ -2013,13 +1994,10 @@ describe("timeline CLI rendering snapshots", () => { }); const timeline = renderActiveTimeline([ event.turnStarted(), - event.toolCallStarted({ + event.delegationStarted({ itemId: "delegation-1", - tool: "spawnAgent", - arguments: { - prompt: "Investigate the timeline", - receiverThreadIds: ["child-provider"], - }, + childRef: "child-provider", + label: "Investigate the timeline", }), event.commandCompleted({ providerThreadId: "child-provider", @@ -2266,7 +2244,11 @@ describe("timeline CLI rendering snapshots", () => { expect(timeline.text).toMatchInlineSnapshot(`""`); }); - it("shows web search, file edit, and assistant output without task updates", () => { + it("projects a persisted codex plan notification as a plan-steps row beside the work", () => { + // `turn/plan/updated` used to be excluded from every window and dropped + // by the projection. It now decodes into a `planSteps` item at read time + // (legacy-thread-events.ts), so an old codex thread shows its plan and + // the todo banner reads it. const event = createTimelineEventFactory({ threadId: "thread-1" }); const timeline = renderActiveTimeline([ event.turnStarted(), @@ -2299,21 +2281,35 @@ describe("timeline CLI rendering snapshots", () => { ]); expect(messageKinds(timeline.messages)).toEqual([ + "plan-steps", "web-search", "file-edit", "assistant-text", ]); - expect(timeline.text).toMatchInlineSnapshot(` - "── Researched 1 search query, edited 1 file ──────────────── - ── Ran web search: React suspense docs - ── Edited /repo/packages/core-ui/src/timeline.ts +1 -1 - @@ -1 +1 @@ - -before - +after - - ── Assistant ─────────────────────────────────────────────── - I patched the projection and verified it." - `); + const planRow = flattenTimelineRows(timeline.rows).find( + (row) => row.kind === "work" && row.workKind === "plan-steps", + ); + expect(planRow).toMatchObject({ + kind: "work", + workKind: "plan-steps", + status: "completed", + steps: [ + { step: "Read the route", status: "completed" }, + { step: "Patch the projection", status: "active" }, + { step: "Run focused tests", status: "pending" }, + ], + }); + expect(planRow).not.toHaveProperty("presentation"); + expect(timeline.pendingTodos?.items.map((item) => item.text)).toEqual([ + "Read the route", + "Patch the projection", + "Run focused tests", + ]); + expect(timeline.text).toContain("Updated plan Patch the projection"); + expect(timeline.text).toContain("Ran web search: React suspense docs"); + expect(timeline.text).toContain( + "Edited /repo/packages/core-ui/src/timeline.ts +1 -1", + ); }); it("summarizes completed web search and fetch rows without expanding result text", () => { diff --git a/packages/thread-view/test/timeline-row-title.test.ts b/packages/thread-view/test/timeline-row-title.test.ts index 81e3ca8edc..fc3e04b17c 100644 --- a/packages/thread-view/test/timeline-row-title.test.ts +++ b/packages/thread-view/test/timeline-row-title.test.ts @@ -500,32 +500,7 @@ describe("buildTimelineRowTitle", () => { ]); }); - it("uses native plugin status labels while preserving the generic fallback", () => { - const completed = buildTimelineRowTitle( - { - ...toolRow(), - statusLabels: { - pending: "Reading project overview", - completed: "Read project overview", - }, - }, - DEFAULT_OPTIONS, - ); - const pending = buildTimelineRowTitle( - { - ...toolRow(), - status: "pending", - completedAt: null, - statusLabels: { - pending: "Reading project overview", - completed: "Read project overview", - }, - }, - DEFAULT_OPTIONS, - ); - - expect(completed.plain).toBe("Read project overview (2s)"); - expect(pending.plain).toBe("Reading project overview"); + it("titles a generic tool row from its name and arguments, not from a label table", () => { expect( buildTimelineRowTitle( { @@ -539,41 +514,6 @@ describe("buildTimelineRowTitle", () => { ).toBe("Ran tool repository_context (2s)"); }); - // The labels deliberately cover only pending and completed. Every other - // state must fall back to the tool's own identity, or a failing plugin tool - // would render as a success sentence and the failure would be unreadable. - it("ignores plugin status labels outside pending and completed", () => { - const statusLabels = { - pending: "Reading project overview", - completed: "Read project overview", - }; - const render = (overrides: Partial>): string => - buildTimelineRowTitle( - { - ...toolRow(), - activityIntents: [], - toolName: "repository_context", - toolArgs: null, - statusLabels, - ...overrides, - }, - DEFAULT_OPTIONS, - ).plain; - - expect(render({ status: "error" })).toContain("repository_context"); - expect(render({ status: "error" })).not.toContain("Read project overview"); - expect(render({ status: "interrupted" })).toContain("repository_context"); - expect(render({ status: "interrupted" })).not.toContain( - "Read project overview", - ); - expect( - render({ status: "pending", approvalStatus: "waiting_for_approval" }), - ).not.toContain("Reading project overview"); - expect( - render({ status: "pending", approvalStatus: "denied" }), - ).not.toContain("Reading project overview"); - }); - it("can render completed work leaves with muted summary title treatment", () => { const title = buildTimelineRowTitle(commandRow(), { summaryStyle: "background", diff --git a/packages/thread-view/test/todo-snapshot-extraction.test.ts b/packages/thread-view/test/todo-snapshot-extraction.test.ts index 94204f4330..e8d701066b 100644 --- a/packages/thread-view/test/todo-snapshot-extraction.test.ts +++ b/packages/thread-view/test/todo-snapshot-extraction.test.ts @@ -1,128 +1,25 @@ import { jsonObjectSchema, turnScope } from "@bb/domain"; -import type { - ClaudeTaskToolName, - Thread, - ThreadEventItemStatus, - ThreadEventPlanStep, -} from "@bb/domain"; +import type { Thread, ThreadEventPlanStep } from "@bb/domain"; import { describe, expect, it } from "vitest"; -import { - extractThreadTimelinePendingTodos, - parseTodoWriteTodos, -} from "../src/todo-snapshot-extraction.js"; +import { extractThreadTimelinePendingTodos } from "../src/todo-snapshot-extraction.js"; import type { ThreadEventWithMeta } from "../src/build-event-projection.js"; const ACTIVE: Thread["status"] = "active"; -interface TodoWriteEventArgs { - itemId?: string; - seq: number; - status?: ThreadEventItemStatus; - todos: unknown; - type?: "item/started" | "item/completed"; -} - -interface TurnPlanEventArgs { - plan: ThreadEventPlanStep[]; - seq: number; -} - -interface TaskToolEventArgs { - args?: Record; - itemId?: string; - result?: unknown; - seq: number; - status?: ThreadEventItemStatus; - tool: ClaudeTaskToolName; -} - -function todoWriteEvent({ - itemId = "tool-call-1", - seq, - status, - todos, - type = "item/completed", -}: TodoWriteEventArgs): ThreadEventWithMeta { - return { - event: { - type, - threadId: "thread-1", - providerThreadId: "provider-thread-1", - scope: turnScope("turn-1"), - item: { - type: "toolCall", - id: itemId, - tool: "TodoWrite", - arguments: jsonObjectSchema.parse({ todos }), - status: status ?? (type === "item/completed" ? "completed" : "pending"), - }, - }, - meta: { - id: `event-${seq}`, - seq, - createdAt: seq, - }, - }; -} - -function taskToolEvent({ - args = {}, - itemId = "task-tool-call-1", - result, - seq, - status = "completed", - tool, -}: TaskToolEventArgs): ThreadEventWithMeta { - return { - event: { - type: "item/completed", - threadId: "thread-1", - providerThreadId: "provider-thread-1", - scope: turnScope("turn-1"), - item: { - type: "toolCall", - id: itemId, - tool, - arguments: jsonObjectSchema.parse(args), - status, - ...(result !== undefined ? { result } : {}), - }, - }, - meta: { - id: `event-${seq}`, - seq, - createdAt: seq, - }, - }; -} - -function turnPlanEvent({ plan, seq }: TurnPlanEventArgs): ThreadEventWithMeta { - return { - event: { - type: "turn/plan/updated", - threadId: "thread-1", - providerThreadId: "provider-thread-1", - scope: turnScope("turn-1"), - plan, - }, - meta: { - id: `event-${seq}`, - seq, - createdAt: seq, - }, - }; -} - function planStepsEvent({ steps, seq, + type = "item/completed", + explanation, }: { steps: ThreadEventPlanStep[]; seq: number; + type?: "item/started" | "item/completed"; + explanation?: string; }): ThreadEventWithMeta { return { event: { - type: "item/completed", + type, threadId: "thread-1", providerThreadId: "provider-thread-1", scope: turnScope("turn-1"), @@ -130,14 +27,16 @@ function planStepsEvent({ type: "planSteps", id: `plan-${seq}`, steps, - status: "completed", + ...(explanation === undefined ? {} : { explanation }), + status: type === "item/completed" ? "completed" : "pending", }, }, meta: { id: `event-${seq}`, seq, createdAt: seq }, }; } -function nonTodoToolCallEvent(seq: number): ThreadEventWithMeta { +/** A tool call a legacy bridge named `TodoWrite`: its name decides nothing. */ +function legacyTodoWriteToolCallEvent(seq: number): ThreadEventWithMeta { return { event: { type: "item/completed", @@ -146,9 +45,11 @@ function nonTodoToolCallEvent(seq: number): ThreadEventWithMeta { scope: turnScope("turn-1"), item: { type: "toolCall", - id: "tool-call-other", - tool: "Read", - arguments: { path: "README.md" }, + id: `todo-write-${seq}`, + tool: "TodoWrite", + arguments: jsonObjectSchema.parse({ + todos: [{ content: "Legacy todo", status: "pending" }], + }), status: "completed", }, }, @@ -156,12 +57,19 @@ function nonTodoToolCallEvent(seq: number): ThreadEventWithMeta { }; } +/** + * The todo banner reads grammar v3 `planSteps` snapshots and nothing else: + * the bridge reduces TodoWrite / the Claude Task tools / codex `update_plan` + * / the ACP plan into one complete snapshot per update, and persisted codex + * `turn/plan/updated` notifications decode into the same item at read time. + * Core keeps no table of tool names that used to carry a plan. + */ describe("extractThreadTimelinePendingTodos", () => { - it("reads a grammar v3 planSteps snapshot as the banner, latest snapshot winning", () => { + it("reads the latest planSteps snapshot, mapping step statuses to banner statuses", () => { const result = extractThreadTimelinePendingTodos(ACTIVE, [ - todoWriteEvent({ + planStepsEvent({ seq: 1, - todos: [{ content: "Legacy todo", status: "pending" }], + steps: [{ step: "Older snapshot", status: "active" }], }), planStepsEvent({ seq: 2, @@ -171,6 +79,7 @@ describe("extractThreadTimelinePendingTodos", () => { { step: "Run the tests", status: "pending" }, { step: "Flaky step", status: "failed" }, { step: " ", status: "pending" }, + { step: "No status" }, ], }), ]); @@ -182,623 +91,59 @@ describe("extractThreadTimelinePendingTodos", () => { { id: "seq:2:1", text: "Writing the code", status: "in_progress" }, { id: "seq:2:2", text: "Run the tests", status: "pending" }, { id: "seq:2:3", text: "Flaky step", status: "completed" }, + { id: "seq:2:5", text: "No status", status: "pending" }, ], }); }); - it("returns null when no TodoWrite or task events are observed", () => { - expect(extractThreadTimelinePendingTodos(ACTIVE, [])).toBeNull(); - expect( - extractThreadTimelinePendingTodos(ACTIVE, [nonTodoToolCallEvent(1)]), - ).toBeNull(); - }); - - it("returns the latest TodoWrite snapshot when only TodoWrite events exist", () => { + it("picks the newest snapshot by sequence even when the input is unordered", () => { const result = extractThreadTimelinePendingTodos(ACTIVE, [ - todoWriteEvent({ - seq: 10, - todos: [ - { content: "Old item", status: "pending" }, - { content: "Old doing", status: "in_progress" }, - ], - }), - todoWriteEvent({ - seq: 20, - todos: [ - { content: "New doing", status: "in_progress" }, - { content: "New pending", status: "pending" }, - ], - }), + planStepsEvent({ seq: 30, steps: [{ step: "third", status: "active" }] }), + planStepsEvent({ seq: 10, steps: [{ step: "first", status: "active" }] }), + planStepsEvent({ seq: 20, steps: [{ step: "second", status: "active" }] }), ]); - expect(result).toEqual({ - sourceSeq: 20, - updatedAt: 20, - items: [ - { id: "seq:20:0", text: "New doing", status: "in_progress" }, - { id: "seq:20:1", text: "New pending", status: "pending" }, - ], - }); - }); - - it("uses TodoWrite activeForm for in-progress items", () => { - const result = extractThreadTimelinePendingTodos(ACTIVE, [ - todoWriteEvent({ - seq: 10, - todos: [ - { - content: "Run the test suite", - status: "in_progress", - activeForm: "Running the test suite", - }, - { - content: "Update docs", - status: "pending", - activeForm: "Updating docs", - }, - { - content: "Ship fix", - status: "completed", - activeForm: "Shipping fix", - }, - ], - }), - ]); - expect(result).toMatchObject({ - sourceSeq: 10, - items: [ - { text: "Running the test suite", status: "in_progress" }, - { text: "Update docs", status: "pending" }, - { text: "Ship fix", status: "completed" }, - ], - }); - }); - - it("reduces Claude TaskCreate and TaskUpdate events into pending todos", () => { - const result = extractThreadTimelinePendingTodos(ACTIVE, [ - taskToolEvent({ - seq: 10, - tool: "TaskCreate", - args: { - subject: "Add parser", - activeForm: "Adding parser", - }, - result: { task: { id: "task-1", subject: "Add parser" } }, - }), - taskToolEvent({ - seq: 20, - tool: "TaskCreate", - args: { - subject: "Add tests", - activeForm: "Adding tests", - }, - result: { task: { id: "task-2", subject: "Add tests" } }, - }), - taskToolEvent({ - seq: 30, - tool: "TaskUpdate", - args: { - taskId: "task-1", - status: "in_progress", - }, - result: { success: true, taskId: "task-1", updatedFields: ["status"] }, - }), - taskToolEvent({ - seq: 40, - tool: "TaskUpdate", - args: { - taskId: "task-2", - status: "completed", - }, - result: { success: true, taskId: "task-2", updatedFields: ["status"] }, - }), - ]); - - expect(result).toEqual({ - sourceSeq: 40, - updatedAt: 40, - items: [ - { id: "task:task-1", text: "Adding parser", status: "in_progress" }, - { id: "task:task-2", text: "Add tests", status: "completed" }, - ], - }); + expect(result?.sourceSeq).toBe(30); + expect(result?.items.map((item) => item.text)).toEqual(["third"]); }); - it("deletes Claude Task items when TaskUpdate status is deleted", () => { - const result = extractThreadTimelinePendingTodos(ACTIVE, [ - taskToolEvent({ - seq: 10, - tool: "TaskCreate", - args: { - subject: "Keep", - }, - result: { task: { id: "task-keep", subject: "Keep" } }, - }), - taskToolEvent({ - seq: 20, - tool: "TaskCreate", - args: { - subject: "Remove", - }, - result: { task: { id: "task-remove", subject: "Remove" } }, - }), - taskToolEvent({ - seq: 30, - tool: "TaskUpdate", - args: { - taskId: "task-remove", - status: "deleted", - }, - result: { success: true, taskId: "task-remove" }, - }), - ]); - - expect(result).toEqual({ - sourceSeq: 30, - updatedAt: 30, - items: [{ id: "task:task-keep", text: "Keep", status: "pending" }], - }); - }); - - it("replaces Claude Task state from a TaskList result", () => { - const result = extractThreadTimelinePendingTodos(ACTIVE, [ - taskToolEvent({ - seq: 10, - tool: "TaskCreate", - args: { - subject: "Stale task", - }, - result: { task: { id: "task-stale", subject: "Stale task" } }, - }), - taskToolEvent({ - seq: 20, - tool: "TaskList", - result: { - tasks: [ - { - id: "task-current", - subject: "Current task", - status: "in_progress", - blockedBy: [], - }, - ], - }, - }), - ]); - - expect(result).toEqual({ - sourceSeq: 20, - updatedAt: 20, - items: [ - { - id: "task:task-current", - text: "Current task", - status: "in_progress", - }, - ], - }); - }); - - it("filters invalid and deleted TaskList items without dropping valid siblings", () => { - const result = extractThreadTimelinePendingTodos(ACTIVE, [ - taskToolEvent({ - seq: 20, - tool: "TaskList", - result: { - tasks: [ - { - id: "task-valid", - subject: "Valid task", - status: "pending", - blockedBy: [], - }, - { - id: "task-unknown-status", - subject: "Unknown status", - status: "blocked", - blockedBy: [], - }, - { - id: "task-deleted", - subject: "Deleted task", - status: "deleted", - blockedBy: [], - }, - ], - }, - }), - ]); - - expect(result).toEqual({ - sourceSeq: 20, - updatedAt: 20, - items: [{ id: "task:task-valid", text: "Valid task", status: "pending" }], - }); - }); - - it("uses TaskGet to upsert, refresh, and remove known task state", () => { - const result = extractThreadTimelinePendingTodos(ACTIVE, [ - taskToolEvent({ - seq: 10, - tool: "TaskGet", - args: { taskId: "task-1" }, - result: { - task: { - id: "task-1", - subject: "Loaded task", - status: "pending", - }, - }, - }), - taskToolEvent({ - seq: 20, - tool: "TaskGet", - args: { taskId: "task-1" }, - result: { - task: { - id: "task-1", - subject: "Loaded task updated", - status: "in_progress", - }, - }, - }), - taskToolEvent({ - seq: 30, - tool: "TaskGet", - args: { taskId: "task-1" }, - result: { task: null }, - }), - ]); - - expect(result).toEqual({ - sourceSeq: 30, - updatedAt: 30, - items: [], - }); - }); - - it("ignores TaskGet null results for unknown ids", () => { - const result = extractThreadTimelinePendingTodos(ACTIVE, [ - todoWriteEvent({ - seq: 10, - todos: [{ content: "Keep todo", status: "pending" }], - }), - taskToolEvent({ - seq: 20, - tool: "TaskGet", - args: { taskId: "unknown-task" }, - result: { task: null }, - }), - ]); - - expect(result).toEqual({ - sourceSeq: 10, - updatedAt: 10, - items: [{ id: "seq:10:0", text: "Keep todo", status: "pending" }], - }); - }); - - it("does not let unknown TaskUpdate deletes displace earlier snapshots", () => { - const result = extractThreadTimelinePendingTodos(ACTIVE, [ - todoWriteEvent({ - seq: 10, - todos: [{ content: "Keep todo", status: "pending" }], - }), - taskToolEvent({ - seq: 20, - tool: "TaskUpdate", - args: { - taskId: "missing-task", - status: "deleted", - }, - result: { success: true, taskId: "missing-task" }, - }), - ]); - - expect(result).toEqual({ - sourceSeq: 10, - updatedAt: 10, - items: [{ id: "seq:10:0", text: "Keep todo", status: "pending" }], - }); - }); - - it("does not synthesize unknown TaskUpdate ids into tasks", () => { - const result = extractThreadTimelinePendingTodos(ACTIVE, [ - todoWriteEvent({ - seq: 10, - todos: [{ content: "Keep todo", status: "pending" }], - }), - taskToolEvent({ - seq: 20, - tool: "TaskUpdate", - args: { - taskId: "missing-task", - subject: "Phantom task", - activeForm: "Creating phantom task", - status: "in_progress", - }, - result: { success: true, taskId: "missing-task" }, - }), - ]); - - expect(result).toEqual({ - sourceSeq: 10, - updatedAt: 10, - items: [{ id: "seq:10:0", text: "Keep todo", status: "pending" }], - }); - }); - - it("reduces Task tool events in sequence order even when input is unordered", () => { - const result = extractThreadTimelinePendingTodos(ACTIVE, [ - taskToolEvent({ - seq: 30, - tool: "TaskUpdate", - args: { - taskId: "task-1", - status: "in_progress", - }, - result: { success: true, taskId: "task-1" }, - }), - taskToolEvent({ - seq: 10, - tool: "TaskCreate", - args: { - subject: "Ordered task", - activeForm: "Ordering task", - }, - result: { task: { id: "task-1", subject: "Ordered task" } }, - }), - ]); - - expect(result).toEqual({ - sourceSeq: 30, - updatedAt: 30, - items: [ - { id: "task:task-1", text: "Ordering task", status: "in_progress" }, - ], - }); - }); - - it("parses stringified Claude Task tool results from older persisted events", () => { - const result = extractThreadTimelinePendingTodos(ACTIVE, [ - taskToolEvent({ - seq: 10, - tool: "TaskCreate", - args: { - subject: "Persisted task", - }, - result: JSON.stringify({ - task: { id: "task-string", subject: "Persisted task" }, + it("ignores an opened (pending) snapshot and an empty one clears the banner", () => { + expect( + extractThreadTimelinePendingTodos(ACTIVE, [ + planStepsEvent({ + seq: 5, + type: "item/started", + steps: [{ step: "not settled", status: "active" }], }), - }), - ]); - - expect(result).toEqual({ - sourceSeq: 10, - updatedAt: 10, - items: [ - { id: "task:task-string", text: "Persisted task", status: "pending" }, - ], - }); - }); - - it("ignores turn/plan/updated snapshots instead of treating them as todos", () => { - const result = extractThreadTimelinePendingTodos(ACTIVE, [ - turnPlanEvent({ - seq: 40, - plan: [{ step: "structured plan", status: "active" }], - }), - ]); - expect(result).toBeNull(); - }); - - it("keeps TodoWrite snapshots even when a newer plan snapshot exists", () => { - const todoOlder = todoWriteEvent({ - seq: 30, - todos: [{ content: "todo first", status: "pending" }], - }); - const planNewer = turnPlanEvent({ - seq: 40, - plan: [{ step: "plan won", status: "active" }], - }); + ]), + ).toBeNull(); expect( - extractThreadTimelinePendingTodos(ACTIVE, [todoOlder, planNewer]), - ).toMatchObject({ - sourceSeq: 30, - items: [{ text: "todo first", status: "pending" }], - }); - }); - - it("prefers item/completed over an earlier item/started for the same TodoWrite call", () => { - const result = extractThreadTimelinePendingTodos(ACTIVE, [ - todoWriteEvent({ - seq: 7, - type: "item/started", - todos: [{ content: "started form", status: "pending" }], - }), - todoWriteEvent({ - seq: 8, - type: "item/completed", - todos: [{ content: "completed form", status: "in_progress" }], - }), - ]); - expect(result).toMatchObject({ - sourceSeq: 8, - items: [{ text: "completed form", status: "in_progress" }], - }); - }); - - it("falls through unparseable candidates to the newest valid snapshot", () => { - const result = extractThreadTimelinePendingTodos(ACTIVE, [ - todoWriteEvent({ - seq: 100, - todos: [{ content: "valid older", status: "pending" }], - }), - todoWriteEvent({ - seq: 200, - todos: "this is not an array", - }), - ]); - expect(result).toMatchObject({ - sourceSeq: 100, - items: [{ text: "valid older", status: "pending" }], - }); - }); - - it("returns null when every candidate fails to parse", () => { - const result = extractThreadTimelinePendingTodos(ACTIVE, [ - todoWriteEvent({ seq: 50, todos: "garbage" }), - todoWriteEvent({ seq: 60, todos: { not: "todos" } }), - ]); - expect(result).toBeNull(); - }); - - it("emits an empty snapshot for a parsed-empty candidate and prevents an older snapshot from resurfacing", () => { - const result = extractThreadTimelinePendingTodos(ACTIVE, [ - todoWriteEvent({ - seq: 5, - todos: [{ content: "stale work", status: "pending" }], - }), - todoWriteEvent({ seq: 25, todos: [] }), - ]); - expect(result).toEqual({ - sourceSeq: 25, - updatedAt: 25, - items: [], - }); - }); - - it("ignores tool calls that are not TodoWrite", () => { - const result = extractThreadTimelinePendingTodos(ACTIVE, [ - nonTodoToolCallEvent(1), - nonTodoToolCallEvent(2), - ]); - expect(result).toBeNull(); + extractThreadTimelinePendingTodos(ACTIVE, [ + planStepsEvent({ seq: 6, steps: [{ step: "old", status: "active" }] }), + planStepsEvent({ seq: 7, steps: [] }), + ]), + ).toEqual({ sourceSeq: 7, updatedAt: 7, items: [] }); }); - it("observes TodoWrite events even though they are suppressed from rendered timeline rows", () => { - // tool-call-suppression hides TodoWrite rows from the rendered timeline, - // but extraction walks the raw event stream and must still pick them up. + it("does not read a tool call by its name, whatever its arguments carry", () => { + expect( + extractThreadTimelinePendingTodos(ACTIVE, [ + legacyTodoWriteToolCallEvent(41), + ]), + ).toBeNull(); + // A snapshot item wins regardless of the name-carrying call beside it. const result = extractThreadTimelinePendingTodos(ACTIVE, [ - todoWriteEvent({ - seq: 11, - status: "completed", - todos: [{ content: "still seen", status: "in_progress" }], - }), + planStepsEvent({ seq: 42, steps: [{ step: "planned", status: "active" }] }), + legacyTodoWriteToolCallEvent(43), ]); - expect(result).toMatchObject({ - sourceSeq: 11, - items: [{ text: "still seen", status: "in_progress" }], - }); + expect(result?.sourceSeq).toBe(42); }); - it.each(["idle", "starting", "stopping", "error"])( - "returns null when the thread status is %s, even with valid TodoWrite snapshots", - (status) => { - const result = extractThreadTimelinePendingTodos(status, [ - todoWriteEvent({ - seq: 1, - todos: [{ content: "stale doing", status: "in_progress" }], - }), - ]); - expect(result).toBeNull(); - }, - ); - - it("keeps the snapshot when the turn is active and every item is completed", () => { - const result = extractThreadTimelinePendingTodos(ACTIVE, [ - todoWriteEvent({ - seq: 42, - todos: [ - { content: "first", status: "completed" }, - { content: "second", status: "completed" }, - ], - }), - ]); - expect(result).toEqual({ - sourceSeq: 42, - updatedAt: 42, - items: [ - { id: "seq:42:0", text: "first", status: "completed" }, - { id: "seq:42:1", text: "second", status: "completed" }, - ], - }); - }); -}); - -describe("parseTodoWriteTodos", () => { - it("returns null for non-record input", () => { - expect(parseTodoWriteTodos(null)).toBeNull(); - expect(parseTodoWriteTodos("string")).toBeNull(); - expect(parseTodoWriteTodos(undefined)).toBeNull(); - }); - - it("returns null only when the top-level todos array is missing or wrong shape", () => { - expect(parseTodoWriteTodos({})).toBeNull(); - expect(parseTodoWriteTodos({ todos: "not-an-array" })).toBeNull(); - }); - - it("drops items missing required fields rather than rejecting the whole payload", () => { - const result = parseTodoWriteTodos({ - todos: [ - { status: "pending" }, // missing content - { content: "kept", status: "pending" }, - ], - }); - expect(result).toEqual({ - todos: [{ content: "kept", status: "pending" }], - }); - }); - - it("drops items with unknown status values and keeps the rest", () => { - const result = parseTodoWriteTodos({ - todos: [ - { content: "kept", status: "pending" }, - { content: "dropped", status: "cancelled" }, // future provider drift - ], - }); - expect(result).toEqual({ - todos: [{ content: "kept", status: "pending" }], - }); - }); - - it("trims and drops empty content", () => { - const result = parseTodoWriteTodos({ - todos: [ - { content: " kept ", status: "pending" }, - { content: " ", status: "in_progress" }, - ], - }); - expect(result).toEqual({ - todos: [{ content: "kept", status: "pending" }], - }); - }); - - it("truncates content past the max length", () => { - const long = "a".repeat(300); - const parsed = parseTodoWriteTodos({ - todos: [{ content: long, status: "pending" }], - }); - expect(parsed?.todos[0]?.content.length).toBe(240); - }); - - it("keeps activeForm when present", () => { - const result = parseTodoWriteTodos({ - todos: [ - { - content: "Update docs", - status: "in_progress", - activeForm: "Updating docs", - }, - ], - }); - expect(result).toEqual({ - todos: [ - { - activeForm: "Updating docs", - content: "Update docs", - status: "in_progress", - }, - ], - }); + it("returns null unless the thread is active", () => { + const events = [ + planStepsEvent({ seq: 1, steps: [{ step: "x", status: "active" }] }), + ]; + expect(extractThreadTimelinePendingTodos("idle", events)).toBeNull(); + expect(extractThreadTimelinePendingTodos("error", events)).toBeNull(); + expect(extractThreadTimelinePendingTodos(ACTIVE, events)).not.toBeNull(); }); }); diff --git a/packages/thread-view/test/v3-item-projection.test.ts b/packages/thread-view/test/v3-item-projection.test.ts index 2461c10def..d25671ef19 100644 --- a/packages/thread-view/test/v3-item-projection.test.ts +++ b/packages/thread-view/test/v3-item-projection.test.ts @@ -106,7 +106,7 @@ const SUBAGENT_PRESENTATION: ThreadEventItemPresentation = { * compact rendering treat a v3 read and a legacy `Read` identically. */ describe("v3 item projection", () => { - it("projects fileRead and search items to file-read and search rows with the legacy intents", () => { + it("projects fileRead and search items to file-read and search rows; bare tool calls derive no intent", () => { const event = createTimelineEventFactory({ threadId: "thread-1" }); const v3 = renderTimelineFixture({ events: [ @@ -231,9 +231,11 @@ describe("v3 item projection", () => { const glob = workRow(v3.rows, "search", "glob-1"); expect(glob).not.toHaveProperty("presentation"); - // The derived intents equal the legacy Read/Grep/Glob intents except for - // the `command`/`name` fields, which name the tool a v3 item does not have. - const strip = (rows: TimelineWorkRow[]) => + // The v3 rows derive the read/search/list intents the exploration + // bundles read; the same calls persisted as bare `Read`/`Grep`/`Glob` + // tool calls derive none — core keeps no tool-name table — and render + // as generic tool rows. + const intents = (rows: TimelineWorkRow[]) => rows.flatMap((row) => row.workKind === "tool" || row.workKind === "file-read" || @@ -244,8 +246,17 @@ describe("v3 item projection", () => { ) : [], ); - expect(strip(workRows(v3.rows))).toEqual(strip(workRows(legacy.rows))); - expect(strip(workRows(v3.rows))).toHaveLength(3); + expect(intents(workRows(v3.rows))).toEqual([ + { type: "read", name: "", path: "src/index.ts" }, + { type: "search", query: "TODO", path: "src" }, + { type: "list_files", path: "src" }, + ]); + expect(intents(workRows(legacy.rows))).toEqual([]); + expect( + workRows(legacy.rows).map((row) => + row.workKind === "tool" ? row.toolName : row.workKind, + ), + ).toEqual(["Read", "Grep", "Glob"]); // Row titles: the bridge label leads; the structured content follows. expect(plainTitle(read)).toBe("Read file src/index.ts"); diff --git a/scripts/provider-corpus/classify-row-diff.ts b/scripts/provider-corpus/classify-row-diff.ts new file mode 100644 index 0000000000..b56f0e97d1 --- /dev/null +++ b/scripts/provider-corpus/classify-row-diff.ts @@ -0,0 +1,80 @@ +#!/usr/bin/env -S pnpm exec tsx +/** + * Classify the row-level differences between two corpus row-snapshot dirs + * offline, without re-projecting the corpus (seconds, not minutes): + * + * pnpm exec tsx scripts/provider-corpus/classify-row-diff.ts \ + * \ + * --classes apps/server/test/provider-corpus/allowlists/-row-classes.json [--verbose] + * + * Mint the candidate with a write-mode run into a shadow dir + * (BB_PROVIDER_CORPUS_SNAPSHOT_DIR); the baseline is the main-minted + * snapshots/rows. The gate itself runs the same engine live when + * BB_PROVIDER_CORPUS_ROW_CLASSES is set; see docs/debugging-and-qa.md, + * "Provider corpus". + */ +import fs from "node:fs"; +import path from "node:path"; +import { + classifyRowSnapshotDiff, + createRowDiffReport, + formatRowDiffReport, + readRowDiffClasses, + type RowDiffClass, + type RowSnapshotVariants, +} from "../../apps/server/test/provider-corpus/row-diff-classes.js"; + +const args = process.argv.slice(2); +const classesIndex = args.indexOf("--classes"); +const classesPath = classesIndex === -1 ? undefined : args[classesIndex + 1]; +const verbose = args.includes("--verbose"); +const positional = args.filter( + (arg, index) => + !arg.startsWith("--") && !(index > 0 && args[index - 1] === "--classes"), +); +if (positional.length !== 2) { + console.error( + "usage: classify-row-diff.ts [--classes ] [--verbose]", + ); + process.exit(2); +} +const [baselineDir, candidateDir] = positional.map((p) => path.resolve(p)) as [ + string, + string, +]; +const classes: RowDiffClass[] = classesPath ? readRowDiffClasses(classesPath) : []; +const report = createRowDiffReport(); + +let threads = 0; +let threadsWithChanges = 0; +for (const provider of fs.readdirSync(baselineDir)) { + const providerDir = path.join(baselineDir, provider); + if (!fs.statSync(providerDir).isDirectory()) continue; + for (const file of fs.readdirSync(providerDir)) { + const candidateFile = path.join(candidateDir, provider, file); + if (!fs.existsSync(candidateFile)) continue; + threads += 1; + const before = JSON.parse( + fs.readFileSync(path.join(providerDir, file), "utf8"), + ) as RowSnapshotVariants; + const after = JSON.parse( + fs.readFileSync(candidateFile, "utf8"), + ) as RowSnapshotVariants; + const thread = `${provider}/${file.replace(/\.json$/u, "")}`; + if (classifyRowSnapshotDiff(thread, before, after, classes, report) > 0) { + threadsWithChanges += 1; + } + } +} + +console.log(`threads compared: ${threads}; with changes: ${threadsWithChanges}`); +console.log(formatRowDiffReport(classes, report, { examples: verbose })); +if (report.unclassified.length > 0) { + if (verbose) { + for (const change of report.unclassified.slice(0, 10)) { + console.log(JSON.stringify(change).slice(0, 600)); + } + } + process.exit(1); +} +console.log("\nevery change is classified."); diff --git a/scripts/provider-corpus/snapshot-rows.sh b/scripts/provider-corpus/snapshot-rows.sh index 1a7c25a6e4..7ea5bb869f 100755 --- a/scripts/provider-corpus/snapshot-rows.sh +++ b/scripts/provider-corpus/snapshot-rows.sh @@ -16,6 +16,9 @@ # scripts/provider-corpus/snapshot-rows.sh compare # BB_PROVIDER_CORPUS_SNAPSHOT_DIR=$HOME/.bb/provider-corpus/snapshots/rows. \ # scripts/provider-corpus/snapshot-rows.sh write # shadow snapshot +# BB_PROVIDER_CORPUS_ROW_CLASSES=apps/server/test/provider-corpus/allowlists/-row-classes.json \ +# scripts/provider-corpus/snapshot-rows.sh compare # identity-based classes +# # (rows added/removed/moved) set -euo pipefail mode="${1:-compare}" diff --git a/turbo.json b/turbo.json index 803e8b631d..69e95ed3ae 100644 --- a/turbo.json +++ b/turbo.json @@ -442,7 +442,8 @@ "BB_PROVIDER_CORPUS_DIR", "BB_PROVIDER_CORPUS_SNAPSHOT", "BB_PROVIDER_CORPUS_SNAPSHOT_DIR", - "BB_PROVIDER_CORPUS_ALLOWLIST" + "BB_PROVIDER_CORPUS_ALLOWLIST", + "BB_PROVIDER_CORPUS_ROW_CLASSES" ] }, // Builds real plugin host artifacts (the builtin artifacts suite, plus the