Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,8 @@ const completedDelegation: TimelineRow = {
createdAt: 1778174341060,
kind: "work",
workKind: "delegation",
childRef: null,
background: false,
status: "completed",
callId: "toolu_01LKp2KK7kaTCi5vi15VZYvw",
toolName: "Agent",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -2045,6 +2049,8 @@ const errorDelegation: TimelineRow = {
createdAt: 1778174370069,
kind: "work",
workKind: "delegation",
childRef: null,
background: false,
status: "error",
callId: "toolu_01VfaFeGbfjGckpp9LZNpd5a",
toolName: "Agent",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/app/src/test/fixtures/thread-timeline-rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,8 @@ export function delegationRow({
status,
callId: callId ?? id,
toolName,
childRef: null,
background: false,
subagentType,
description,
output,
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/screens/dev/work-row-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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":
Expand Down
8 changes: 8 additions & 0 deletions apps/mobile/src/screens/thread/timeline/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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">;
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/screens/thread/timeline/test-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ export function delegationRow(
status: "completed",
callId: `call-${id}`,
toolName: "Task",
childRef: null,
background: false,
subagentType: "explore",
description: "Look around",
output: "",
Expand Down
29 changes: 23 additions & 6 deletions apps/server/src/services/threads/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {
listLatestOpenBackgroundTaskStateRowsForThread,
listStoredTimelineWindowEventRows,
listTodoSnapshotEventRowsForThread,
listStoredToolCallRowsByItemIds,
listStoredDelegatingItemRowsByItemIds,
listStoredTurnCompletedRowsByTurnIds,
listStoredTurnInputAcceptedRowsByClientRequestIds,
listStoredTurnRejectedRowsByClientRequestIds,
Expand Down Expand Up @@ -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<string>();
for (const row of rows) {
if (row.itemKind !== "toolCall" || row.itemId === null) {
if (!isStoredDelegatingItemRow(row) || row.itemId === null) {
continue;
}
itemIds.add(row.itemId);
Expand All @@ -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<string>();
let outOfBoundsChildDataBytesRemaining = args.outOfBoundsChildDataByteLimit;

Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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,
Expand Down Expand Up @@ -593,6 +605,11 @@ const CROSS_TURN_TOOL_ITEM_KINDS: ReadonlySet<ThreadEventItemType> = new Set([
"webSearch",
"webFetch",
"imageView",
"fileRead",
"search",
"planSteps",
"delegation",
"extension",
]);

function filterExactEventRowsForRequestedTurn(
Expand Down
18 changes: 18 additions & 0 deletions apps/server/test/provider-corpus/allowlists/README.md
Original file line number Diff line number Diff line change
@@ -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/<ws>.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.
Original file line number Diff line number Diff line change
@@ -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."
}
]
57 changes: 52 additions & 5 deletions apps/server/test/provider-corpus/corpus-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
9 changes: 5 additions & 4 deletions apps/server/test/provider-corpus/row-snapshots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
normalizeJson,
readAllowlist,
resolveSnapshotMode,
resolveSnapshotRowsDir,
unifiedJsonDiff,
type JsonDiff,
type JsonValue,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<number>();
let registry: ProviderRegistryService | null = null;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
14 changes: 11 additions & 3 deletions docs/debugging-and-qa.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<ws>.json`, same schema, merged
after the shared file) and compares with
`BB_PROVIDER_CORPUS_ALLOWLIST=<that file>`. A snapshot of the branch's own
rows goes to a shadow directory: `BB_PROVIDER_CORPUS_SNAPSHOT_DIR=<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
Expand Down
10 changes: 10 additions & 0 deletions packages/client-core/src/timeline/timeline-auto-expand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
Loading
Loading