From efee937565fdbd943ef59db5cdd76d7e0b7c2ee2 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 21 Aug 2026 15:42:55 +0000 Subject: [PATCH] Project grammar v3 items to presentation-driven rows (WS3 layer 1) The bridges now persist a presentation on every item and emit fileRead, search, delegation, planSteps and extension items, but the projection rendered from the legacy path: fileRead/search were bridged onto tool rows, planSteps only fed the todo banner, extension items were dropped, and no row carried the presentation. - TimelineRow: every work row projected from a provider item carries the persisted `presentation`; new `file-read`, `search`, `plan-steps` and `extension` work rows; delegation rows carry `childRef` and `background`. - thread-view: the begin/end item family (web-activity-lifecycle) covers the four new kinds with their own terminal status; delegation items take `item/delegation/progress`; suppression honours `presentation.suppress` on every kind (failed items always render); titles use the bridge label first and fall back to the legacy derivation for pre-presentation rows; exploration bundles, dedupe and counts treat a v3 read/search like a legacy Read/Grep through `timelineRowActivityIntents`. - server/db: delegated-child fetch and parent closure cover v3 delegation items (children were orphaned when the parent fell outside the window); the todo head-state backfill includes the latest planSteps snapshot by kind. - client-core/app/mobile: render signatures and exhaustive switches know the new kinds (title-only until the renderer layers land). - Corpus harness: a PR carries its allowlist in the repo (BB_PROVIDER_CORPUS_ALLOWLIST) and snapshots into a shadow dir (BB_PROVIDER_CORPUS_SNAPSHOT_DIR); the shared baseline stays main-minted. Corpus A4 vs a baseline minted from origin/main: one diff class, legacy delegation rows gain `childRef: null` / `background: false`. Parity A2: 39 passed, 0 failed, 13 skipped; 29 entries for the presentation field, the delegation fields and the file-read rows. --- .../thread/timeline/TimelineRowDetails.tsx | 6 + .../timeline/rows/Delegation.stories.tsx | 8 + .../src/test/fixtures/thread-timeline-rows.ts | 2 + .../src/screens/dev/work-row-fixtures.ts | 2 + .../timeline/renderers/work/work-row-model.ts | 15 +- .../src/screens/thread/timeline/rows.ts | 8 + .../screens/thread/timeline/test-fixtures.ts | 2 + apps/server/src/services/threads/timeline.ts | 29 +- .../test/provider-corpus/allowlists/README.md | 18 + .../allowlists/ws3-projection-rows.json | 14 + .../test/provider-corpus/corpus-harness.ts | 57 +- .../provider-corpus/row-snapshots.test.ts | 9 +- docs/debugging-and-qa.md | 14 +- .../src/timeline/timeline-auto-expand.ts | 10 + .../src/timeline/timelineRowSignatures.ts | 69 ++- packages/db/src/data/events.ts | 57 +- packages/db/src/data/index.ts | 2 +- .../recordings/parity-allowlist.json | 232 +++++++++ .../server-contract/src/thread-timeline.ts | 130 ++++- .../src/background-task-projection.ts | 3 + .../thread-view/src/build-event-projection.ts | 4 + .../thread-view/src/build-thread-timeline.ts | 79 +++ .../src/event-projection-message.ts | 98 +++- .../thread-view/src/event-projection-types.ts | 4 + packages/thread-view/src/exec-lifecycle.ts | 108 ++-- packages/thread-view/src/file-edit-parsing.ts | 10 +- .../thread-view/src/format-timeline-text.ts | 35 +- packages/thread-view/src/index.ts | 2 + .../thread-view/src/operation-projection.ts | 6 + .../src/timeline-activity-intents.ts | 62 ++- .../thread-view/src/timeline-row-title.ts | 279 +++++++++- packages/thread-view/src/timeline-view.ts | 72 ++- .../thread-view/src/tool-activity-cells.ts | 20 +- .../src/tool-activity-projection.ts | 70 ++- .../src/tool-activity-web-projection.ts | 198 +++++-- .../thread-view/src/tool-call-suppression.ts | 47 +- .../thread-view/src/web-activity-lifecycle.ts | 205 +++++--- .../test/timeline-row-title.test.ts | 2 + .../thread-view/test/timeline-test-harness.ts | 135 +++++ .../thread-view/test/timeline-view.test.ts | 2 + .../test/v3-item-projection.test.ts | 488 +++++++++++++++--- scripts/provider-corpus/snapshot-rows.sh | 9 + .../fake/smoke/timeline-response.test.ts | 2 + turbo.json | 4 +- 44 files changed, 2276 insertions(+), 352 deletions(-) create mode 100644 apps/server/test/provider-corpus/allowlists/README.md create mode 100644 apps/server/test/provider-corpus/allowlists/ws3-projection-rows.json diff --git a/apps/app/src/components/thread/timeline/TimelineRowDetails.tsx b/apps/app/src/components/thread/timeline/TimelineRowDetails.tsx index 0875ea990e..23873fffad 100644 --- a/apps/app/src/components/thread/timeline/TimelineRowDetails.tsx +++ b/apps/app/src/components/thread/timeline/TimelineRowDetails.tsx @@ -278,6 +278,12 @@ export function WorkRowBody({ case "approval": case "web-search": case "web-fetch": + case "file-read": + case "search": + case "plan-steps": + case "extension": + // Title-only until the presentation-driven renderers land (WS3 layer + // 2): the row title already carries the bridge's label and headline. return null; default: return assertNever(row); diff --git a/apps/app/src/components/thread/timeline/rows/Delegation.stories.tsx b/apps/app/src/components/thread/timeline/rows/Delegation.stories.tsx index 5976805ca5..a9d8c2cbc6 100644 --- a/apps/app/src/components/thread/timeline/rows/Delegation.stories.tsx +++ b/apps/app/src/components/thread/timeline/rows/Delegation.stories.tsx @@ -619,6 +619,8 @@ const completedDelegation: TimelineRow = { createdAt: 1778174341060, kind: "work", workKind: "delegation", + childRef: null, + background: false, status: "completed", callId: "toolu_01LKp2KK7kaTCi5vi15VZYvw", toolName: "Agent", @@ -1309,6 +1311,8 @@ const runningDelegation: TimelineRow = { createdAt: Date.now(), kind: "work", workKind: "delegation", + childRef: null, + background: false, status: "pending", callId: "toolu_012rpTKMPCmiRnZnYXLA5Vy9", toolName: "Agent", @@ -2045,6 +2049,8 @@ const errorDelegation: TimelineRow = { createdAt: 1778174370069, kind: "work", workKind: "delegation", + childRef: null, + background: false, status: "error", callId: "toolu_01VfaFeGbfjGckpp9LZNpd5a", toolName: "Agent", @@ -2096,6 +2102,8 @@ const interruptedDelegation: TimelineRow = { createdAt: 1778174341060, kind: "work", workKind: "delegation", + childRef: null, + background: false, status: "interrupted", callId: "toolu_01LKp2KK7kaTCi5vi15VZYvw-interrupted", toolName: "Agent", diff --git a/apps/app/src/test/fixtures/thread-timeline-rows.ts b/apps/app/src/test/fixtures/thread-timeline-rows.ts index c7b9d624f2..27a3167230 100644 --- a/apps/app/src/test/fixtures/thread-timeline-rows.ts +++ b/apps/app/src/test/fixtures/thread-timeline-rows.ts @@ -1045,6 +1045,8 @@ export function delegationRow({ status, callId: callId ?? id, toolName, + childRef: null, + background: false, subagentType, description, output, diff --git a/apps/mobile/src/screens/dev/work-row-fixtures.ts b/apps/mobile/src/screens/dev/work-row-fixtures.ts index 58481f90c5..33672350b8 100644 --- a/apps/mobile/src/screens/dev/work-row-fixtures.ts +++ b/apps/mobile/src/screens/dev/work-row-fixtures.ts @@ -457,6 +457,8 @@ function delegation( status: "completed", callId: `call-${id}`, toolName: "Task", + childRef: null, + background: false, subagentType: "explore", description: "Find where the timeline rows are rendered", output: diff --git a/apps/mobile/src/screens/thread/timeline/renderers/work/work-row-model.ts b/apps/mobile/src/screens/thread/timeline/renderers/work/work-row-model.ts index 897823b146..e0d2c5f64d 100644 --- a/apps/mobile/src/screens/thread/timeline/renderers/work/work-row-model.ts +++ b/apps/mobile/src/screens/thread/timeline/renderers/work/work-row-model.ts @@ -76,7 +76,12 @@ export function leadingIconForWorkRow(row: TimelineViewWorkRow): IconName { if ("activityIntents" in row && row.activityIntents.some(isSkillReadIntent)) { return "Zap"; } - if (row.workKind === "command" || row.workKind === "tool") { + if ( + row.workKind === "command" || + row.workKind === "tool" || + row.workKind === "file-read" || + row.workKind === "search" + ) { const intent = primaryTimelineActivityIntent(row); if (intent !== null && intent.type !== "unknown") { return explorationIntentIcon(intent.type); @@ -88,6 +93,14 @@ export function leadingIconForWorkRow(row: TimelineViewWorkRow): IconName { case "command": case "tool": return "Terminal"; + case "file-read": + return "FileText"; + case "search": + return "Search"; + case "plan-steps": + return "ListTodo"; + case "extension": + return "Puzzle"; case "web-search": return "Search"; case "web-fetch": diff --git a/apps/mobile/src/screens/thread/timeline/rows.ts b/apps/mobile/src/screens/thread/timeline/rows.ts index 0474545821..e5cebb1928 100644 --- a/apps/mobile/src/screens/thread/timeline/rows.ts +++ b/apps/mobile/src/screens/thread/timeline/rows.ts @@ -49,6 +49,10 @@ export const TIMELINE_ROW_KINDS: readonly TimelineRowKind[] = [ "work:web-search", "work:web-fetch", "work:image-view", + "work:file-read", + "work:search", + "work:plan-steps", + "work:extension", "work:approval", "work:question", "work:delegation", @@ -75,6 +79,10 @@ interface TimelineRowByKind { "work:web-search": TimelineViewWorkRowOfKind<"web-search">; "work:web-fetch": TimelineViewWorkRowOfKind<"web-fetch">; "work:image-view": TimelineViewWorkRowOfKind<"image-view">; + "work:file-read": TimelineViewWorkRowOfKind<"file-read">; + "work:search": TimelineViewWorkRowOfKind<"search">; + "work:plan-steps": TimelineViewWorkRowOfKind<"plan-steps">; + "work:extension": TimelineViewWorkRowOfKind<"extension">; "work:approval": TimelineViewWorkRowOfKind<"approval">; "work:question": TimelineViewWorkRowOfKind<"question">; "work:delegation": TimelineViewWorkRowOfKind<"delegation">; diff --git a/apps/mobile/src/screens/thread/timeline/test-fixtures.ts b/apps/mobile/src/screens/thread/timeline/test-fixtures.ts index 9792862a75..d7c46f25bc 100644 --- a/apps/mobile/src/screens/thread/timeline/test-fixtures.ts +++ b/apps/mobile/src/screens/thread/timeline/test-fixtures.ts @@ -97,6 +97,8 @@ export function delegationRow( status: "completed", callId: `call-${id}`, toolName: "Task", + childRef: null, + background: false, subagentType: "explore", description: "Look around", output: "", diff --git a/apps/server/src/services/threads/timeline.ts b/apps/server/src/services/threads/timeline.ts index 58d305ce88..af4ed9330a 100644 --- a/apps/server/src/services/threads/timeline.ts +++ b/apps/server/src/services/threads/timeline.ts @@ -45,7 +45,7 @@ import { listLatestOpenBackgroundTaskStateRowsForThread, listStoredTimelineWindowEventRows, listTodoSnapshotEventRowsForThread, - listStoredToolCallRowsByItemIds, + listStoredDelegatingItemRowsByItemIds, listStoredTurnCompletedRowsByTurnIds, listStoredTurnInputAcceptedRowsByClientRequestIds, listStoredTurnRejectedRowsByClientRequestIds, @@ -394,12 +394,24 @@ function getStoredEventParentToolCallId( : undefined; } -function collectStoredToolCallItemIds( +/** + * Item kinds that can parent other events: a tool call (legacy delegation + * tools, MCP calls with nested work) and the grammar v3 `delegation` item, + * whose child turns link back through `parentToolCallId`. + */ +function isStoredDelegatingItemRow(row: StoredEventRow): boolean { + return ( + (row.itemKind === "toolCall" || row.itemKind === "delegation") && + row.itemId !== null + ); +} + +function collectStoredDelegatingItemIds( rows: readonly StoredEventRow[], ): string[] { const itemIds = new Set(); for (const row of rows) { - if (row.itemKind !== "toolCall" || row.itemId === null) { + if (!isStoredDelegatingItemRow(row) || row.itemId === null) { continue; } itemIds.add(row.itemId); @@ -426,7 +438,7 @@ function ensureTimelineWindowParentedRows( ): TimelineWindowParentedRowsResult { let rows = [...args.rows]; const rowIds = new Set(rows.map((row) => row.id)); - const visibleToolCallIds = new Set(collectStoredToolCallItemIds(rows)); + const visibleToolCallIds = new Set(collectStoredDelegatingItemIds(rows)); const fetchedChildToolCallIds = new Set(); let outOfBoundsChildDataBytesRemaining = args.outOfBoundsChildDataByteLimit; @@ -469,7 +481,7 @@ function ensureTimelineWindowParentedRows( } for (const row of newChildRows) { rowIds.add(row.id); - if (row.itemKind === "toolCall" && row.itemId !== null) { + if (isStoredDelegatingItemRow(row) && row.itemId !== null) { visibleToolCallIds.add(row.itemId); } } @@ -480,7 +492,7 @@ function ensureTimelineWindowParentedRows( const missingParentToolCallIds = collectStoredParentToolCallIds(rows).filter( (parentToolCallId) => !visibleToolCallIds.has(parentToolCallId), ); - const parentRows = listStoredToolCallRowsByItemIds(db, { + const parentRows = listStoredDelegatingItemRowsByItemIds(db, { itemIds: missingParentToolCallIds, maxInlineOutputChars: args.maxInlineOutputChars, threadId: args.threadId, @@ -593,6 +605,11 @@ const CROSS_TURN_TOOL_ITEM_KINDS: ReadonlySet = new Set([ "webSearch", "webFetch", "imageView", + "fileRead", + "search", + "planSteps", + "delegation", + "extension", ]); function filterExactEventRowsForRequestedTurn( diff --git a/apps/server/test/provider-corpus/allowlists/README.md b/apps/server/test/provider-corpus/allowlists/README.md new file mode 100644 index 0000000000..9ee1c35d07 --- /dev/null +++ b/apps/server/test/provider-corpus/allowlists/README.md @@ -0,0 +1,18 @@ +# Per-workstream corpus allowlists + +`snapshots/rows` under the private corpus is the row baseline minted on +`main`, shared by every workstream. A pull request that intentionally +changes projected rows carries its allowlist here, one file per workstream, +and compares against the shared baseline with: + +```bash +BB_PROVIDER_CORPUS_ALLOWLIST=apps/server/test/provider-corpus/allowlists/.json \ + scripts/provider-corpus/snapshot-rows.sh compare +``` + +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. diff --git a/apps/server/test/provider-corpus/allowlists/ws3-projection-rows.json b/apps/server/test/provider-corpus/allowlists/ws3-projection-rows.json new file mode 100644 index 0000000000..a867bb5016 --- /dev/null +++ b/apps/server/test/provider-corpus/allowlists/ws3-projection-rows.json @@ -0,0 +1,14 @@ +[ + { + "*": true, + "path": "/variants/**/childRef", + "pr": "#2192", + "reason": "WS3 layer 1: delegation rows gain `childRef` (the provider-native child id of a grammar v3 delegation item); legacy tool-call delegations carry null. Read-time projection only; the persisted events are unchanged." + }, + { + "*": true, + "path": "/variants/**/background", + "pr": "#2192", + "reason": "WS3 layer 1: delegation rows gain `background` (a grammar v3 delegation that outlives its turn); legacy tool-call delegations carry false. Read-time projection only; the persisted events are unchanged." + } +] diff --git a/apps/server/test/provider-corpus/corpus-harness.ts b/apps/server/test/provider-corpus/corpus-harness.ts index 8da2a79f5d..fd17b73a2d 100644 --- a/apps/server/test/provider-corpus/corpus-harness.ts +++ b/apps/server/test/provider-corpus/corpus-harness.ts @@ -44,6 +44,32 @@ import { export const SNAPSHOT_MODE_ENV = "BB_PROVIDER_CORPUS_SNAPSHOT"; +/** + * Where the row baseline lives. `snapshots/rows` under the corpus is the + * canonical baseline minted on main and shared by every workstream; a + * workstream that wants to write a snapshot of its own rows points this at + * a shadow directory instead of overwriting the shared one. + */ +export const SNAPSHOT_ROWS_DIR_ENV = "BB_PROVIDER_CORPUS_SNAPSHOT_DIR"; + +/** + * An additional allowlist file, merged after the shared + * `snapshots/allowlist.json`. A PR that intentionally changes rows carries + * its entries in the repository (see `allowlists/`) so the change is + * documented with the code and never lands in the shared file. + */ +export const ALLOWLIST_FILE_ENV = "BB_PROVIDER_CORPUS_ALLOWLIST"; + +export function resolveSnapshotRowsDir( + snapshotsDir: string, + env: NodeJS.ProcessEnv = process.env, +): string { + const value = env[SNAPSHOT_ROWS_DIR_ENV]; + return value === undefined || value === "" + ? path.join(snapshotsDir, "rows") + : path.resolve(value); +} + export type SnapshotMode = "write" | "compare"; export function resolveSnapshotMode( @@ -513,16 +539,37 @@ export function applyAllowlist( return { allowed, unallowed, usedEntryIndexes }; } -export function readAllowlist(snapshotsDir: string): AllowlistEntry[] { - const allowlistPath = path.join(snapshotsDir, "allowlist.json"); - if (!fs.existsSync(allowlistPath)) { - return []; - } +function readAllowlistFile(allowlistPath: string): AllowlistEntry[] { return allowlistSchema.parse( JSON.parse(fs.readFileSync(allowlistPath, "utf8")), ); } +/** + * The shared `snapshots/allowlist.json` (absent → none) followed by the + * file `BB_PROVIDER_CORPUS_ALLOWLIST` names, which must exist when set: a + * typo must not silently turn an intended diff into a failure. + */ +export function readAllowlist( + snapshotsDir: string, + env: NodeJS.ProcessEnv = process.env, +): AllowlistEntry[] { + const entries: AllowlistEntry[] = []; + const sharedPath = path.join(snapshotsDir, "allowlist.json"); + if (fs.existsSync(sharedPath)) { + entries.push(...readAllowlistFile(sharedPath)); + } + 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)); + } + return entries; +} + // --------------------------------------------------------------------------- // Statistics // --------------------------------------------------------------------------- diff --git a/apps/server/test/provider-corpus/row-snapshots.test.ts b/apps/server/test/provider-corpus/row-snapshots.test.ts index f1b24868a9..ba92220fa3 100644 --- a/apps/server/test/provider-corpus/row-snapshots.test.ts +++ b/apps/server/test/provider-corpus/row-snapshots.test.ts @@ -29,6 +29,7 @@ import { normalizeJson, readAllowlist, resolveSnapshotMode, + resolveSnapshotRowsDir, unifiedJsonDiff, type JsonDiff, type JsonValue, @@ -93,11 +94,10 @@ function buildRowSnapshot( * resolved file under the rows root even if that validation ever loosens. */ function snapshotFilePath( - snapshotsDir: string, + rowsRoot: string, provider: string, threadId: string, ): string { - const rowsRoot = path.resolve(snapshotsDir, "rows"); const filePath = path.resolve(rowsRoot, provider, `${threadId}.json`); if (!filePath.startsWith(`${rowsRoot}${path.sep}`)) { throw new Error( @@ -126,6 +126,7 @@ describe.skipIf(!available)("provider corpus row snapshots", () => { // so everything here must tolerate a missing corpus. const corpusDir = resolveProviderCorpusDir() ?? ""; const snapshotsDir = path.join(corpusDir, "snapshots"); + const rowsDir = resolveSnapshotRowsDir(snapshotsDir); const allowlist = available ? readAllowlist(snapshotsDir) : []; const usedAllowlistEntries = new Set(); let registry: ProviderRegistryService | null = null; @@ -159,7 +160,7 @@ describe.skipIf(!available)("provider corpus row snapshots", () => { totals.threads += 1; totals.rows += built.rows; totals.bytes += Buffer.byteLength(serialized); - const filePath = snapshotFilePath(snapshotsDir, provider, threadId); + const filePath = snapshotFilePath(rowsDir, provider, threadId); if (mode === "write") { // A baseline must not depend on the wall clock or on iteration @@ -209,7 +210,7 @@ describe.skipIf(!available)("provider corpus row snapshots", () => { ); } throw new Error( - `${threadId} (${provider}) has ${matched.unallowed.length} row diff(s) not covered by snapshots/allowlist.json; first: ${matched.unallowed[0]?.pointer}`, + `${threadId} (${provider}) has ${matched.unallowed.length} row diff(s) not covered by the allowlist; first: ${matched.unallowed[0]?.pointer}`, ); } } finally { diff --git a/docs/debugging-and-qa.md b/docs/debugging-and-qa.md index cd4bcf5e97..841b4a64c6 100644 --- a/docs/debugging-and-qa.md +++ b/docs/debugging-and-qa.md @@ -151,9 +151,17 @@ cover. An entry names a scope, a path, and the PR that made the change: `path` is a JSON pointer over the snapshot, or a glob where `*` matches one segment and `**` any number. The run prints the entries it used; an entry that -covers nothing fails the run because it is stale. Refresh the baseline with -`write` only when the diff is the intended behavior change, in the PR that -makes it, and remove the allowlist entries it absorbs. +covers nothing fails the run because it is stale. + +`snapshots/rows` is the baseline minted on `main` and shared by every +workstream, so never run `write` against it from a feature branch. A PR that +intentionally changes rows carries its own allowlist in the repository +(`apps/server/test/provider-corpus/allowlists/.json`, same schema, merged +after the shared file) and compares with +`BB_PROVIDER_CORPUS_ALLOWLIST=`. A snapshot of the branch's own +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. 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 diff --git a/packages/client-core/src/timeline/timeline-auto-expand.ts b/packages/client-core/src/timeline/timeline-auto-expand.ts index c2db72cf5c..1519da4e74 100644 --- a/packages/client-core/src/timeline/timeline-auto-expand.ts +++ b/packages/client-core/src/timeline/timeline-auto-expand.ts @@ -32,6 +32,16 @@ export function isWorkRowExpandable(row: TimelineViewWorkRow): boolean { case "command": case "tool": return !hasTimelineExplorationIntent(row); + case "file-read": + case "search": + // Exploration rows are title-only, like the legacy Read/Grep bundles. + return false; + case "plan-steps": + return row.steps.length > 0; + case "extension": + // The declarative base shows the bridge's detail in the body; a row + // without one stays title-only (a plugin renderer may still expand). + return row.presentation.detail !== undefined; case "file-change": return true; case "delegation": diff --git a/packages/client-core/src/timeline/timelineRowSignatures.ts b/packages/client-core/src/timeline/timelineRowSignatures.ts index cfc15523b1..6f3ff6920c 100644 --- a/packages/client-core/src/timeline/timelineRowSignatures.ts +++ b/packages/client-core/src/timeline/timelineRowSignatures.ts @@ -1,4 +1,7 @@ -import type { TimelineActivityIntent } from "@bb/server-contract"; +import type { + TimelineActivityIntent, + TimelineRowPresentation, +} from "@bb/server-contract"; import { assertNever, type ThreadTimelineViewRow, @@ -88,12 +91,36 @@ function timelineRowBaseSignature(row: ThreadTimelineViewRow): string { ]); } +/** + * The bridge presentation is persisted per item and only changes between an + * item's open and its close (a close's presentation wins), so the fields a + * title or icon reads from are enough to break memo equality. + */ +function presentationSignature( + presentation: TimelineRowPresentation | undefined, +): string | null { + if (!presentation) return null; + return joinSignatureParts([ + presentation.label.pending, + presentation.label.completed, + presentation.icon.glyph, + presentation.title ?? null, + presentation.detail ?? null, + presentation.suppress ?? null, + presentation.tint?.light ?? null, + presentation.tint?.dark ?? null, + ]); +} + function timelineWorkRowRenderSignature(row: TimelineViewWorkRow): string { const baseParts: TimelineRowSignaturePart[] = [ timelineRowBaseSignature(row), row.status, row.workKind, row.inClosedStep, + row.workKind === "approval" || row.workKind === "question" + ? null + : presentationSignature(row.presentation), ]; switch (row.workKind) { @@ -151,11 +178,51 @@ function timelineWorkRowRenderSignature(row: TimelineViewWorkRow): string { row.path, row.completedAt, ]); + case "file-read": + return joinSignatureParts([ + ...baseParts, + row.callId, + row.path, + row.cmd, + row.completedAt, + ]); + case "search": + return joinSignatureParts([ + ...baseParts, + row.callId, + row.mode, + row.query, + row.path, + row.cmd, + row.completedAt, + ]); + case "plan-steps": + return joinSignatureParts([ + ...baseParts, + row.callId, + row.explanation, + row.completedAt, + row.steps + .map((step) => joinSignatureParts([step.step, step.status ?? null])) + .join("\u001e"), + ]); + case "extension": + return joinSignatureParts([ + ...baseParts, + row.callId, + row.extensionKind, + row.completedAt, + // The payload is opaque plugin JSON; a plugin renderer may read any + // of it, so the whole serialized value takes part. + JSON.stringify(row.payload), + ]); case "delegation": return joinSignatureParts([ ...baseParts, row.callId, row.toolName, + row.childRef, + row.background, row.subagentType, row.description, row.completedAt, diff --git a/packages/db/src/data/events.ts b/packages/db/src/data/events.ts index fc280db8cf..202417be1e 100644 --- a/packages/db/src/data/events.ts +++ b/packages/db/src/data/events.ts @@ -1534,7 +1534,16 @@ export function getStoredEventRowsByParentToolCallIdsDataBytes( return row?.dataBytes ?? 0; } -export function listStoredToolCallRowsByItemIds( +/** + * 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. + */ +export function listStoredDelegatingItemRowsByItemIds( db: DbConnection, args: ListStoredToolCallRowsByItemIdsArgs, ): StoredEventRow[] { @@ -1545,19 +1554,37 @@ export function listStoredToolCallRowsByItemIds( return []; } - return db - .select(storedEventRowFieldsWithInlineOutputLimit(args.maxInlineOutputChars)) + 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, ["item/started", "item/completed"]), + inArray(events.type, [...lifecycleTypes]), ), ) - .orderBy(events.sequence) .all(); + const delegationRows = db + .select(fields) + .from(events) + .where( + and( + eq(events.threadId, args.threadId), + inArray(events.type, [...lifecycleTypes]), + eq(events.itemKind, "delegation"), + inArray(events.itemId, itemIds), + ), + ) + .all(); + return [...toolCallRows, ...delegationRows].sort( + (left, right) => left.sequence - right.sequence, + ); } /** Whether the thread still has an event at exactly this sequence. */ @@ -2034,7 +2061,7 @@ export function listTodoSnapshotEventRowsForThread( db: DbConnection, args: ListTodoSnapshotEventRowsForThreadArgs, ): StoredEventRow[] { - const rows = db + const legacyRows = db .select(storedEventRowFields) .from(events) .where( @@ -2054,6 +2081,24 @@ export function listTodoSnapshotEventRowsForThread( ), ) .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 + .select(storedEventRowFields) + .from(events) + .where( + and( + eq(events.threadId, args.threadId), + eq(events.type, "item/completed"), + eq(events.itemKind, "planSteps"), + ), + ) + .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 diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts index 66f3484bd8..921a8be90a 100644 --- a/packages/db/src/data/index.ts +++ b/packages/db/src/data/index.ts @@ -269,7 +269,7 @@ export { scopedItemRefKey, listStoredThreadProvisioningRowsByProvisioningId, listStoredTimelineWindowEventRows, - listStoredToolCallRowsByItemIds, + listStoredDelegatingItemRowsByItemIds, listStoredTurnInputAcceptedRowsByClientRequestIds, listStoredTurnRejectedRowsByClientRequestIds, listStoredTurnCompletedRowsByTurnIds, diff --git a/packages/provider-bridge-protocol/recordings/parity-allowlist.json b/packages/provider-bridge-protocol/recordings/parity-allowlist.json index a089421700..f722a98a93 100644 --- a/packages/provider-bridge-protocol/recordings/parity-allowlist.json +++ b/packages/provider-bridge-protocol/recordings/parity-allowlist.json @@ -558,5 +558,237 @@ "path": "/0/children/3/toolName", "pr": "#2179", "reason": "Projection of the same change: the legacy tool row's toolName is the item's `tool` slot, now the native kind rather than the agent's title (the title is in the persisted presentation, which the presentation-driven projection will read)." + }, + { + "provider": "acp-cursor", + "cell": "subagent", + "layer": "rows", + "path": "/0/children/1/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": "acp-cursor", + "cell": "turn-tools", + "layer": "rows", + "path": "/0/children/1/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": "acp-cursor", + "cell": "turn-tools", + "layer": "rows", + "path": "/0/children/3/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": "acp-cursor", + "cell": "user-question", + "layer": "rows", + "path": "/0/children/1/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": "acp-cursor", + "cell": "web-search", + "layer": "rows", + "path": "/0/children/1/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": "claude-code", + "cell": "plan-mode", + "layer": "rows", + "path": "/0/children/0/activityIntents", + "pr": "#2192", + "reason": "WS3 layer 1: a fileRead item projects to its own `file-read` row (path, cmd, presentation) instead of a tool row with a synthesized read intent; bundles derive the same intent from the row's fields." + }, + { + "provider": "claude-code", + "cell": "plan-mode", + "layer": "rows", + "path": "/0/children/0/approvalStatus", + "pr": "#2192", + "reason": "WS3 layer 1: a fileRead item projects to its own `file-read` row (path, cmd, presentation) instead of a tool row with a synthesized read intent; bundles derive the same intent from the row's fields." + }, + { + "provider": "claude-code", + "cell": "plan-mode", + "layer": "rows", + "path": "/0/children/0/cmd", + "pr": "#2192", + "reason": "WS3 layer 1: a fileRead item projects to its own `file-read` row (path, cmd, presentation) instead of a tool row with a synthesized read intent; bundles derive the same intent from the row's fields." + }, + { + "provider": "claude-code", + "cell": "plan-mode", + "layer": "rows", + "path": "/0/children/0/path", + "pr": "#2192", + "reason": "WS3 layer 1: a fileRead item projects to its own `file-read` row (path, cmd, presentation) instead of a tool row with a synthesized read intent; bundles derive the same intent from the row's fields." + }, + { + "provider": "claude-code", + "cell": "plan-mode", + "layer": "rows", + "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": "claude-code", + "cell": "plan-mode", + "layer": "rows", + "path": "/0/children/0/workKind", + "pr": "#2192", + "reason": "WS3 layer 1: a fileRead item projects to its own `file-read` row (path, cmd, presentation) instead of a tool row with a synthesized read intent; bundles derive the same intent from the row's fields." + }, + { + "provider": "claude-code", + "cell": "plan-mode", + "layer": "rows", + "path": "/0/children/3/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": "claude-code", + "cell": "plan-mode", + "layer": "rows", + "path": "/0/children/4/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": "claude-code", + "cell": "plan-mode", + "layer": "rows", + "path": "/0/children/5/activityIntents", + "pr": "#2192", + "reason": "WS3 layer 1: a fileRead item projects to its own `file-read` row (path, cmd, presentation) instead of a tool row with a synthesized read intent; bundles derive the same intent from the row's fields." + }, + { + "provider": "claude-code", + "cell": "plan-mode", + "layer": "rows", + "path": "/0/children/5/approvalStatus", + "pr": "#2192", + "reason": "WS3 layer 1: a fileRead item projects to its own `file-read` row (path, cmd, presentation) instead of a tool row with a synthesized read intent; bundles derive the same intent from the row's fields." + }, + { + "provider": "claude-code", + "cell": "plan-mode", + "layer": "rows", + "path": "/0/children/5/cmd", + "pr": "#2192", + "reason": "WS3 layer 1: a fileRead item projects to its own `file-read` row (path, cmd, presentation) instead of a tool row with a synthesized read intent; bundles derive the same intent from the row's fields." + }, + { + "provider": "claude-code", + "cell": "plan-mode", + "layer": "rows", + "path": "/0/children/5/path", + "pr": "#2192", + "reason": "WS3 layer 1: a fileRead item projects to its own `file-read` row (path, cmd, presentation) instead of a tool row with a synthesized read intent; bundles derive the same intent from the row's fields." + }, + { + "provider": "claude-code", + "cell": "plan-mode", + "layer": "rows", + "path": "/0/children/5/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": "claude-code", + "cell": "plan-mode", + "layer": "rows", + "path": "/0/children/5/workKind", + "pr": "#2192", + "reason": "WS3 layer 1: a fileRead item projects to its own `file-read` row (path, cmd, presentation) instead of a tool row with a synthesized read intent; bundles derive the same intent from the row's fields." + }, + { + "provider": "claude-code", + "cell": "subagent", + "layer": "rows", + "path": "/0/children/0/background", + "pr": "#2192", + "reason": "WS3 layer 1: delegation rows carry `background` (a delegation that outlives its spawning turn)." + }, + { + "provider": "claude-code", + "cell": "subagent", + "layer": "rows", + "path": "/0/children/0/childRef", + "pr": "#2192", + "reason": "WS3 layer 1: delegation rows carry `childRef`, the provider-native child id of the grammar v3 delegation item." + }, + { + "provider": "claude-code", + "cell": "subagent", + "layer": "rows", + "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": "claude-code", + "cell": "web-search", + "layer": "rows", + "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": "subagent", + "layer": "rows", + "path": "/0/children/0/background", + "pr": "#2192", + "reason": "WS3 layer 1: delegation rows carry `background` (a delegation that outlives its spawning turn)." + }, + { + "provider": "codex", + "cell": "subagent", + "layer": "rows", + "path": "/0/children/0/childRef", + "pr": "#2192", + "reason": "WS3 layer 1: delegation rows carry `childRef`, the provider-native child id of the grammar v3 delegation item." + }, + { + "provider": "codex", + "cell": "subagent", + "layer": "rows", + "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": "subagent", + "layer": "rows", + "path": "/0/children/1/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": "turn-tools", + "layer": "rows", + "path": "/0/children/2/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": "web-search", + "layer": "rows", + "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." } ] diff --git a/packages/server-contract/src/thread-timeline.ts b/packages/server-contract/src/thread-timeline.ts index 1543287091..fffa5f5298 100644 --- a/packages/server-contract/src/thread-timeline.ts +++ b/packages/server-contract/src/thread-timeline.ts @@ -2,15 +2,20 @@ import { z } from "zod"; import { backgroundTaskStatusSchema, backgroundTaskUsageSchema, + extensionKindSchema, jsonValueSchema, pendingInteractionUserAnswerSchema, pendingInteractionUserQuestionQuestionSchema, promptTextMentionSchema, systemMessageKindSchema, systemMessageSubjectSchema, + threadEventItemPresentationSchema, + threadEventPlanStepSchema, + threadEventSearchModeSchema, threadTurnInitiatorSchema, workflowProgressSnapshotSchema, type JsonObject, + type ThreadEventItemPresentation, } from "@bb/domain"; export const timelineRowStatusValues = [ @@ -257,6 +262,23 @@ interface TimelineWorkRowBase extends TimelineRowBase { status: TimelineRowStatus; } +/** + * The declarative presentation the provider bridge attached to the item a + * work row projects from (`ThreadEventItemPresentation`, persisted with the + * item). Clients render the row's label, icon, headline and detail from it; + * core kinds keep their core renderer and use it for customization only. + * + * Absent on rows projected from events persisted before presentation existed + * (grammar v2) and on rows bb authors itself (approvals, questions); those + * render through the legacy per-kind derivation. + */ +export const timelineRowPresentationSchema = threadEventItemPresentationSchema; +export type TimelineRowPresentation = ThreadEventItemPresentation; + +const timelineRowPresentationField = { + presentation: timelineRowPresentationSchema.optional(), +}; + /** * Marks a command/tool row whose `output` the server replaced with a head+tail * preview. The latest window caps the inline outputs of its running turn so a @@ -282,6 +304,7 @@ export const timelineCommandWorkRowSchema = timelineWorkRowBaseSchema.extend({ completedAt: z.number().nullable(), approvalStatus: timelineApprovalStatusSchema, activityIntents: z.array(timelineActivityIntentSchema), + ...timelineRowPresentationField, }); export type TimelineCommandWorkRow = z.infer< typeof timelineCommandWorkRowSchema @@ -292,7 +315,11 @@ export const timelineToolWorkRowSchema = timelineWorkRowBaseSchema.extend({ callId: z.string(), toolName: z.string(), toolArgs: z.record(z.string(), jsonValueSchema).nullable(), - /** Optional plugin-supplied labels for the native pending/completed title. */ + /** + * 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(), @@ -301,6 +328,7 @@ export const timelineToolWorkRowSchema = timelineWorkRowBaseSchema.extend({ completedAt: z.number().nullable(), approvalStatus: timelineApprovalStatusSchema, activityIntents: z.array(timelineActivityIntentSchema), + ...timelineRowPresentationField, }); export type TimelineToolWorkRow = z.infer; @@ -312,6 +340,7 @@ export const timelineFileChangeWorkRowSchema = timelineWorkRowBaseSchema.extend( stdout: z.string().nullable(), stderr: z.string().nullable(), approvalStatus: timelineApprovalStatusSchema, + ...timelineRowPresentationField, }, ); export type TimelineFileChangeWorkRow = z.infer< @@ -323,6 +352,7 @@ export const timelineWebSearchWorkRowSchema = timelineWorkRowBaseSchema.extend({ callId: z.string(), queries: z.array(z.string()), completedAt: z.number().nullable(), + ...timelineRowPresentationField, }); export type TimelineWebSearchWorkRow = z.infer< typeof timelineWebSearchWorkRowSchema @@ -335,6 +365,7 @@ export const timelineWebFetchWorkRowSchema = timelineWorkRowBaseSchema.extend({ prompt: z.string().nullable(), pattern: z.string().nullable(), completedAt: z.number().nullable(), + ...timelineRowPresentationField, }); export type TimelineWebFetchWorkRow = z.infer< typeof timelineWebFetchWorkRowSchema @@ -345,11 +376,87 @@ export const timelineImageViewWorkRowSchema = timelineWorkRowBaseSchema.extend({ callId: z.string(), path: z.string(), completedAt: z.number().nullable(), + ...timelineRowPresentationField, }); export type TimelineImageViewWorkRow = z.infer< typeof timelineImageViewWorkRowSchema >; +/** + * A file the agent read (grammar v3 `fileRead`). `cmd` is the native shell + * form when the provider read through a command rather than a structured + * tool; null for a structured read. + */ +export const timelineFileReadWorkRowSchema = timelineWorkRowBaseSchema.extend({ + workKind: z.literal("file-read"), + callId: z.string(), + path: z.string(), + cmd: z.string().nullable(), + completedAt: z.number().nullable(), + ...timelineRowPresentationField, +}); +export type TimelineFileReadWorkRow = z.infer< + typeof timelineFileReadWorkRowSchema +>; + +/** + * An exploration search (grammar v3 `search`): `content` searches inside + * files, `path` matches file names, `list` enumerates a directory. `query` + * is the pattern (empty for a whole-directory listing); `path` is the root + * the search ran under when the provider named one. + */ +export const timelineSearchWorkRowSchema = timelineWorkRowBaseSchema.extend({ + workKind: z.literal("search"), + callId: z.string(), + mode: threadEventSearchModeSchema, + query: z.string(), + path: z.string().nullable(), + cmd: z.string().nullable(), + completedAt: z.number().nullable(), + ...timelineRowPresentationField, +}); +export type TimelineSearchWorkRow = z.infer; + +/** + * A structured plan snapshot the agent maintains (grammar v3 `planSteps`: + * codex `update_plan`, the Claude TodoWrite/Task family). Each row carries + * the full step list of one snapshot; the todo banner reads the latest. + */ +export const timelinePlanStepsWorkRowSchema = timelineWorkRowBaseSchema.extend( + { + workKind: z.literal("plan-steps"), + callId: z.string(), + steps: z.array(threadEventPlanStepSchema), + explanation: z.string().nullable(), + completedAt: z.number().nullable(), + ...timelineRowPresentationField, + }, +); +export type TimelinePlanStepsWorkRow = z.infer< + typeof timelinePlanStepsWorkRowSchema +>; + +/** + * A plugin-defined item kind outside the core vocabulary + * (`extensionKind` is `"/"`). The payload is the plugin's + * declared shape, validated at ingest and opaque here. `presentation` is + * required: the declarative base is the only thing every client can render, + * and a plugin web renderer registered for the kind is an upgrade on top. + */ +export const timelineExtensionWorkRowSchema = timelineWorkRowBaseSchema.extend( + { + workKind: z.literal("extension"), + callId: z.string(), + extensionKind: extensionKindSchema, + payload: jsonValueSchema, + completedAt: z.number().nullable(), + presentation: timelineRowPresentationSchema, + }, +); +export type TimelineExtensionWorkRow = z.infer< + typeof timelineExtensionWorkRowSchema +>; + export const timelineFileEditApprovalLifecycleValues = [ "waiting", "denied", @@ -426,15 +533,24 @@ export type TimelineQuestionWorkRow = z.infer< typeof timelineQuestionWorkRowSchema >; +/** + * Work the agent delegated to a child agent. `childRef` is the provider- + * native id of the child (grammar v3 `delegation`); null for rows projected + * from a legacy delegation tool call, whose child is linked by turn parentage + * alone. `background` marks a delegation that outlives its spawning turn. + */ export interface TimelineDelegationWorkRow extends TimelineWorkRowBase { workKind: "delegation"; callId: string; toolName: string; + childRef: string | null; + background: boolean; subagentType: string | null; description: string | null; output: string; completedAt: number | null; childRows: TimelineRow[]; + presentation?: TimelineRowPresentation; } export const timelineDelegationWorkRowSchema: z.ZodType = @@ -442,11 +558,14 @@ export const timelineDelegationWorkRowSchema: z.ZodType timelineRowSchema)), + ...timelineRowPresentationField, }); /** @@ -473,6 +592,7 @@ export const timelineWorkflowWorkRowSchema = timelineWorkRowBaseSchema.extend({ summary: z.string().nullable(), error: z.string().nullable(), completedAt: z.number().nullable(), + ...timelineRowPresentationField, }); export type TimelineWorkflowWorkRow = z.infer< typeof timelineWorkflowWorkRowSchema @@ -485,6 +605,10 @@ export type TimelineWorkRow = | TimelineWebSearchWorkRow | TimelineWebFetchWorkRow | TimelineImageViewWorkRow + | TimelineFileReadWorkRow + | TimelineSearchWorkRow + | TimelinePlanStepsWorkRow + | TimelineExtensionWorkRow | TimelineApprovalWorkRow | TimelineQuestionWorkRow | TimelineDelegationWorkRow @@ -497,6 +621,10 @@ export const timelineWorkRowSchema: z.ZodType = z.union([ timelineWebSearchWorkRowSchema, timelineWebFetchWorkRowSchema, timelineImageViewWorkRowSchema, + timelineFileReadWorkRowSchema, + timelineSearchWorkRowSchema, + timelinePlanStepsWorkRowSchema, + timelineExtensionWorkRowSchema, timelineApprovalWorkRowSchema, timelineQuestionWorkRowSchema, timelineDelegationWorkRowSchema, diff --git a/packages/thread-view/src/background-task-projection.ts b/packages/thread-view/src/background-task-projection.ts index 349d248375..72ec982f5b 100644 --- a/packages/thread-view/src/background-task-projection.ts +++ b/packages/thread-view/src/background-task-projection.ts @@ -125,6 +125,9 @@ export function upsertBackgroundTaskMessage( ...(lifecycle.item.parentToolCallId ? { parentToolCallId: lifecycle.item.parentToolCallId } : {}), + ...(lifecycle.item.presentation + ? { presentation: lifecycle.item.presentation } + : {}), itemId: lifecycle.item.id, familyId: lifecycle.item.familyId ?? null, taskType: lifecycle.item.taskType, diff --git a/packages/thread-view/src/build-event-projection.ts b/packages/thread-view/src/build-event-projection.ts index cb67d9fed8..3560ac8a56 100644 --- a/packages/thread-view/src/build-event-projection.ts +++ b/packages/thread-view/src/build-event-projection.ts @@ -175,8 +175,12 @@ function isEventProjectionCallMessage( switch (message.kind) { case "command": case "delegation": + case "extension": case "file-edit": + case "file-read": case "image-view": + case "plan-steps": + case "search": case "tool-call": case "web-fetch": case "web-search": diff --git a/packages/thread-view/src/build-thread-timeline.ts b/packages/thread-view/src/build-thread-timeline.ts index 25de2847e7..72620c4286 100644 --- a/packages/thread-view/src/build-thread-timeline.ts +++ b/packages/thread-view/src/build-thread-timeline.ts @@ -19,6 +19,7 @@ import { readTerminalOutputLines, type ActiveThinking, type Thread, + type ThreadEventItemPresentation, type ThreadTimelineActivePromptMode, type ThreadTimelineGoal, type ThreadTimelineModelFallback, @@ -365,6 +366,7 @@ function buildWorkflowWorkRow( summary: message.summary, error: message.error, completedAt: message.completedAt, + ...rowPresentation(message), }; } @@ -402,6 +404,16 @@ function toConversationAttachments( }; } +/** + * The bridge's presentation, spread onto a row only when the item had one so + * pre-presentation rows keep an absent field rather than an `undefined` key. + */ +function rowPresentation(message: { + presentation?: ThreadEventItemPresentation; +}): { presentation?: ThreadEventItemPresentation } { + return message.presentation ? { presentation: message.presentation } : {}; +} + function convertActivityIntent( intent: EventProjectionToolParsedIntent, ): TimelineActivityIntent { @@ -599,6 +611,7 @@ function convertMessage( completedAt: message.completedAt, approvalStatus: message.approvalStatus, activityIntents: message.parsedIntents.map(convertActivityIntent), + ...rowPresentation(message), }, ]; case "tool-call": @@ -618,6 +631,7 @@ function convertMessage( completedAt: message.completedAt, approvalStatus: message.approvalStatus, activityIntents: message.parsedIntents.map(convertActivityIntent), + ...rowPresentation(message), }, ]; case "file-edit": @@ -652,6 +666,7 @@ function convertMessage( stdout: message.stdout ?? null, stderr: message.stderr ?? null, approvalStatus: message.approvalStatus, + ...rowPresentation(message), }; }); case "web-search": @@ -664,6 +679,7 @@ function convertMessage( callId: message.callId, queries: message.queries, completedAt: message.completedAt, + ...rowPresentation(message), }, ]; case "web-fetch": @@ -678,6 +694,7 @@ function convertMessage( prompt: message.prompt, pattern: message.pattern, completedAt: message.completedAt, + ...rowPresentation(message), }, ]; case "image-view": @@ -690,6 +707,65 @@ function convertMessage( callId: message.callId, path: message.path, completedAt: message.completedAt, + ...rowPresentation(message), + }, + ]; + case "file-read": + return [ + { + ...buildTimelineRowBase(message, options.rowIdPrefix), + kind: "work", + workKind: "file-read", + status: message.status, + callId: message.callId, + path: message.path, + cmd: message.cmd, + completedAt: message.completedAt, + ...rowPresentation(message), + }, + ]; + case "search": + return [ + { + ...buildTimelineRowBase(message, options.rowIdPrefix), + kind: "work", + workKind: "search", + status: message.status, + callId: message.callId, + mode: message.mode, + query: message.query, + path: message.path, + cmd: message.cmd, + completedAt: message.completedAt, + ...rowPresentation(message), + }, + ]; + case "plan-steps": + return [ + { + ...buildTimelineRowBase(message, options.rowIdPrefix), + kind: "work", + workKind: "plan-steps", + status: message.status, + callId: message.callId, + steps: message.steps, + explanation: message.explanation, + completedAt: message.completedAt, + ...rowPresentation(message), + }, + ]; + case "extension": + return [ + { + ...buildTimelineRowBase(message, options.rowIdPrefix), + kind: "work", + workKind: "extension", + status: message.status, + callId: message.callId, + extensionKind: message.extensionKind, + payload: message.payload, + completedAt: message.completedAt, + presentation: message.presentation, }, ]; case "delegation": { @@ -702,6 +778,8 @@ function convertMessage( status: message.status, callId: message.callId, toolName: message.toolName, + childRef: message.childRef, + background: message.background, subagentType: message.subagentType ?? null, description: message.description ?? null, output: message.output, @@ -713,6 +791,7 @@ function convertMessage( workspaceRoot: options.workspaceRoot, }), ), + ...rowPresentation(message), }, ]; } diff --git a/packages/thread-view/src/event-projection-message.ts b/packages/thread-view/src/event-projection-message.ts index 1f6b7e386a..033d9f4cd2 100644 --- a/packages/thread-view/src/event-projection-message.ts +++ b/packages/thread-view/src/event-projection-message.ts @@ -1,7 +1,9 @@ import type { BackgroundTaskStatus, BackgroundTaskUsage, + ExtensionKind, JsonObject, + JsonValue, OwnershipChangeOperationMetadata, PendingInteractionUserAnswer, PendingInteractionUserQuestionQuestion, @@ -10,7 +12,10 @@ import type { SystemMessageKind, SystemMessageSubject, Thread, + ThreadEventItemPresentation, + ThreadEventPlanStep, ThreadEventScope, + ThreadEventSearchMode, ThreadTurnInitiator, WorkflowProgressSnapshot, } from "@bb/domain"; @@ -62,6 +67,16 @@ export interface EventProjectionMessageBase { parentToolCallId?: string; } +/** + * Messages projected from a provider item carry the bridge's declarative + * presentation (grammar v3) when the persisted item had one. Absent on + * events persisted before presentation existed; the row then renders + * through its kind's legacy derivation. + */ +interface EventProjectionPresentedMessage { + presentation?: ThreadEventItemPresentation; +} + const eventProjectionTurnRequestKindValues = ["message", "steer"] as const; export type EventProjectionTurnRequestKind = (typeof eventProjectionTurnRequestKindValues)[number]; @@ -138,7 +153,8 @@ interface EventProjectionDelegationMetadata { model?: string; } -export interface EventProjectionToolCallMessage extends EventProjectionMessageBase { +export interface EventProjectionToolCallMessage + extends EventProjectionMessageBase, EventProjectionPresentedMessage { kind: "tool-call"; toolName: string; toolArgs: JsonObject | null; @@ -154,7 +170,8 @@ export interface EventProjectionToolCallMessage extends EventProjectionMessageBa >; } -export interface EventProjectionCommandMessage extends EventProjectionMessageBase { +export interface EventProjectionCommandMessage + extends EventProjectionMessageBase, EventProjectionPresentedMessage { kind: "command"; callId: string; command: string; @@ -171,7 +188,8 @@ export interface EventProjectionCommandMessage extends EventProjectionMessageBas >; } -export interface EventProjectionWebSearchMessage extends EventProjectionMessageBase { +export interface EventProjectionWebSearchMessage + extends EventProjectionMessageBase, EventProjectionPresentedMessage { kind: "web-search"; callId: string; queries: string[]; @@ -182,7 +200,8 @@ export interface EventProjectionWebSearchMessage extends EventProjectionMessageB >; } -export interface EventProjectionWebFetchMessage extends EventProjectionMessageBase { +export interface EventProjectionWebFetchMessage + extends EventProjectionMessageBase, EventProjectionPresentedMessage { kind: "web-fetch"; callId: string; url: string; @@ -195,7 +214,8 @@ export interface EventProjectionWebFetchMessage extends EventProjectionMessageBa >; } -export interface EventProjectionImageViewMessage extends EventProjectionMessageBase { +export interface EventProjectionImageViewMessage + extends EventProjectionMessageBase, EventProjectionPresentedMessage { kind: "image-view"; callId: string; path: string; @@ -206,6 +226,57 @@ export interface EventProjectionImageViewMessage extends EventProjectionMessageB >; } +type EventProjectionItemActivityStatus = Extract< + EventProjectionMessageStatus, + "pending" | "completed" | "error" | "interrupted" +>; + +/** A grammar v3 `fileRead` item. */ +export interface EventProjectionFileReadMessage + extends EventProjectionMessageBase, EventProjectionPresentedMessage { + kind: "file-read"; + callId: string; + path: string; + cmd: string | null; + completedAt: number | null; + status: EventProjectionItemActivityStatus; +} + +/** A grammar v3 `search` item (content search, path match, or listing). */ +export interface EventProjectionSearchMessage + extends EventProjectionMessageBase, EventProjectionPresentedMessage { + kind: "search"; + callId: string; + mode: ThreadEventSearchMode; + query: string; + path: string | null; + cmd: string | null; + completedAt: number | null; + status: EventProjectionItemActivityStatus; +} + +/** A grammar v3 `planSteps` snapshot. */ +export interface EventProjectionPlanStepsMessage + extends EventProjectionMessageBase, EventProjectionPresentedMessage { + kind: "plan-steps"; + callId: string; + steps: ThreadEventPlanStep[]; + explanation: string | null; + completedAt: number | null; + status: EventProjectionItemActivityStatus; +} + +/** A plugin extension item; `presentation` is mandatory on the item. */ +export interface EventProjectionExtensionMessage extends EventProjectionMessageBase { + kind: "extension"; + callId: string; + extensionKind: ExtensionKind; + payload: JsonValue; + presentation: ThreadEventItemPresentation; + completedAt: number | null; + status: EventProjectionItemActivityStatus; +} + export interface EventProjectionFileEditChange { path: string; kind?: string; @@ -213,7 +284,8 @@ export interface EventProjectionFileEditChange { diff?: string; } -export interface EventProjectionFileEditMessage extends EventProjectionMessageBase { +export interface EventProjectionFileEditMessage + extends EventProjectionMessageBase, EventProjectionPresentedMessage { kind: "file-edit"; callId: string; changes: EventProjectionFileEditChange[]; @@ -344,10 +416,15 @@ export interface EventProjectionUserQuestionLifecycleMessage extends EventProjec } export interface EventProjectionDelegationMessage - extends EventProjectionMessageBase, EventProjectionDelegationMetadata { + extends EventProjectionMessageBase, + EventProjectionDelegationMetadata, + EventProjectionPresentedMessage { kind: "delegation"; toolName: string; callId: string; + /** Provider-native child id (grammar v3); null for legacy tool-call delegations. */ + childRef: string | null; + background: boolean; output: string; completedAt: number | null; status: Extract< @@ -363,7 +440,8 @@ export interface EventProjectionDelegationMessage * thread: the turn-scoped item/started anchors placement and later * thread-scoped progress/completed events replace its payload in place. */ -export interface EventProjectionWorkflowMessage extends EventProjectionMessageBase { +export interface EventProjectionWorkflowMessage + extends EventProjectionMessageBase, EventProjectionPresentedMessage { kind: "workflow"; itemId: string; /** @@ -410,6 +488,10 @@ export type EventProjectionMessage = | EventProjectionWebSearchMessage | EventProjectionWebFetchMessage | EventProjectionImageViewMessage + | EventProjectionFileReadMessage + | EventProjectionSearchMessage + | EventProjectionPlanStepsMessage + | EventProjectionExtensionMessage | EventProjectionFileEditMessage | EventProjectionOperationMessage | EventProjectionPermissionGrantLifecycleMessage diff --git a/packages/thread-view/src/event-projection-types.ts b/packages/thread-view/src/event-projection-types.ts index ac9b9f3413..7d95b5231f 100644 --- a/packages/thread-view/src/event-projection-types.ts +++ b/packages/thread-view/src/event-projection-types.ts @@ -14,8 +14,10 @@ export type { EventProjectionCommandMessage, EventProjectionDelegationMessage, EventProjectionErrorMessage, + EventProjectionExtensionMessage, EventProjectionFileEditChange, EventProjectionFileEditMessage, + EventProjectionFileReadMessage, EventProjectionImageViewMessage, EventProjectionMessage, EventProjectionMessageBase, @@ -24,10 +26,12 @@ export type { EventProjectionPermissionGrantLifecycle, EventProjectionPermissionGrantGrantScope, EventProjectionPermissionGrantLifecycleMessage, + EventProjectionPlanStepsMessage, EventProjectionProvisioningMetadata, EventProjectionProvisioningTranscriptEntry, EventProjectionThreadOperationKind, EventProjectionThreadOperationMetadata, + EventProjectionSearchMessage, EventProjectionThreadOperationStatus, EventProjectionUserQuestionLifecycle, EventProjectionUserQuestionLifecycleMessage, diff --git a/packages/thread-view/src/exec-lifecycle.ts b/packages/thread-view/src/exec-lifecycle.ts index 1d67f3890d..9bfebd1a1b 100644 --- a/packages/thread-view/src/exec-lifecycle.ts +++ b/packages/thread-view/src/exec-lifecycle.ts @@ -3,6 +3,7 @@ import { type JsonObject, ThreadEvent, ThreadEventItemApprovalStatus, + ThreadEventItemPresentation, ThreadEventItemStatus, } from "@bb/domain"; import { getEventParentToolCallId, type EventMeta } from "./event-decode.js"; @@ -82,6 +83,11 @@ interface ExecutionUpdateBase { completedAt: number | null; status?: EventProjectionToolCallMessage["status"]; parentToolCallId?: string; + /** + * The bridge's declarative presentation for the item (grammar v3). Absent + * on events persisted before presentation existed. + */ + presentation?: ThreadEventItemPresentation; } export interface CommandExecutionUpdate extends ExecutionUpdateBase { @@ -107,6 +113,10 @@ export interface DelegationExecutionUpdate extends ExecutionUpdateBase, DelegationMetadata { kind: "delegation"; toolName?: string; + /** Provider-native id of the child (grammar v3 `delegation` only). */ + childRef?: string; + /** The delegation outlives its spawning turn (grammar v3 `delegation` only). */ + background?: boolean; } export type ProviderExecutionUpdate = @@ -296,6 +306,13 @@ export function parseExecLifecycleEvent( /** The neutral tool name a v3 delegation row carries: it has no tool. */ export const DELEGATION_ITEM_TOOL_NAME = "delegation"; +/** + * A grammar v3 `delegation` item: `item/started` opens it, a background + * delegation's `item/delegation/progress` snapshots revise its label and + * summary while it runs, and `item/completed` (foreground) or + * `item/delegation/completed` (background) settles it with the child's + * terminal summary as the row output. + */ function parseDelegationItemLifecycleEvent( decoded: ThreadEvent, meta: EventMeta, @@ -304,6 +321,7 @@ function parseDelegationItemLifecycleEvent( if ( decoded.type !== "item/started" && decoded.type !== "item/completed" && + decoded.type !== "item/delegation/progress" && decoded.type !== "item/delegation/completed" ) { return null; @@ -311,81 +329,27 @@ function parseDelegationItemLifecycleEvent( if (decoded.item.type !== "delegation") { return null; } - const kind = decoded.type === "item/started" ? "begin" : "end"; + const kind = + decoded.type === "item/started" || + decoded.type === "item/delegation/progress" + ? "begin" + : "end"; const status = kind === "end" ? itemStatusToExecStatus(decoded.item.status) : "pending"; + const presentation = decoded.item.presentation; return { kind, call: { kind: "delegation", callId: decoded.item.id, toolName: DELEGATION_ITEM_TOOL_NAME, + childRef: decoded.item.childRef, + background: decoded.item.background, description: decoded.item.label, - output: kind === "end" ? decoded.item.summary : undefined, + output: decoded.item.summary, completedAt: kind === "end" ? meta.createdAt : null, status, - ...(parentToolCallId ? { parentToolCallId } : {}), - }, - }; -} - -/** - * Grammar v3 `fileRead` and `search` items (Claude Read/Grep/Glob, and every - * provider's reads and searches as its bridge migrates) project to the - * tool-call row with the parsed intent the legacy structured tool calls - * produced, so the activity bundles ("Read x", "Searched for y") keep - * rendering until the presentation-driven projection lands. The row's tool - * name is the item kind: a v3 item has no tool. - */ -function parseExplorationItemLifecycleEvent( - decoded: ThreadEvent, - meta: EventMeta, - parentToolCallId: string | undefined, -): ExecLifecycleEvent | null { - if (decoded.type !== "item/started" && decoded.type !== "item/completed") { - return null; - } - const item = decoded.item; - if (item.type !== "fileRead" && item.type !== "search") { - return null; - } - const kind = decoded.type === "item/started" ? "begin" : "end"; - const parsedIntents: EventProjectionToolParsedIntent[] = - item.type === "fileRead" - ? [ - { - type: "read", - cmd: item.cmd ?? `Read ${item.path}`, - name: item.type, - path: item.path, - }, - ] - : item.mode === "content" - ? [ - { - type: "search", - cmd: item.cmd ?? `Grep ${item.query}`, - query: item.query, - path: item.path ?? null, - }, - ] - : [ - { - type: "list_files", - cmd: item.cmd ?? `Glob ${item.query}`, - path: item.path ?? item.query, - }, - ]; - return { - kind, - call: { - kind: "tool-call", - callId: item.id, - toolName: item.type, - toolArgs: null, - parsedIntents, - completedAt: kind === "end" ? meta.createdAt : null, - status: kind === "end" ? itemStatusToExecStatus(item.status) : "pending", + ...(presentation ? { presentation } : {}), ...(parentToolCallId ? { parentToolCallId } : {}), }, }; @@ -413,12 +377,6 @@ export function parseToolCallLifecycleEvent( }; } - // A grammar v3 `delegation` item (codex native sub-agents, and every - // provider's delegated work once its bridge migrates): the child's label is - // the row description and the child's terminal summary is its output. The - // full v3 projection (presentation-driven rows for every kind) is a later - // workstream; this keeps delegation rows — and the child content nested - // under them — rendering in the meantime. const delegationEvent = parseDelegationItemLifecycleEvent( decoded, meta, @@ -427,14 +385,6 @@ export function parseToolCallLifecycleEvent( if (delegationEvent) { return delegationEvent; } - const explorationEvent = parseExplorationItemLifecycleEvent( - decoded, - meta, - parentToolCallId, - ); - if (explorationEvent) { - return explorationEvent; - } if (decoded.type === "item/started" || decoded.type === "item/completed") { if (decoded.item.type !== "toolCall") return null; @@ -472,6 +422,7 @@ export function parseToolCallLifecycleEvent( const delegationMetadata = getDelegationMetadata(fullToolName, parsedArgs); const toolArgs = parseToolArgs(parsedArgs); const statusLabels = decoded.item.statusLabels; + const presentation = decoded.item.presentation; const baseCall = { callId, @@ -479,6 +430,7 @@ export function parseToolCallLifecycleEvent( output: kind === "end" ? (output ?? errorField) : undefined, completedAt, status, + ...(presentation ? { presentation } : {}), ...(parentToolCallId ? { parentToolCallId } : {}), }; diff --git a/packages/thread-view/src/file-edit-parsing.ts b/packages/thread-view/src/file-edit-parsing.ts index 7807ef4514..2cc161b400 100644 --- a/packages/thread-view/src/file-edit-parsing.ts +++ b/packages/thread-view/src/file-edit-parsing.ts @@ -1,4 +1,8 @@ -import type { ThreadEvent, ThreadEventFileChange } from "@bb/domain"; +import type { + ThreadEvent, + ThreadEventFileChange, + ThreadEventItemPresentation, +} from "@bb/domain"; import { itemStatusToApprovalStatus, itemStatusToExecStatus, @@ -38,6 +42,7 @@ interface FileEditChangesPartial extends FileEditPartialBase { changes: EventProjectionFileEditChange[]; approvalStatus: EventProjectionApprovalLifecycleStatus | null; status: FileEditStatus; + presentation?: ThreadEventItemPresentation; } export type FileEditPartial = FileEditOutputPartial | FileEditChangesPartial; @@ -76,6 +81,9 @@ export function parseFileEditFromItemEvent( changes, approvalStatus: itemStatusToApprovalStatus(decoded.item.approvalStatus), status: itemStatusToExecStatus(decoded.item.status), + ...(decoded.item.presentation + ? { presentation: decoded.item.presentation } + : {}), ...(parentToolCallId ? { parentToolCallId } : {}), }; } diff --git a/packages/thread-view/src/format-timeline-text.ts b/packages/thread-view/src/format-timeline-text.ts index 0c076fc31f..a27d46b500 100644 --- a/packages/thread-view/src/format-timeline-text.ts +++ b/packages/thread-view/src/format-timeline-text.ts @@ -12,6 +12,7 @@ import { formatTimelineActivityIntentDetail, getTimelineActivityIntentDetailDedupeKey, hasTimelineExplorationIntent, + timelineRowActivityIntents, type TimelineExplorationWorkRow, } from "./timeline-activity-intents.js"; import { @@ -240,6 +241,29 @@ function formatWorkBody( return lines; case "image-view": return lines; + case "file-read": + case "search": + return lines; + case "plan-steps": + if (context.verbose) { + for (const step of row.steps) { + const marker = + step.status === "completed" + ? "[x]" + : step.status === "active" + ? "[>]" + : step.status === "failed" + ? "[!]" + : "[ ]"; + lines.push(dim(` ${marker} ${step.step}`, context.color)); + } + } + return lines; + case "extension": + if (row.presentation.detail) { + lines.push(dim(indentBlock(row.presentation.detail, " "), context.color)); + } + return lines; case "approval": case "question": case "workflow": @@ -274,7 +298,7 @@ function formatExplorationWorkDetails( ): string[] { let lastEmittedKey: string | null = null; const details: string[] = []; - for (const intent of row.activityIntents) { + for (const intent of timelineRowActivityIntents(row)) { if (intent.type === "unknown") { continue; } @@ -302,7 +326,10 @@ function formatWorkSummaryDetails( const childContext = nestedContext(context, null); for (const child of row.children) { if ( - (child.workKind === "command" || child.workKind === "tool") && + (child.workKind === "command" || + child.workKind === "tool" || + child.workKind === "file-read" || + child.workKind === "search") && hasTimelineExplorationIntent(child) ) { lines.push( @@ -315,7 +342,9 @@ function formatWorkSummaryDetails( if ( child.workKind === "web-search" || child.workKind === "web-fetch" || - child.workKind === "image-view" + child.workKind === "image-view" || + child.workKind === "plan-steps" || + child.workKind === "extension" ) { lines.push(rowHeader(formatWorkTitle(child, childContext), childContext)); continue; diff --git a/packages/thread-view/src/index.ts b/packages/thread-view/src/index.ts index 0951b40e46..e80384736c 100644 --- a/packages/thread-view/src/index.ts +++ b/packages/thread-view/src/index.ts @@ -15,6 +15,8 @@ export { export { hasTimelineExplorationIntent, primaryTimelineActivityIntent, + timelineRowActivityIntents, + type TimelineExplorationWorkRow, } from "./timeline-activity-intents.js"; export { capitalize, diff --git a/packages/thread-view/src/operation-projection.ts b/packages/thread-view/src/operation-projection.ts index 07c742e84d..77eb68976e 100644 --- a/packages/thread-view/src/operation-projection.ts +++ b/packages/thread-view/src/operation-projection.ts @@ -429,6 +429,9 @@ function createFileEditMessage({ ...(partial.parentToolCallId ? { parentToolCallId: partial.parentToolCallId } : {}), + ...("presentation" in partial && partial.presentation + ? { presentation: partial.presentation } + : {}), callId, changes: change ? [{ ...change }] : [], stdout, @@ -644,6 +647,9 @@ function updateFileEditMessage( if (!existing.parentToolCallId && partial.parentToolCallId) { existing.parentToolCallId = partial.parentToolCallId; } + if ("presentation" in partial && partial.presentation) { + existing.presentation = partial.presentation; + } if (change) { existing.changes = [mergeFileChange(existing.changes[0], change)]; diff --git a/packages/thread-view/src/timeline-activity-intents.ts b/packages/thread-view/src/timeline-activity-intents.ts index 09856facf7..a3546b17e9 100644 --- a/packages/thread-view/src/timeline-activity-intents.ts +++ b/packages/thread-view/src/timeline-activity-intents.ts @@ -1,6 +1,8 @@ import type { TimelineActivityIntent, TimelineCommandWorkRow, + TimelineFileReadWorkRow, + TimelineSearchWorkRow, TimelineToolWorkRow, } from "@bb/server-contract"; import { assertNever } from "./assert-never.js"; @@ -9,14 +11,68 @@ import { type TimelinePathDisplayMode, } from "./timeline-path-display.js"; +/** + * Rows that can read as exploration ("Read x", "Searched for y"): commands + * and tool calls through their parsed `activityIntents`, and the grammar v3 + * `file-read` / `search` rows through the intent their structured fields + * derive to (see {@link timelineRowActivityIntents}). + */ export type TimelineExplorationWorkRow = | TimelineCommandWorkRow - | TimelineToolWorkRow; + | TimelineToolWorkRow + | TimelineFileReadWorkRow + | TimelineSearchWorkRow; type TimelineReadActivityIntent = Extract< TimelineActivityIntent, { type: "read" } >; +/** + * The activity intents a row contributes to exploration bundles. A v3 + * `file-read` is one read; a v3 `search` is one content search or, for the + * path-match and listing modes, one listing rooted at its path (or, absent a + * root, its pattern) — the same shape the legacy Read/Grep/Glob tool calls + * projected to, so bundles, dedupe and counts treat both identically. + */ +export function timelineRowActivityIntents( + row: TimelineExplorationWorkRow, +): readonly TimelineActivityIntent[] { + switch (row.workKind) { + case "command": + case "tool": + return row.activityIntents; + case "file-read": + return [ + { + type: "read", + command: row.cmd ?? row.path, + name: "fileRead", + path: row.path, + }, + ]; + case "search": + if (row.mode === "content") { + return [ + { + type: "search", + command: row.cmd ?? row.query, + query: row.query, + path: row.path, + }, + ]; + } + return [ + { + type: "list_files", + command: row.cmd ?? row.query, + path: row.path ?? (row.query.length > 0 ? row.query : null), + }, + ]; + default: + return assertNever(row); + } +} + const SKILL_FILE_NAME = "SKILL.md"; const PLUGIN_CACHE_PATH_MARKERS = ["plugins", "cache"]; @@ -47,7 +103,9 @@ export function primaryTimelineActivityIntent( row: TimelineExplorationWorkRow, ): TimelineActivityIntent | null { return ( - row.activityIntents.find((intent) => intent.type !== "unknown") ?? null + timelineRowActivityIntents(row).find( + (intent) => intent.type !== "unknown", + ) ?? null ); } diff --git a/packages/thread-view/src/timeline-row-title.ts b/packages/thread-view/src/timeline-row-title.ts index 4677d21534..81a44db2f6 100644 --- a/packages/thread-view/src/timeline-row-title.ts +++ b/packages/thread-view/src/timeline-row-title.ts @@ -7,11 +7,16 @@ import type { TimelineActivityIntent, TimelineApprovalStatus, TimelineCommandWorkRow, + TimelineExtensionWorkRow, TimelineFileChange, TimelineFileChangeWorkRow, + TimelineFileReadWorkRow, TimelineImageViewWorkRow, TimelineParentChangeSystemRow, + TimelinePlanStepsWorkRow, + TimelineRowPresentation, TimelineRowStatus, + TimelineSearchWorkRow, TimelineToolWorkRow, TimelineWebFetchWorkRow, TimelineWebSearchWorkRow, @@ -34,9 +39,13 @@ import { formatTimelineActivityIntentDetailParts, getTimelineActivityIntentDetailDedupeKey, hasTimelineExplorationIntent, + timelineRowActivityIntents, type TimelineExplorationWorkRow, } from "./timeline-activity-intents.js"; -import { fileNameFromPath } from "./timeline-path-display.js"; +import { + fileNameFromPath, + formatTimelinePath, +} from "./timeline-path-display.js"; import { buildTimelineWorkSummaryLabelParts, type ThreadTimelineViewRow, @@ -413,6 +422,79 @@ function displayStatus({ return status; } +// --------------------------------------------------------------------------- +// Presentation-driven titles +// --------------------------------------------------------------------------- + +/** + * The bridge's label for the row's lifecycle state: the present-tense label + * while pending, the past-tense label once settled. A failed or interrupted + * row keeps the settled label and says how it ended through a status + * decoration, so the row stays identifiable without a second verb table. + */ +function presentationLabel( + presentation: TimelineRowPresentation, + status: TimelineRowStatus, +): string { + return status === "pending" + ? presentation.label.pending + : presentation.label.completed; +} + +interface PresentedTitleArgs { + presentation: TimelineRowPresentation; + status: TimelineRowStatus; + startedAt: number; + completedAt: number | null; + /** Row content beside the label; defaults to the presentation's headline. */ + content?: string | null; + /** CLI plain-text override for `content`. */ + plainContent?: string; + /** Emphasize the content segment (a path, a command), not plain prose. */ + em?: boolean; +} + +/** + * Label + content + lifecycle decoration, the one shape every row with a + * bridge presentation renders through. Core kinds pass their own structured + * content (a path, a query, a child description) so a generic bridge label + * such as "Read file" still names what was read. + */ +function presentedTitle({ + presentation, + status, + startedAt, + completedAt, + content, + plainContent, + em = true, +}: PresentedTitleArgs): TimelineTitle { + const resolvedContent = content === undefined ? presentation.title : content; + const segments: TimelineTitleSegment[] = [ + segment(presentationLabel(presentation, status), { + shimmer: status === "pending", + truncate: resolvedContent ? false : true, + }), + ]; + if (resolvedContent) { + segments.push( + segment(resolvedContent, { + em, + truncate: true, + ...(plainContent === undefined ? {} : { plainText: plainContent }), + }), + ); + } + const durationMs = completedAt !== null ? completedAt - startedAt : null; + const decorations: TimelineTitleDecoration[] = + status === "error" + ? [statusDecoration("error", durationMs)] + : status === "interrupted" + ? [statusDecoration("interrupted", durationMs)] + : filterNull([durationDecoration(startedAt, completedAt)]); + return makeTitle({ segments, decorations }); +} + // --------------------------------------------------------------------------- // Mappers — one per row kind. Each produces a structured Title. // --------------------------------------------------------------------------- @@ -423,6 +505,28 @@ function mapExecutionTitle(row: TimelineExecutionWorkRow): TimelineTitle { status: row.status, }); const isCommand = row.workKind === "command"; + // Approval states keep the core phrasing: the bridge's label describes the + // work, not the gate bb put in front of it. + if ( + row.presentation && + status !== "waiting" && + status !== "denied" && + // A command's own text already says what ran; the bridge label would + // only repeat "Ran command". + !isCommand + ) { + // The bridge's label already names the tool, and its headline (when it + // set one) is the content; the raw arguments stay in the expanded body. + return presentedTitle({ + presentation: row.presentation, + status, + startedAt: row.startedAt, + completedAt: row.completedAt, + }); + } + 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. @@ -445,9 +549,6 @@ function mapExecutionTitle(row: TimelineExecutionWorkRow): TimelineTitle { if (explorationTitle !== null) { return explorationTitle; } - const content = isCommand - ? row.command - : formatToolCallCommand(row.toolName, row.toolArgs); switch (status) { case "waiting": return makeTitle({ @@ -519,12 +620,12 @@ function mapExecutionTitle(row: TimelineExecutionWorkRow): TimelineTitle { } function mapSingleExplorationIntentTitle( - row: TimelineExecutionWorkRow, + row: TimelineExplorationWorkRow, ): TimelineTitle | null { if (!hasTimelineExplorationIntent(row)) { return null; } - const knownIntents = row.activityIntents.filter( + const knownIntents = timelineRowActivityIntents(row).filter( (intent) => intent.type !== "unknown", ); if (knownIntents.length !== 1) { @@ -535,7 +636,10 @@ function mapSingleExplorationIntentTitle( return null; } const status = displayStatus({ - approvalStatus: row.approvalStatus, + approvalStatus: + row.workKind === "command" || row.workKind === "tool" + ? row.approvalStatus + : null, status: row.status, }); const pending = status === "pending"; @@ -691,6 +795,16 @@ function mapFileChangeTitle(row: TimelineFileChangeWorkRow): TimelineTitle { } function mapWebSearchTitle(row: TimelineWebSearchWorkRow): TimelineTitle { + if (row.presentation) { + return presentedTitle({ + presentation: row.presentation, + status: row.status, + startedAt: row.startedAt, + completedAt: row.completedAt, + content: row.queries.join(", "), + em: false, + }); + } const query = row.queries.join(", ") || "web search"; const querySegment = segment(query, { em: false, @@ -738,6 +852,16 @@ function mapWebSearchTitle(row: TimelineWebSearchWorkRow): TimelineTitle { } function mapWebFetchTitle(row: TimelineWebFetchWorkRow): TimelineTitle { + if (row.presentation) { + return presentedTitle({ + presentation: row.presentation, + status: row.status, + startedAt: row.startedAt, + completedAt: row.completedAt, + content: row.url, + em: false, + }); + } const urlSegment = segment(row.url, { em: false, truncate: true }); switch (row.status) { case "pending": @@ -778,6 +902,16 @@ function mapWebFetchTitle(row: TimelineWebFetchWorkRow): TimelineTitle { } function mapImageViewTitle(row: TimelineImageViewWorkRow): TimelineTitle { + if (row.presentation) { + return presentedTitle({ + presentation: row.presentation, + status: row.status, + startedAt: row.startedAt, + completedAt: row.completedAt, + content: fileNameFromPath(row.path), + plainContent: row.path, + }); + } const pathSegment = segment(fileNameFromPath(row.path), { em: false, plainText: row.path, @@ -840,6 +974,15 @@ function delegationVerbForStatus(status: TimelineRowStatus): { function mapDelegationTitle(row: TimelineViewDelegationWorkRow): TimelineTitle { const description = row.description ?? (row.output.trim() || row.toolName); + if (row.presentation) { + return presentedTitle({ + presentation: row.presentation, + status: row.status, + startedAt: row.startedAt, + completedAt: row.completedAt, + content: description, + }); + } const verb = delegationVerbForStatus(row.status); const segments: TimelineTitleSegment[] = [ segment(verb.text, { shimmer: verb.shimmer }), @@ -1193,6 +1336,118 @@ function mapQuestionTitle(row: TimelineQuestionViewWorkRow): TimelineTitle { } } +function mapFileReadTitle(row: TimelineFileReadWorkRow): TimelineTitle { + if (row.presentation) { + return presentedTitle({ + presentation: row.presentation, + status: row.status, + startedAt: row.startedAt, + completedAt: row.completedAt, + content: formatTimelinePath({ path: row.path, mode: "compact" }), + plainContent: row.path, + }); + } + return ( + mapSingleExplorationIntentTitle(row) ?? + makeTitle({ + segments: [ + segment(row.status === "pending" ? "Reading" : "Read", { + shimmer: row.status === "pending", + }), + segment(row.path, { em: true, truncate: true }), + ], + }) + ); +} + +/** What a search row searched, phrased to follow the bridge's label. */ +function searchContent(row: TimelineSearchWorkRow): string { + const root = row.path ? ` in ${row.path}` : ""; + switch (row.mode) { + case "content": + return `for ${row.query}${root}`; + case "path": + return `matching ${row.query}${root}`; + case "list": + return row.path + ? `in ${row.path}` + : row.query.length > 0 + ? `in ${row.query}` + : ""; + default: + return assertNever(row.mode); + } +} + +function mapSearchTitle(row: TimelineSearchWorkRow): TimelineTitle { + if (row.presentation) { + return presentedTitle({ + presentation: row.presentation, + status: row.status, + startedAt: row.startedAt, + completedAt: row.completedAt, + content: searchContent(row), + em: false, + }); + } + return ( + mapSingleExplorationIntentTitle(row) ?? + makeTitle({ + segments: [ + segment(row.status === "pending" ? "Searching" : "Searched", { + shimmer: row.status === "pending", + }), + segment(searchContent(row), { truncate: true }), + ], + }) + ); +} + +function activePlanStep(row: TimelinePlanStepsWorkRow): string | null { + const active = row.steps.find((step) => step.status === "active"); + return active?.step ?? row.explanation; +} + +function mapPlanStepsTitle(row: TimelinePlanStepsWorkRow): TimelineTitle { + const content = activePlanStep(row); + if (row.presentation) { + return presentedTitle({ + presentation: row.presentation, + status: row.status, + startedAt: row.startedAt, + completedAt: row.completedAt, + content: row.presentation.title ?? content, + em: false, + }); + } + const completedSteps = row.steps.filter( + (step) => step.status === "completed", + ).length; + return makeTitle({ + segments: [ + segment(row.status === "pending" ? "Updating plan" : "Updated plan", { + shimmer: row.status === "pending", + }), + segment(content ?? `${completedSteps}/${row.steps.length} steps done`, { + truncate: true, + }), + ], + decorations: filterNull([ + durationDecoration(row.startedAt, row.completedAt), + ]), + }); +} + +function mapExtensionTitle(row: TimelineExtensionWorkRow): TimelineTitle { + return presentedTitle({ + presentation: row.presentation, + status: row.status, + startedAt: row.startedAt, + completedAt: row.completedAt, + em: false, + }); +} + function mapWorkTitle( row: TimelineViewWorkRow, options: BuildTimelineRowTitleOptions, @@ -1202,6 +1457,14 @@ function mapWorkTitle( case "command": case "tool": return mapExecutionTitle(row); + case "file-read": + return mapFileReadTitle(row); + case "search": + return mapSearchTitle(row); + case "plan-steps": + return mapPlanStepsTitle(row); + case "extension": + return mapExtensionTitle(row); case "file-change": return mapFileChangeTitle(row); case "web-search": @@ -1515,7 +1778,7 @@ export function buildTimelineActivityIntentTitles( ? "interrupted" : undefined; - row.activityIntents.forEach((intent, index) => { + timelineRowActivityIntents(row).forEach((intent, index) => { if (intent.type === "unknown") { return; } diff --git a/packages/thread-view/src/timeline-view.ts b/packages/thread-view/src/timeline-view.ts index 58c2b297e2..212015b003 100644 --- a/packages/thread-view/src/timeline-view.ts +++ b/packages/thread-view/src/timeline-view.ts @@ -8,7 +8,6 @@ import type { TimelineRowBase, TimelineRowStatus, TimelineSystemRow, - TimelineToolWorkRow, TimelineTurnRow, TimelineWorkRow, } from "@bb/server-contract"; @@ -21,6 +20,8 @@ import { plural } from "./format-helpers.js"; import { getTimelineActivityIntentDetailDedupeKey, hasTimelineExplorationIntent, + timelineRowActivityIntents, + type TimelineExplorationWorkRow, } from "./timeline-activity-intents.js"; export interface TimelineViewDelegationWorkRow extends Omit< @@ -105,9 +106,11 @@ interface TimelineWorkSummaryCounts { deletedFiles: number; delegations: number; editedFiles: number; + extensions: number; fileChanges: number; files: number; lists: number; + planUpdates: number; renamedFiles: number; searches: number; tools: number; @@ -122,8 +125,10 @@ type TimelineWorkSummaryCategory = | "commands" | "delegations" | "exploration" + | "extensions" | "fileChanges" | "imageViews" + | "planUpdates" | "tools" | "webResearch"; @@ -155,12 +160,12 @@ function getExploredFileIdentity( } function countExplorationIntents( - row: TimelineCommandWorkRow | TimelineToolWorkRow, + row: TimelineExplorationWorkRow, counts: TimelineWorkSummaryCounts, exploredFileIdentities: Set, noteExplorationKind: (kind: TimelineExplorationKind) => void, ): void { - for (const intent of row.activityIntents) { + for (const intent of timelineRowActivityIntents(row)) { switch (intent.type) { case "read": { const identity = getExploredFileIdentity(intent); @@ -208,9 +213,11 @@ function summarizeTimelineWork( deletedFiles: 0, delegations: 0, editedFiles: 0, + extensions: 0, fileChanges: 0, files: 0, lists: 0, + planUpdates: 0, renamedFiles: 0, searches: 0, tools: 0, @@ -251,6 +258,21 @@ function summarizeTimelineWork( counts.tools += 1; } break; + case "file-read": + case "search": + countExplorationIntents( + row, + counts, + exploredFileIdentities, + noteExplorationKind, + ); + break; + case "plan-steps": + counts.planUpdates += 1; + break; + case "extension": + counts.extensions += 1; + break; case "file-change": switch (getFileChangeAction(row.change)) { case "created": @@ -356,7 +378,11 @@ function approvalStatusSummaryLabel( case "approval": case "question": case "delegation": + case "extension": + case "file-read": case "image-view": + case "plan-steps": + case "search": case "web-fetch": case "web-search": case "workflow": @@ -396,6 +422,13 @@ function getTimelineWorkSummaryCategory( return hasTimelineExplorationIntent(row) ? "exploration" : "commands"; case "tool": return hasTimelineExplorationIntent(row) ? "exploration" : "tools"; + case "file-read": + case "search": + return "exploration"; + case "plan-steps": + return "planUpdates"; + case "extension": + return "extensions"; case "file-change": return "fileChanges"; case "web-fetch": @@ -499,6 +532,14 @@ function completedSummaryPhrase( : null; case "tools": return counts.tools > 0 ? `Ran ${plural(counts.tools, "tool")}` : null; + case "planUpdates": + return counts.planUpdates > 0 + ? `Updated plan ${plural(counts.planUpdates, "time")}` + : null; + case "extensions": + return counts.extensions > 0 + ? `Ran ${plural(counts.extensions, "plugin step")}` + : null; default: return assertNever(category); } @@ -530,6 +571,12 @@ function activeSummaryPhrase( return counts.tools > 0 ? `Running ${plural(counts.tools, "tool")}` : null; + case "planUpdates": + return counts.planUpdates > 0 ? "Updating plan" : null; + case "extensions": + return counts.extensions > 0 + ? `Running ${plural(counts.extensions, "plugin step")}` + : null; default: return assertNever(category); } @@ -716,6 +763,13 @@ function rowConcept(row: TimelineViewWorkRow): TimelineWorkSummaryCategory { : row.workKind === "command" ? "commands" : "tools"; + case "file-read": + case "search": + return "exploration"; + case "plan-steps": + return "planUpdates"; + case "extension": + return "extensions"; case "file-change": return "fileChanges"; case "delegation": @@ -756,6 +810,18 @@ function dedupeBundleChildIntents( let lastEmittedKey: string | null = null; const out: TimelineViewWorkRow[] = []; for (const child of children) { + if (child.workKind === "file-read" || child.workKind === "search") { + // A v3 exploration row carries exactly one intent; a duplicate of the + // previous sibling's collapses the whole row. + const [intent] = timelineRowActivityIntents(child); + const key = intent ? getTimelineActivityIntentDetailDedupeKey(intent) : null; + if (key !== null && key === lastEmittedKey) { + continue; + } + lastEmittedKey = key; + out.push(child); + continue; + } if ( (child.workKind !== "command" && child.workKind !== "tool") || child.activityIntents.length === 0 diff --git a/packages/thread-view/src/tool-activity-cells.ts b/packages/thread-view/src/tool-activity-cells.ts index 586f44918c..3a81e2ee41 100644 --- a/packages/thread-view/src/tool-activity-cells.ts +++ b/packages/thread-view/src/tool-activity-cells.ts @@ -1,8 +1,12 @@ import type { EventProjectionCommandMessage, EventProjectionDelegationMessage, + EventProjectionExtensionMessage, + EventProjectionFileReadMessage, EventProjectionImageViewMessage, EventProjectionMessage, + EventProjectionPlanStepsMessage, + EventProjectionSearchMessage, EventProjectionToolCallMessage, EventProjectionWebFetchMessage, EventProjectionWebSearchMessage, @@ -12,10 +16,18 @@ export type ViewProviderExecutionMessage = | EventProjectionCommandMessage | EventProjectionToolCallMessage | EventProjectionDelegationMessage; +/** + * The begin/end item family (see web-activity-lifecycle.ts): web research, + * the v3 exploration kinds, plan snapshots and plugin extension items. + */ export type ViewWebActivityMessage = | EventProjectionWebSearchMessage | EventProjectionWebFetchMessage - | EventProjectionImageViewMessage; + | EventProjectionImageViewMessage + | EventProjectionFileReadMessage + | EventProjectionSearchMessage + | EventProjectionPlanStepsMessage + | EventProjectionExtensionMessage; export type ToolActivityCell = | ViewProviderExecutionMessage | ViewWebActivityMessage; @@ -65,7 +77,11 @@ export function isWebActivityMessage( return ( cell?.kind === "web-search" || cell?.kind === "web-fetch" || - cell?.kind === "image-view" + cell?.kind === "image-view" || + cell?.kind === "file-read" || + cell?.kind === "search" || + cell?.kind === "plan-steps" || + cell?.kind === "extension" ); } diff --git a/packages/thread-view/src/tool-activity-projection.ts b/packages/thread-view/src/tool-activity-projection.ts index 58b557affc..bec4fb73e1 100644 --- a/packages/thread-view/src/tool-activity-projection.ts +++ b/packages/thread-view/src/tool-activity-projection.ts @@ -1,4 +1,8 @@ -import type { JsonObject, ThreadEventScope } from "@bb/domain"; +import type { + JsonObject, + ThreadEventItemPresentation, + ThreadEventScope, +} from "@bb/domain"; import type { EventProjectionApprovalLifecycleStatus, EventProjectionMessage, @@ -31,6 +35,7 @@ import { findExecMessageInHistoryCells, flushActiveToolCell, isProviderExecutionMessage, + isWebActivityMessage, type ToolActivityCell, type ViewProviderExecutionMessage, type ViewWebActivityMessage, @@ -71,6 +76,7 @@ interface RunningExecutionBase { completedAt: number | null; status: ViewProviderExecutionMessage["status"]; outputBuffer: VisibleTextBuffer; + presentation?: ThreadEventItemPresentation; } interface PendingExecutionOutput { @@ -109,6 +115,8 @@ interface RunningToolCallExecution extends RunningExecutionBase { interface RunningDelegationExecution extends RunningExecutionBase { kind: "delegation"; toolName: string | null; + childRef: string | null; + background: boolean; subagentType?: string; description?: string; model?: string; @@ -294,6 +302,9 @@ function createRunningExecutionBase({ ...(incoming.parentToolCallId ? { parentToolCallId: incoming.parentToolCallId } : {}), + ...(incoming.presentation + ? { presentation: incoming.presentation } + : {}), output: getVisibleTextBufferText(outputBuffer) ?? "", completedAt: incoming.completedAt ?? null, status: incoming.status ?? "pending", @@ -347,6 +358,8 @@ function createRunningExecCall( ...base, kind: "delegation", toolName: incoming.toolName ?? null, + childRef: incoming.childRef ?? null, + background: incoming.background ?? false, subagentType: incoming.subagentType, description: incoming.description, model: incoming.model, @@ -391,6 +404,8 @@ interface ToolCallExecutionFieldsSource { } interface DelegationExecutionFieldsTarget { + background: boolean; + childRef: string | null; description?: string; model?: string; subagentType?: string; @@ -398,12 +413,36 @@ interface DelegationExecutionFieldsTarget { } interface DelegationExecutionFieldsSource { + background?: boolean; + childRef?: string | null; description?: string; model?: string; subagentType?: string; toolName?: string | null; } +interface PresentedExecutionFieldsTarget { + presentation?: ThreadEventItemPresentation; +} + +interface PresentedExecutionFieldsSource { + presentation?: ThreadEventItemPresentation; +} + +/** + * The latest presentation wins: the assembler echoes the opened presentation + * onto a close that carries none, so whatever an end event carries is the + * bridge's final word on the row. + */ +function mergePresentation( + target: PresentedExecutionFieldsTarget, + incoming: PresentedExecutionFieldsSource, +): void { + if (incoming.presentation) { + target.presentation = incoming.presentation; + } +} + function mergeCommandExecutionFields( target: CommandExecutionFieldsTarget, incoming: CommandExecutionFieldsSource, @@ -455,10 +494,21 @@ function mergeDelegationExecutionFields( if (incoming.toolName && !target.toolName) { target.toolName = incoming.toolName; } + if (incoming.childRef && !target.childRef) { + target.childRef = incoming.childRef; + } + if (incoming.background === true) { + target.background = true; + } if (incoming.subagentType && !target.subagentType) { target.subagentType = incoming.subagentType; } - if (incoming.description && !target.description) { + // A v3 delegation's progress snapshots revise the label; a legacy + // delegation's description is set once from its arguments. + if ( + incoming.description && + (!target.description || incoming.childRef !== undefined) + ) { target.description = incoming.description; } if (incoming.model && !target.model) { @@ -485,6 +535,7 @@ function mergeRunningExecutionMetadata( existing: RunningExecCall, incoming: ProviderExecutionUpdate, ): void { + mergePresentation(existing, incoming); switch (incoming.kind) { case "command": if (existing.kind !== "command") return; @@ -713,6 +764,10 @@ function interruptPendingToolMessage( case "web-search": case "web-fetch": case "image-view": + case "file-read": + case "search": + case "plan-steps": + case "extension": if (message.status === "pending") { message.status = "interrupted"; message.completedAt = completedAt; @@ -724,12 +779,7 @@ function interruptPendingToolMessage( function isInterruptibleToolMessage( message: EventProjectionMessage, ): message is InterruptibleToolMessage { - return ( - isProviderExecutionMessage(message) || - message.kind === "web-search" || - message.kind === "web-fetch" || - message.kind === "image-view" - ); + return isProviderExecutionMessage(message) || isWebActivityMessage(message); } type ExecutionMergeTarget = RunningExecCall | ViewProviderExecutionMessage; @@ -772,6 +822,7 @@ function mergeExecutionSummary( `Cannot merge ${target.kind} with ${incoming.kind} for call ${incoming.callId}`, ); } + mergePresentation(target, incoming); switch (incoming.kind) { case "command": if (target.kind !== "command") return; @@ -908,6 +959,7 @@ function createExecMessage( ...(call.parentToolCallId ? { parentToolCallId: call.parentToolCallId } : {}), + ...(call.presentation ? { presentation: call.presentation } : {}), callId: call.callId, output: call.output, completedAt: call.completedAt, @@ -932,6 +984,8 @@ function createExecMessage( ...base, kind: "delegation", toolName: call.toolName ?? "Agent", + childRef: call.childRef, + background: call.background, subagentType: call.subagentType, description: call.description, model: call.model, diff --git a/packages/thread-view/src/tool-activity-web-projection.ts b/packages/thread-view/src/tool-activity-web-projection.ts index 971d7de096..a1577a1792 100644 --- a/packages/thread-view/src/tool-activity-web-projection.ts +++ b/packages/thread-view/src/tool-activity-web-projection.ts @@ -1,6 +1,7 @@ import type { EventMeta } from "./event-decode.js"; import type { ToolActivityProjectionState } from "./tool-activity-projection.js"; import type { WebActivityLifecycleEvent } from "./web-activity-lifecycle.js"; +import { itemStatusToExecStatus } from "./exec-lifecycle.js"; import { messageId } from "./format-helpers.js"; import { areThreadEventScopesEqual, @@ -20,6 +21,28 @@ function buildWebActivityKey(kind: WebActivityKind, callId: string): string { return `${kind}:${callId}`; } +/** + * The settled status of a begin/end item. The web kinds carry no status and + * settle as completed; the v3 kinds carry the item's own terminal status. + */ +function settledStatus( + payload: WebActivityLifecycleEvent, +): ViewWebActivityMessage["status"] { + switch (payload.itemKind) { + case "web-search": + case "web-fetch": + case "image-view": + return "completed"; + case "file-read": + case "search": + case "plan-steps": + case "extension": { + const status = itemStatusToExecStatus(payload.status); + return status === "pending" ? "completed" : status; + } + } +} + function createWebActivityMessage( threadId: string, meta: EventMeta, @@ -27,53 +50,8 @@ function createWebActivityMessage( payload: WebActivityLifecycleEvent, status: ViewWebActivityMessage["status"], ): ViewWebActivityMessage { - if (payload.itemKind === "web-search") { - return { - kind: "web-search", - id: messageId(threadId, "web-search", payload.callId), - threadId, - sourceSeqStart: meta.seq, - sourceSeqEnd: meta.seq, - createdAt: meta.createdAt, - startedAt: meta.createdAt, - ...(turnId - ? eventProjectionMessageTurnScopeFields(turnId) - : eventProjectionMessageThreadScopeFields()), - ...(payload.parentToolCallId - ? { parentToolCallId: payload.parentToolCallId } - : {}), - callId: payload.callId, - queries: payload.queries, - completedAt: status === "pending" ? null : meta.createdAt, - status, - }; - } - - if (payload.itemKind === "image-view") { - return { - kind: "image-view", - id: messageId(threadId, "image-view", payload.callId), - threadId, - sourceSeqStart: meta.seq, - sourceSeqEnd: meta.seq, - createdAt: meta.createdAt, - startedAt: meta.createdAt, - ...(turnId - ? eventProjectionMessageTurnScopeFields(turnId) - : eventProjectionMessageThreadScopeFields()), - ...(payload.parentToolCallId - ? { parentToolCallId: payload.parentToolCallId } - : {}), - callId: payload.callId, - path: payload.path, - completedAt: status === "pending" ? null : meta.createdAt, - status, - }; - } - - return { - kind: "web-fetch", - id: messageId(threadId, "web-fetch", payload.callId), + const base = { + id: messageId(threadId, payload.itemKind, payload.callId), threadId, sourceSeqStart: meta.seq, sourceSeqEnd: meta.seq, @@ -85,13 +63,71 @@ function createWebActivityMessage( ...(payload.parentToolCallId ? { parentToolCallId: payload.parentToolCallId } : {}), + ...(payload.presentation ? { presentation: payload.presentation } : {}), callId: payload.callId, - url: payload.url, - prompt: payload.prompt, - pattern: payload.pattern, completedAt: status === "pending" ? null : meta.createdAt, - status, }; + + switch (payload.itemKind) { + case "web-search": + return { + ...base, + kind: "web-search", + queries: payload.queries, + status: status === "error" ? "completed" : status, + }; + case "image-view": + return { + ...base, + kind: "image-view", + path: payload.path, + status: status === "error" ? "completed" : status, + }; + case "web-fetch": + return { + ...base, + kind: "web-fetch", + url: payload.url, + prompt: payload.prompt, + pattern: payload.pattern, + status: status === "error" ? "completed" : status, + }; + case "file-read": + return { + ...base, + kind: "file-read", + path: payload.path, + cmd: payload.cmd, + status, + }; + case "search": + return { + ...base, + kind: "search", + mode: payload.mode, + query: payload.query, + path: payload.path, + cmd: payload.cmd, + status, + }; + case "plan-steps": + return { + ...base, + kind: "plan-steps", + steps: payload.steps, + explanation: payload.explanation, + status, + }; + case "extension": + return { + ...base, + kind: "extension", + extensionKind: payload.extensionKind, + payload: payload.payload, + presentation: payload.presentation, + status, + }; + } } function mergeWebActivityMessage( @@ -113,6 +149,12 @@ function mergeWebActivityMessage( if (!target.parentToolCallId && payload.parentToolCallId) { target.parentToolCallId = payload.parentToolCallId; } + // The close's presentation wins over the opened one (the assembler echoes + // the open presentation onto a close that carries none, so a close always + // has the latest). + if (payload.presentation) { + target.presentation = payload.presentation; + } if (target.kind === "web-search" && payload.itemKind === "web-search") { target.queries = payload.queries; @@ -128,7 +170,53 @@ function mergeWebActivityMessage( if (target.kind === "image-view" && payload.itemKind === "image-view") { target.path = payload.path; + return; + } + + if (target.kind === "file-read" && payload.itemKind === "file-read") { + target.path = payload.path; + target.cmd = payload.cmd; + return; + } + + if (target.kind === "search" && payload.itemKind === "search") { + target.mode = payload.mode; + target.query = payload.query; + target.path = payload.path; + target.cmd = payload.cmd; + return; + } + + if (target.kind === "plan-steps" && payload.itemKind === "plan-steps") { + target.steps = payload.steps; + target.explanation = payload.explanation; + return; + } + + if (target.kind === "extension" && payload.itemKind === "extension") { + target.extensionKind = payload.extensionKind; + target.payload = payload.payload; + target.presentation = payload.presentation; + } +} + +function settleWebActivityMessage( + target: ViewWebActivityMessage, + meta: EventMeta, + payload: WebActivityLifecycleEvent, +): void { + const status = settledStatus(payload); + // The web kinds never fail; keep their narrower status union honest. + if ( + target.kind === "web-search" || + target.kind === "web-fetch" || + target.kind === "image-view" + ) { + target.status = status === "error" ? "completed" : status; + } else { + target.status = status; } + target.completedAt = meta.createdAt; } export function onWebActivityBegin( @@ -200,8 +288,7 @@ export function onWebActivityEnd( active.callId === payload.callId ) { mergeWebActivityMessage(active, meta, turnId, payload); - active.status = "completed"; - active.completedAt = meta.createdAt; + settleWebActivityMessage(active, meta, payload); flushActiveToolCell(state); state.toolActivity.finalizedWebActivityCallIds.add(activityKey); return; @@ -225,8 +312,7 @@ export function onWebActivityEnd( }); if (historyMatch) { mergeWebActivityMessage(historyMatch, meta, turnId, payload); - historyMatch.status = "completed"; - historyMatch.completedAt = meta.createdAt; + settleWebActivityMessage(historyMatch, meta, payload); state.toolActivity.finalizedWebActivityCallIds.add(activityKey); return; } @@ -236,7 +322,7 @@ export function onWebActivityEnd( meta, turnId, payload, - "completed", + settledStatus(payload), ); completedMessage.id = messageId( threadId, diff --git a/packages/thread-view/src/tool-call-suppression.ts b/packages/thread-view/src/tool-call-suppression.ts index 5c8f695131..5a4989c253 100644 --- a/packages/thread-view/src/tool-call-suppression.ts +++ b/packages/thread-view/src/tool-call-suppression.ts @@ -14,27 +14,40 @@ const SUPPRESSED_TIMELINE_TOOL_NAMES = new Set([ ]); /** - * A low-value tool call row: one the bridge marked `suppress` in its - * presentation (grammar v3 — the bridge owns its tools' presentation), or, - * for events persisted before presentation existed, one of the legacy names - * above. Failed and interrupted calls always render. + * 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. */ export function shouldSuppressLowValueToolCall(decoded: ThreadEvent): boolean { - if ( - (decoded.type !== "item/started" && decoded.type !== "item/completed") || - decoded.item.type !== "toolCall" - ) { + if (decoded.type !== "item/started" && decoded.type !== "item/completed") { return false; } - - if ( - decoded.item.presentation?.suppress !== true && - !SUPPRESSED_TIMELINE_TOOL_NAMES.has(decoded.item.tool) - ) { - return false; + 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": + case "extension": + case "delegation": + case "fileChange": + if (item.presentation?.suppress !== true) { + return false; + } + break; + default: + return false; } - return ( - decoded.item.status === "pending" || decoded.item.status === "completed" - ); + return item.status === "pending" || item.status === "completed"; } diff --git a/packages/thread-view/src/web-activity-lifecycle.ts b/packages/thread-view/src/web-activity-lifecycle.ts index 61fbd8c490..c7ed809ad4 100644 --- a/packages/thread-view/src/web-activity-lifecycle.ts +++ b/packages/thread-view/src/web-activity-lifecycle.ts @@ -1,92 +1,173 @@ -import type { ThreadEvent } from "@bb/domain"; +import type { + ExtensionKind, + JsonValue, + ThreadEvent, + ThreadEventItemPresentation, + ThreadEventItemStatus, + ThreadEventPlanStep, + ThreadEventSearchMode, +} from "@bb/domain"; import { getEventParentToolCallId } from "./event-decode.js"; -export interface WebSearchLifecycleEvent { +/** + * The begin/end item family: items with no streamed output whose whole row + * comes from the opened item plus its settled counterpart. Historically the + * web kinds (webSearch, webFetch, imageView); grammar v3 adds the + * exploration kinds (fileRead, search), plan snapshots (planSteps) and + * plugin extension items. Commands, tool calls and delegations stream + * output and take the exec-lifecycle path instead. + */ +interface ItemActivityLifecycleBase { kind: "begin" | "end"; - itemKind: "web-search"; callId: string; - queries: string[]; parentToolCallId?: string; + /** The bridge's declarative presentation; absent on pre-v3 events. */ + presentation?: ThreadEventItemPresentation; } -export interface WebFetchLifecycleEvent { - kind: "begin" | "end"; +export interface WebSearchLifecycleEvent extends ItemActivityLifecycleBase { + itemKind: "web-search"; + queries: string[]; +} + +export interface WebFetchLifecycleEvent extends ItemActivityLifecycleBase { itemKind: "web-fetch"; - callId: string; url: string; prompt: string | null; pattern: string | null; - parentToolCallId?: string; } -interface ImageViewLifecycleEvent { - kind: "begin" | "end"; +interface ImageViewLifecycleEvent extends ItemActivityLifecycleBase { itemKind: "image-view"; - callId: string; path: string; - parentToolCallId?: string; +} + +/** + * The v3 kinds carry an explicit item status, so a failed or interrupted + * read/search/extension settles with that status instead of `completed`. + */ +interface StatusedItemActivityLifecycleBase extends ItemActivityLifecycleBase { + status: ThreadEventItemStatus; +} + +export interface FileReadLifecycleEvent extends StatusedItemActivityLifecycleBase { + itemKind: "file-read"; + path: string; + cmd: string | null; +} + +export interface SearchLifecycleEvent extends StatusedItemActivityLifecycleBase { + itemKind: "search"; + mode: ThreadEventSearchMode; + query: string; + path: string | null; + cmd: string | null; +} + +export interface PlanStepsLifecycleEvent extends StatusedItemActivityLifecycleBase { + itemKind: "plan-steps"; + steps: ThreadEventPlanStep[]; + explanation: string | null; +} + +export interface ExtensionLifecycleEvent extends StatusedItemActivityLifecycleBase { + itemKind: "extension"; + extensionKind: ExtensionKind; + payload: JsonValue; + presentation: ThreadEventItemPresentation; } export type WebActivityLifecycleEvent = | WebSearchLifecycleEvent | WebFetchLifecycleEvent - | ImageViewLifecycleEvent; + | ImageViewLifecycleEvent + | FileReadLifecycleEvent + | SearchLifecycleEvent + | PlanStepsLifecycleEvent + | ExtensionLifecycleEvent; export function parseWebActivityLifecycleEvent( decoded: ThreadEvent, parentToolCallIdOverride?: string, ): WebActivityLifecycleEvent | null { + if (decoded.type !== "item/started" && decoded.type !== "item/completed") { + return null; + } const parentToolCallId = parentToolCallIdOverride ?? getEventParentToolCallId(decoded); - if ( - (decoded.type === "item/started" || decoded.type === "item/completed") && - decoded.item.type === "webSearch" - ) { - const callId = decoded.item.id; - if (!callId) return null; + const item = decoded.item; + const callId = item.id; + if (!callId) return null; + const kind = decoded.type === "item/started" ? "begin" : "end"; + const base = { + kind, + callId, + ...(parentToolCallId ? { parentToolCallId } : {}), + } as const; - return { - kind: decoded.type === "item/started" ? "begin" : "end", - itemKind: "web-search", - callId, - queries: decoded.item.queries, - ...(parentToolCallId ? { parentToolCallId } : {}), - }; + switch (item.type) { + case "webSearch": + return { + ...base, + itemKind: "web-search", + queries: item.queries, + ...(item.presentation ? { presentation: item.presentation } : {}), + }; + case "webFetch": + return { + ...base, + itemKind: "web-fetch", + url: item.url, + prompt: item.prompt, + pattern: item.pattern, + ...(item.presentation ? { presentation: item.presentation } : {}), + }; + case "imageView": + return { + ...base, + itemKind: "image-view", + path: item.path, + ...(item.presentation ? { presentation: item.presentation } : {}), + }; + case "fileRead": + return { + ...base, + itemKind: "file-read", + path: item.path, + cmd: item.cmd ?? null, + status: item.status, + ...(item.presentation ? { presentation: item.presentation } : {}), + }; + case "search": + return { + ...base, + itemKind: "search", + mode: item.mode, + query: item.query, + path: item.path ?? null, + cmd: item.cmd ?? null, + status: item.status, + ...(item.presentation ? { presentation: item.presentation } : {}), + }; + case "planSteps": + return { + ...base, + itemKind: "plan-steps", + steps: item.steps, + explanation: item.explanation ?? null, + status: item.status, + ...(item.presentation ? { presentation: item.presentation } : {}), + }; + case "extension": + return { + ...base, + itemKind: "extension", + extensionKind: item.kind, + payload: item.payload, + status: item.status, + presentation: item.presentation, + }; + default: + return null; } - - if ( - (decoded.type === "item/started" || decoded.type === "item/completed") && - decoded.item.type === "webFetch" - ) { - const callId = decoded.item.id; - if (!callId) return null; - - return { - kind: decoded.type === "item/started" ? "begin" : "end", - itemKind: "web-fetch", - callId, - url: decoded.item.url, - prompt: decoded.item.prompt, - pattern: decoded.item.pattern, - ...(parentToolCallId ? { parentToolCallId } : {}), - }; - } - - if ( - (decoded.type === "item/started" || decoded.type === "item/completed") && - decoded.item.type === "imageView" - ) { - const callId = decoded.item.id; - if (!callId) return null; - - return { - kind: decoded.type === "item/started" ? "begin" : "end", - itemKind: "image-view", - callId, - path: decoded.item.path, - ...(parentToolCallId ? { parentToolCallId } : {}), - }; - } - - return null; } diff --git a/packages/thread-view/test/timeline-row-title.test.ts b/packages/thread-view/test/timeline-row-title.test.ts index 1943d1886f..81e3ca8edc 100644 --- a/packages/thread-view/test/timeline-row-title.test.ts +++ b/packages/thread-view/test/timeline-row-title.test.ts @@ -269,6 +269,8 @@ function delegationRow(): TimelineViewDelegationWorkRow { status: "completed", callId: "delegation-call-1", toolName: "spawnAgent", + childRef: null, + background: false, subagentType: "general-purpose-review-agent-with-a-long-name", description: "Review correctness + plan adherence", output: "", diff --git a/packages/thread-view/test/timeline-test-harness.ts b/packages/thread-view/test/timeline-test-harness.ts index 071dc1a211..1fbfc883b0 100644 --- a/packages/thread-view/test/timeline-test-harness.ts +++ b/packages/thread-view/test/timeline-test-harness.ts @@ -17,6 +17,7 @@ import type { ThreadEventRowOfType, SystemThreadInterruptedReason, ThreadEventWarningCategory, + ThreadTimelinePendingTodos, ThreadTurnInitiator, TurnRequestTarget, } from "@bb/domain"; @@ -48,6 +49,7 @@ export interface RenderTimelineFixtureArgs { } export interface RenderedTimelineFixture { + pendingTodos: ThreadTimelinePendingTodos | null; events: ThreadEventRow[]; messages: EventProjectionMessage[]; projection: EventProjection; @@ -140,6 +142,7 @@ interface FileReadEventArgs extends ProviderTurnEventOptions { itemId?: string; path: string; cmd?: string; + presentation?: ThreadEventItemPresentation; status?: "pending" | "completed" | "failed" | "interrupted"; } @@ -149,6 +152,7 @@ interface SearchEventArgs extends ProviderTurnEventOptions { query: string; path?: string; cmd?: string; + presentation?: ThreadEventItemPresentation; status?: "pending" | "completed" | "failed" | "interrupted"; } @@ -158,6 +162,26 @@ interface DelegationEventArgs extends ProviderTurnEventOptions { label: string; background?: boolean; summary?: string; + presentation?: ThreadEventItemPresentation; + status?: "pending" | "completed" | "failed" | "interrupted"; +} + +interface PlanStepsEventArgs extends ProviderTurnEventOptions { + itemId?: string; + steps: Array<{ + step: string; + status?: "pending" | "active" | "completed" | "failed"; + }>; + explanation?: string; + presentation?: ThreadEventItemPresentation; + status?: "pending" | "completed" | "failed" | "interrupted"; +} + +interface ExtensionEventArgs extends ProviderTurnEventOptions { + itemId?: string; + kind: `${string}/${string}`; + payload: JsonValue; + presentation: ThreadEventItemPresentation; status?: "pending" | "completed" | "failed" | "interrupted"; } @@ -377,6 +401,18 @@ export interface TimelineEventFactory { searchCompleted( args: SearchEventArgs, ): ThreadEventRowOfType<"item/completed">; + planStepsStarted( + args: PlanStepsEventArgs, + ): ThreadEventRowOfType<"item/started">; + planStepsCompleted( + args: PlanStepsEventArgs, + ): ThreadEventRowOfType<"item/completed">; + extensionStarted( + args: ExtensionEventArgs, + ): ThreadEventRowOfType<"item/started">; + extensionCompleted( + args: ExtensionEventArgs, + ): ThreadEventRowOfType<"item/completed">; threadCompacted( args?: ProviderTurnEventOptions, ): ThreadEventRowOfType<"thread/compacted">; @@ -908,6 +944,9 @@ export function createTimelineEventFactory( status: args.status ?? "pending", background: args.background ?? false, ...(args.summary === undefined ? {} : { summary: args.summary }), + ...(args.presentation === undefined + ? {} + : { presentation: args.presentation }), }, }, }; @@ -927,6 +966,9 @@ export function createTimelineEventFactory( status: args.status ?? "completed", background: args.background ?? false, ...(args.summary === undefined ? {} : { summary: args.summary }), + ...(args.presentation === undefined + ? {} + : { presentation: args.presentation }), }, }, }; @@ -966,6 +1008,9 @@ export function createTimelineEventFactory( path: args.path, ...(args.cmd === undefined ? {} : { cmd: args.cmd }), status: args.status ?? "pending", + ...(args.presentation === undefined + ? {} + : { presentation: args.presentation }), }, }, }; @@ -983,6 +1028,9 @@ export function createTimelineEventFactory( path: args.path, ...(args.cmd === undefined ? {} : { cmd: args.cmd }), status: args.status ?? "completed", + ...(args.presentation === undefined + ? {} + : { presentation: args.presentation }), }, }, }; @@ -1002,6 +1050,9 @@ export function createTimelineEventFactory( ...(args.path === undefined ? {} : { path: args.path }), ...(args.cmd === undefined ? {} : { cmd: args.cmd }), status: args.status ?? "pending", + ...(args.presentation === undefined + ? {} + : { presentation: args.presentation }), }, }, }; @@ -1021,6 +1072,89 @@ export function createTimelineEventFactory( ...(args.path === undefined ? {} : { path: args.path }), ...(args.cmd === undefined ? {} : { cmd: args.cmd }), status: args.status ?? "completed", + ...(args.presentation === undefined + ? {} + : { presentation: args.presentation }), + }, + }, + }; + }, + planStepsStarted(args) { + const base = nextProviderTurnScopedRowBase("plan-steps-started", args); + return { + ...base, + type: "item/started", + data: { + ...providerFields(args), + item: { + type: "planSteps", + id: args.itemId ?? `plan-steps-${base.seq}`, + steps: args.steps, + ...(args.explanation === undefined + ? {} + : { explanation: args.explanation }), + status: args.status ?? "pending", + ...(args.presentation === undefined + ? {} + : { presentation: args.presentation }), + }, + }, + }; + }, + planStepsCompleted(args) { + const base = nextProviderTurnScopedRowBase("plan-steps-completed", args); + return { + ...base, + type: "item/completed", + data: { + ...providerFields(args), + item: { + type: "planSteps", + id: args.itemId ?? `plan-steps-${base.seq}`, + steps: args.steps, + ...(args.explanation === undefined + ? {} + : { explanation: args.explanation }), + status: args.status ?? "completed", + ...(args.presentation === undefined + ? {} + : { presentation: args.presentation }), + }, + }, + }; + }, + extensionStarted(args) { + const base = nextProviderTurnScopedRowBase("extension-started", args); + return { + ...base, + type: "item/started", + data: { + ...providerFields(args), + item: { + type: "extension", + id: args.itemId ?? `extension-${base.seq}`, + kind: args.kind, + payload: args.payload, + status: args.status ?? "pending", + presentation: args.presentation, + }, + }, + }; + }, + extensionCompleted(args) { + const base = nextProviderTurnScopedRowBase("extension-completed", args); + return { + ...base, + type: "item/completed", + data: { + ...providerFields(args), + item: { + type: "extension", + id: args.itemId ?? `extension-${base.seq}`, + kind: args.kind, + payload: args.payload, + status: args.status ?? "completed", + presentation: args.presentation, }, }, }; @@ -1242,6 +1376,7 @@ export function renderTimelineFixture( return { events: args.events, messages, + pendingTodos: timeline.pendingTodos, projection, rows, text, diff --git a/packages/thread-view/test/timeline-view.test.ts b/packages/thread-view/test/timeline-view.test.ts index c80229dba0..6d837be0dc 100644 --- a/packages/thread-view/test/timeline-view.test.ts +++ b/packages/thread-view/test/timeline-view.test.ts @@ -227,6 +227,8 @@ function delegationRow({ status, callId, toolName: "spawnAgent", + childRef: null, + background: false, subagentType: "reviewer", description: "Review timeline grouping", output: "", diff --git a/packages/thread-view/test/v3-item-projection.test.ts b/packages/thread-view/test/v3-item-projection.test.ts index 988f5ab2b2..2461c10def 100644 --- a/packages/thread-view/test/v3-item-projection.test.ts +++ b/packages/thread-view/test/v3-item-projection.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; -import type { TimelineRow } from "@bb/server-contract"; +import type { TimelineRow, TimelineWorkRow } from "@bb/server-contract"; +import type { ThreadTimelineViewRow } from "../src/index.js"; import type { ThreadEventItemPresentation } from "@bb/domain"; +import { + buildTimelineActivityIntentTitles, + buildTimelineRowTitle, + buildTimelineViewRows, + timelineRowActivityIntents, +} from "../src/index.js"; import { createTimelineEventFactory, renderTimelineFixture, @@ -10,35 +17,96 @@ function flattenRows(rows: readonly TimelineRow[]): TimelineRow[] { return rows.flatMap((row) => row.kind === "turn" && row.children ? [row, ...flattenRows(row.children)] - : [row], + : row.kind === "work" && row.workKind === "delegation" + ? [row, ...flattenRows(row.childRows)] + : [row], + ); +} + +function workRows(rows: readonly TimelineRow[]): TimelineWorkRow[] { + return flattenRows(rows).filter( + (row): row is TimelineWorkRow => row.kind === "work", ); } -function activityIntents(rows: readonly TimelineRow[]) { - return flattenRows(rows).flatMap((row) => - row.kind === "work" && row.workKind === "tool" - ? row.activityIntents.map((intent) => ({ - ...intent, - status: row.status, - })) - : [], +function workRow( + rows: readonly TimelineRow[], + workKind: K, + callId: string, +): Extract { + const row = workRows(rows).find( + (candidate): candidate is Extract => + candidate.workKind === workKind && + "callId" in candidate && + candidate.callId === callId, ); + if (!row) { + throw new Error(`no ${workKind} row for ${callId}`); + } + return row; +} + +function plainTitle(row: ThreadTimelineViewRow): string { + return buildTimelineRowTitle(row, { + summaryStyle: "bundle", + workStyle: "default", + }).plain; } const READ_PRESENTATION: ThreadEventItemPresentation = { + label: { pending: "Reading file", completed: "Read file" }, + icon: { glyph: "FileText" }, + title: "index.ts", +}; + +const SEARCH_PRESENTATION: ThreadEventItemPresentation = { + label: { pending: "Searching files", completed: "Searched files" }, + icon: { glyph: "Search" }, + title: "TODO", +}; + +const SUPPRESSED_TOOL_PRESENTATION: ThreadEventItemPresentation = { label: { pending: "Searching tools", completed: "Searched tools" }, icon: { glyph: "Toolbox" }, suppress: true, }; +const JS_PRESENTATION: ThreadEventItemPresentation = { + label: { pending: "Running JavaScript", completed: "Ran JavaScript" }, + icon: { glyph: "Code" }, + title: "Compute primes", + detail: "Sieve of Eratosthenes up to 10k", +}; + +const ECHO_PRESENTATION: ThreadEventItemPresentation = { + label: { pending: "Echoing", completed: "Echoed" }, + icon: { glyph: "MessageSquare" }, + title: "hello", + detail: "**hello** world", + tint: { light: "#1d4ed8", dark: "#93c5fd" }, +}; + +const PLAN_PRESENTATION: ThreadEventItemPresentation = { + label: { pending: "Updating plan", completed: "Updated plan" }, + icon: { glyph: "ListTodo" }, + title: "Wire the renderer", +}; + +const SUBAGENT_PRESENTATION: ThreadEventItemPresentation = { + label: { pending: "Running subagent", completed: "Subagent finished" }, + icon: { glyph: "UserRound" }, + title: "Review the diff", + detail: "reviewer agent · model opus", +}; + /** - * Grammar v3 `fileRead` and `search` items project to the same activity - * intents the legacy structured Read/Grep/Glob tool calls produced, so a - * migrated bridge's reads and searches keep rendering as "Read x" / - * "Searched for y" bundles until the presentation-driven projection lands. + * Grammar v3 items project to rows of their own kind that carry the + * bridge's presentation; the exploration kinds derive the same activity + * intents the legacy structured tool calls produced, so bundles, dedupe and + * compact rendering treat a v3 read and a legacy `Read` identically. */ -describe("v3 exploration item projection", () => { - it("renders fileRead and search items with the legacy tool calls' intents", () => { +describe("v3 item projection", () => { + it("projects fileRead and search items to file-read and search rows with the legacy intents", () => { const event = createTimelineEventFactory({ threadId: "thread-1" }); const v3 = renderTimelineFixture({ events: [ @@ -47,12 +115,14 @@ describe("v3 exploration item projection", () => { turnId: "turn-1", itemId: "read-1", path: "src/index.ts", + presentation: READ_PRESENTATION, createdAt: 1_000, }), event.fileReadCompleted({ turnId: "turn-1", itemId: "read-1", path: "src/index.ts", + presentation: READ_PRESENTATION, createdAt: 2_000, }), event.searchStarted({ @@ -61,6 +131,7 @@ describe("v3 exploration item projection", () => { mode: "content", query: "TODO", path: "src", + presentation: SEARCH_PRESENTATION, createdAt: 3_000, }), event.searchCompleted({ @@ -69,6 +140,7 @@ describe("v3 exploration item projection", () => { mode: "content", query: "TODO", path: "src", + presentation: SEARCH_PRESENTATION, createdAt: 4_000, }), event.searchStarted({ @@ -141,95 +213,361 @@ describe("v3 exploration item projection", () => { projectionOptions: { threadStatus: "idle", turnMessageDetail: "full" }, }); - // The command text and the read intent's tool name differ by design (a - // v3 item has no tool); everything the bundles render from is equal. - const strip = (intents: ReturnType) => - intents.map(({ command: _command, ...rest }) => - rest.type === "read" ? { ...rest, name: "" } : rest, + const read = workRow(v3.rows, "file-read", "read-1"); + expect(read).toMatchObject({ + path: "src/index.ts", + cmd: null, + status: "completed", + completedAt: 2_000, + presentation: READ_PRESENTATION, + }); + const grep = workRow(v3.rows, "search", "grep-1"); + expect(grep).toMatchObject({ + mode: "content", + query: "TODO", + path: "src", + presentation: SEARCH_PRESENTATION, + }); + 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[]) => + rows.flatMap((row) => + row.workKind === "tool" || + row.workKind === "file-read" || + row.workKind === "search" + ? timelineRowActivityIntents(row).map( + ({ command: _command, ...intent }) => + intent.type === "read" ? { ...intent, name: "" } : intent, + ) + : [], ); - expect(activityIntents(v3.rows)).toHaveLength(3); - expect(strip(activityIntents(v3.rows))).toEqual( - strip(activityIntents(legacy.rows)), - ); - expect(activityIntents(v3.rows)).toEqual([ - expect.objectContaining({ - type: "read", - path: "src/index.ts", - status: "completed", - }), - expect.objectContaining({ - type: "search", - query: "TODO", - path: "src", - status: "completed", - }), - expect.objectContaining({ - type: "list_files", - path: "src", - status: "completed", - }), - ]); + expect(strip(workRows(v3.rows))).toEqual(strip(workRows(legacy.rows))); + expect(strip(workRows(v3.rows))).toHaveLength(3); + + // Row titles: the bridge label leads; the structured content follows. + expect(plainTitle(read)).toBe("Read file src/index.ts"); + expect(plainTitle(grep)).toBe("Searched files for TODO in src"); + // No presentation → the legacy intent phrasing. + expect(plainTitle(glob)).toBe("Listed files in src"); + + // Compact bundle rendering reads the same intents for both shapes. + expect( + buildTimelineActivityIntentTitles(read).map((title) => title.title.plain), + ).toEqual(["Read src/index.ts"]); }); - it("hides a tool call the bridge marked suppress in its presentation", () => { + it("groups v3 exploration rows into one exploration bundle like legacy reads", () => { + const event = createTimelineEventFactory({ threadId: "thread-1" }); + const rendered = renderTimelineFixture({ + events: [ + event.turnStarted({ turnId: "turn-1", createdAt: 0 }), + event.fileReadCompleted({ + turnId: "turn-1", + itemId: "read-1", + path: "src/a.ts", + createdAt: 1_000, + }), + event.fileReadCompleted({ + turnId: "turn-1", + itemId: "read-2", + path: "src/b.ts", + createdAt: 2_000, + }), + event.searchCompleted({ + turnId: "turn-1", + itemId: "grep-1", + mode: "content", + query: "TODO", + createdAt: 3_000, + }), + event.assistantCompleted({ + turnId: "turn-1", + itemId: "answer", + text: "Done.", + createdAt: 4_000, + }), + event.turnCompleted({ turnId: "turn-1", createdAt: 5_000 }), + ], + projectionOptions: { threadStatus: "idle", turnMessageDetail: "full" }, + }); + const turn = rendered.rows.find((row) => row.kind === "turn"); + const viewRows = buildTimelineViewRows(turn?.children ?? []); + const bundle = viewRows.find((row) => row.kind === "bundle-summary"); + expect(bundle).toBeDefined(); + expect(bundle && plainTitle(bundle)).toBe("Explored 2 files, 1 search"); + }); + + it("labels a generic tool row from its presentation and keeps the headline", () => { const event = createTimelineEventFactory({ threadId: "thread-1" }); const rendered = renderTimelineFixture({ events: [ event.turnStarted({ turnId: "turn-1", createdAt: 0 }), event.toolCallStarted({ turnId: "turn-1", - itemId: "ts-1", - tool: "Monitor", - arguments: { command: "tail -f log" }, - presentation: READ_PRESENTATION, + itemId: "js-1", + tool: "js", + arguments: { code: "primes(10000)" }, + presentation: JS_PRESENTATION, createdAt: 1_000, }), event.toolCallCompleted({ turnId: "turn-1", - itemId: "ts-1", - tool: "Monitor", - arguments: { command: "tail -f log" }, - presentation: READ_PRESENTATION, - createdAt: 2_000, + itemId: "js-1", + tool: "js", + arguments: { code: "primes(10000)" }, + result: "1229", + presentation: JS_PRESENTATION, + createdAt: 2_500, }), event.toolCallStarted({ turnId: "turn-1", - itemId: "keep-1", - tool: "Monitor", - arguments: { command: "tail -f log" }, + itemId: "js-2", + tool: "js", + arguments: { code: "boom()" }, + presentation: JS_PRESENTATION, createdAt: 3_000, }), event.toolCallCompleted({ turnId: "turn-1", - itemId: "keep-1", - tool: "Monitor", - arguments: { command: "tail -f log" }, + itemId: "js-2", + tool: "js", + arguments: { code: "boom()" }, + error: "ReferenceError", + status: "failed", + presentation: JS_PRESENTATION, + createdAt: 4_500, + }), + event.turnCompleted({ turnId: "turn-1", createdAt: 5_000 }), + ], + projectionOptions: { threadStatus: "idle", turnMessageDetail: "full" }, + }); + const ok = workRow(rendered.rows, "tool", "js-1"); + expect(ok.presentation).toEqual(JS_PRESENTATION); + expect(plainTitle(ok)).toBe("Ran JavaScript Compute primes (2s)"); + // A failed call keeps the settled label and says how it ended. + const failed = workRow(rendered.rows, "tool", "js-2"); + expect(plainTitle(failed)).toBe( + "Ran JavaScript Compute primes (2s, error)", + ); + }); + + it("prefers presentation over server-enriched statusLabels on the same row", () => { + const event = createTimelineEventFactory({ threadId: "thread-1" }); + const rendered = renderTimelineFixture({ + events: [ + event.turnStarted({ turnId: "turn-1", createdAt: 0 }), + event.toolCallCompleted({ + turnId: "turn-1", + itemId: "plugin-1", + tool: "bb_task_update", + presentation: { + label: { pending: "Updating task", completed: "Updated task" }, + icon: { glyph: "ListTodo" }, + }, + createdAt: 1_000, + }), + event.turnCompleted({ turnId: "turn-1", createdAt: 2_000 }), + ], + projectionOptions: { threadStatus: "idle", turnMessageDetail: "full" }, + }); + const row = workRow(rendered.rows, "tool", "plugin-1"); + expect(plainTitle(row)).toBe("Updated task"); + }); + + it("projects a planSteps snapshot to a plan-steps row that also feeds the todo banner", () => { + const event = createTimelineEventFactory({ threadId: "thread-1" }); + const steps = [ + { step: "Read the spec", status: "completed" as const }, + { step: "Wire the renderer", status: "active" as const }, + { step: "Write tests", status: "pending" as const }, + ]; + const rendered = renderTimelineFixture({ + events: [ + event.turnStarted({ turnId: "turn-1", createdAt: 0 }), + event.planStepsCompleted({ + turnId: "turn-1", + itemId: "plan-1", + steps, + explanation: "Three steps.", + presentation: PLAN_PRESENTATION, + createdAt: 1_000, + }), + ], + projectionOptions: { threadStatus: "active", turnMessageDetail: "full" }, + }); + const row = workRow(rendered.rows, "plan-steps", "plan-1"); + expect(row).toMatchObject({ + steps, + explanation: "Three steps.", + status: "completed", + presentation: PLAN_PRESENTATION, + }); + expect(plainTitle(row)).toBe("Updated plan Wire the renderer"); + expect(rendered.pendingTodos?.items.map((item) => item.status)).toEqual([ + "completed", + "in_progress", + "pending", + ]); + }); + + it("projects an extension item to an extension row carrying kind, payload and presentation", () => { + const event = createTimelineEventFactory({ threadId: "thread-1" }); + const rendered = renderTimelineFixture({ + events: [ + event.turnStarted({ turnId: "turn-1", createdAt: 0 }), + event.extensionStarted({ + turnId: "turn-1", + itemId: "echo-1", + kind: "provider-echo/echo", + payload: { text: "hello" }, + presentation: ECHO_PRESENTATION, + createdAt: 1_000, + }), + event.extensionCompleted({ + turnId: "turn-1", + itemId: "echo-1", + kind: "provider-echo/echo", + payload: { text: "hello", echoed: true }, + presentation: ECHO_PRESENTATION, + createdAt: 2_500, + }), + event.turnCompleted({ turnId: "turn-1", createdAt: 3_000 }), + ], + projectionOptions: { threadStatus: "idle", turnMessageDetail: "full" }, + }); + const row = workRow(rendered.rows, "extension", "echo-1"); + expect(row).toMatchObject({ + extensionKind: "provider-echo/echo", + payload: { text: "hello", echoed: true }, + presentation: ECHO_PRESENTATION, + status: "completed", + startedAt: 1_000, + completedAt: 2_500, + }); + expect(plainTitle(row)).toBe("Echoed hello (2s)"); + }); + + it("renders a pending extension row with the present-tense label while the turn runs", () => { + const event = createTimelineEventFactory({ threadId: "thread-1" }); + const rendered = renderTimelineFixture({ + events: [ + event.turnStarted({ turnId: "turn-1", createdAt: 0 }), + event.extensionStarted({ + turnId: "turn-1", + itemId: "echo-1", + kind: "provider-echo/echo", + payload: { text: "hello" }, + presentation: ECHO_PRESENTATION, + createdAt: 1_000, + }), + ], + projectionOptions: { + threadStatus: "active", + turnMessageDetail: "full", + }, + }); + const row = workRow(rendered.rows, "extension", "echo-1"); + expect(row.status).toBe("pending"); + expect(plainTitle(row)).toMatch(/^Echoing hello/); + }); + + it("carries childRef, background and presentation on a v3 delegation row", () => { + const event = createTimelineEventFactory({ threadId: "thread-1" }); + const rendered = renderTimelineFixture({ + events: [ + event.turnStarted({ turnId: "turn-1", createdAt: 0 }), + event.delegationStarted({ + turnId: "turn-1", + itemId: "agent-1", + childRef: "subagent-7", + label: "Review the diff", + background: true, + presentation: SUBAGENT_PRESENTATION, + createdAt: 1_000, + }), + event.delegationCompleted({ + turnId: "turn-1", + itemId: "agent-1", + childRef: "subagent-7", + label: "Review the diff", + background: true, + summary: "Looks good.", + presentation: SUBAGENT_PRESENTATION, createdAt: 4_000, }), - // A failed call renders even when suppressed: failures are never noise. - event.toolCallStarted({ + event.turnCompleted({ turnId: "turn-1", createdAt: 5_000 }), + ], + projectionOptions: { threadStatus: "idle", turnMessageDetail: "full" }, + }); + const row = workRow(rendered.rows, "delegation", "agent-1"); + expect(row).toMatchObject({ + childRef: "subagent-7", + background: true, + description: "Review the diff", + output: "Looks good.", + presentation: SUBAGENT_PRESENTATION, + }); + expect(plainTitle(row)).toBe("Subagent finished Review the diff (3s)"); + }); + + it("hides items the bridge marked suppress, whatever their kind, unless they failed", () => { + const event = createTimelineEventFactory({ threadId: "thread-1" }); + const rendered = renderTimelineFixture({ + events: [ + event.turnStarted({ turnId: "turn-1", createdAt: 0 }), + event.toolCallCompleted({ turnId: "turn-1", - itemId: "fail-1", - tool: "Monitor", - presentation: READ_PRESENTATION, - createdAt: 5_000, + itemId: "ts-1", + tool: "ToolSearch", + presentation: SUPPRESSED_TOOL_PRESENTATION, + createdAt: 1_000, + }), + event.planStepsCompleted({ + turnId: "turn-1", + itemId: "plan-1", + steps: [{ step: "Do it", status: "active" }], + presentation: { ...PLAN_PRESENTATION, suppress: true }, + createdAt: 2_000, + }), + event.extensionCompleted({ + turnId: "turn-1", + itemId: "echo-quiet", + kind: "provider-echo/echo", + payload: {}, + presentation: { ...ECHO_PRESENTATION, suppress: true }, + createdAt: 3_000, + }), + event.extensionCompleted({ + turnId: "turn-1", + itemId: "echo-failed", + kind: "provider-echo/echo", + payload: {}, + status: "failed", + presentation: { ...ECHO_PRESENTATION, suppress: true }, + createdAt: 4_000, }), event.toolCallCompleted({ turnId: "turn-1", - itemId: "fail-1", + itemId: "keep-1", tool: "Monitor", - presentation: READ_PRESENTATION, - status: "failed", - error: "boom", - createdAt: 6_000, + createdAt: 5_000, }), - event.turnCompleted({ turnId: "turn-1", createdAt: 7_000 }), ], - projectionOptions: { threadStatus: "idle", turnMessageDetail: "full" }, + projectionOptions: { + threadStatus: "active", + turnMessageDetail: "full", + }, }); - const callIds = flattenRows(rendered.rows).flatMap((row) => - row.kind === "work" && row.workKind === "tool" ? [row.callId] : [], + const callIds = workRows(rendered.rows).map((row) => + "callId" in row ? row.callId : null, ); - expect(callIds).toEqual(["keep-1", "fail-1"]); + expect(callIds).toEqual(["echo-failed", "keep-1"]); + // A suppressed plan snapshot still drives the banner. + expect(rendered.pendingTodos?.items.map((item) => item.text)).toEqual([ + "Do it", + ]); }); }); diff --git a/scripts/provider-corpus/snapshot-rows.sh b/scripts/provider-corpus/snapshot-rows.sh index 089c415037..1a7c25a6e4 100755 --- a/scripts/provider-corpus/snapshot-rows.sh +++ b/scripts/provider-corpus/snapshot-rows.sh @@ -7,6 +7,15 @@ # # Requires BB_PROVIDER_CORPUS_DIR (defaults to ~/.bb/provider-corpus when that # directory exists). See docs/debugging-and-qa.md, "Provider corpus". +# +# snapshots/rows is the baseline minted on main and shared by every +# workstream. From a feature branch, compare against it with a per-workstream +# allowlist and never write into it: +# +# BB_PROVIDER_CORPUS_ALLOWLIST=apps/server/test/provider-corpus/allowlists/.json \ +# 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 set -euo pipefail mode="${1:-compare}" diff --git a/tests/integration/fake/smoke/timeline-response.test.ts b/tests/integration/fake/smoke/timeline-response.test.ts index 9dbc00882b..64dce3699c 100644 --- a/tests/integration/fake/smoke/timeline-response.test.ts +++ b/tests/integration/fake/smoke/timeline-response.test.ts @@ -103,6 +103,8 @@ describe("timeline response helpers", () => { workKind: "delegation", callId: "call_test", toolName: "spawnAgent", + childRef: null, + background: false, subagentType: null, description: null, output: "", diff --git a/turbo.json b/turbo.json index 04f216a6df..803e8b631d 100644 --- a/turbo.json +++ b/turbo.json @@ -440,7 +440,9 @@ "cache": false, "passThroughEnv": [ "BB_PROVIDER_CORPUS_DIR", - "BB_PROVIDER_CORPUS_SNAPSHOT" + "BB_PROVIDER_CORPUS_SNAPSHOT", + "BB_PROVIDER_CORPUS_SNAPSHOT_DIR", + "BB_PROVIDER_CORPUS_ALLOWLIST" ] }, // Builds real plugin host artifacts (the builtin artifacts suite, plus the