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