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 @@ -47,7 +47,7 @@ describe("classifyInteractionRequest", () => {
});

it("lifts today's plan approval subject into a plan_review request that resolves as an approval", () => {
const payload = {
const payload: PendingInteraction["payload"] = {
kind: "approval",
reason: null,
availableDecisions: ["allow_once", "deny"],
Expand All @@ -57,7 +57,7 @@ describe("classifyInteractionRequest", () => {
plan: "# Plan\n\n1. Do it",
planFilePath: "/tmp/plan.md",
},
} as const;
};
expect(classifyInteractionRequest({ ...base, payload })).toEqual({
family: "request",
kind: "plan_review",
Expand All @@ -73,7 +73,9 @@ describe("classifyInteractionRequest", () => {
});

it("classifies a user question and the target plan_review payload as requests", () => {
const questions = [{ id: "q1", prompt: "Which?", multiSelect: false }];
const questions = [
{ id: "q1", prompt: "Which?", multiSelect: false, allowFreeText: true },
];
expect(
classifyInteractionRequest({
payload: { kind: "user_question", questions },
Expand Down
7 changes: 4 additions & 3 deletions apps/mobile/src/screens/dev/work-row-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,9 +690,10 @@ export function buildWorkRowFixtureSections(): WorkRowFixtureSection[] {
}),
tool("tool-labels", {
toolName: "deploy_preview",
statusLabels: {
pending: "Deploying preview",
completed: "Deployed preview",
presentation: {
label: { pending: "Deploying preview", completed: "Deployed preview" },
icon: { glyph: "Globe" },
title: "bb/mobile",
},
toolArgs: { branch: "bb/mobile" },
output: "https://preview.example.com/bb-mobile",
Expand Down
36 changes: 1 addition & 35 deletions apps/server/src/internal/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ import {
} from "../services/lib/error-log-fields.js";
import { applyLoggedThreadLifecycleEvent } from "../services/threads/lifecycle-outcome.js";
import { applyTurnCompletedEvent } from "./turn-completed-events.js";
import { findPluginAgentTool } from "../services/plugins/plugin-agent-contributions.js";
import {
getInactiveSessionLogFields,
requireAuthenticatedDaemonSession,
Expand Down Expand Up @@ -284,39 +283,6 @@ function toStoredEvent(args: ToStoredEventArgs): AppendDaemonEventInput {
};
}

/**
* Plugin status labels are server-owned presentation metadata: providers do
* not know about them, and old daemon clients therefore need no protocol
* change. Persist the snapshot on both lifecycle events so historical rows
* remain readable if a plugin later reloads or disappears.
*/
function withPluginToolStatusLabels(
envelope: HostDaemonEventEnvelope,
): HostDaemonEventEnvelope {
const event = envelope.event;
if (
(event.type !== "item/started" && event.type !== "item/completed") ||
event.item.type !== "toolCall" ||
event.item.server !== undefined
) {
return envelope;
}
const statusLabels = findPluginAgentTool(event.item.tool)?.record
.experimentalStatusLabels;
if (statusLabels === null || statusLabels === undefined) return envelope;

return {
...envelope,
event: {
...event,
item: {
...event.item,
statusLabels,
},
},
};
}

function notifyInsertedEventThreads(
deps: NotifyInsertedEventThreadsDeps,
args: NotifyInsertedEventThreadsArgs,
Expand Down Expand Up @@ -925,7 +891,7 @@ export function registerInternalEventRoutes(app: Hono, deps: AppDeps): void {
}
return {
...entry,
envelope: withPluginToolStatusLabels(validated),
envelope: validated,
};
});
const eventInputs = labelledEntries.map((entry) => {
Expand Down
15 changes: 9 additions & 6 deletions apps/server/test/internal/internal-events-tool-calls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ describe("internal event and tool-call routes", () => {
});
});

it("snapshots native plugin status labels into tool-call events", async () => {
it("persists a native plugin tool call as the bridge sent it: no server-side label enrichment", async () => {
await withTestHarness(async (harness) => {
const statusLabels = {
pending: "Reading project overview",
Expand Down Expand Up @@ -255,11 +255,14 @@ describe("internal event and tool-call routes", () => {
event.type === "item/started" || event.type === "item/completed",
);
expect(storedToolEvents).toHaveLength(2);
expect(
storedToolEvents.map(
(event) => JSON.parse(event.data).item.statusLabels,
),
).toEqual([statusLabels, statusLabels]);
// The plugin's labels reach the row only through the presentation
// the bridge stamps on the item (resolved onto the tool definition
// it receives); the server no longer writes a `statusLabels` key.
for (const event of storedToolEvents) {
const item = JSON.parse(event.data).item;
expect(item.tool).toBe(record.name);
expect(item).not.toHaveProperty("statusLabels");
}
} finally {
setPluginAgentContributions(undefined);
}
Expand Down
18 changes: 13 additions & 5 deletions apps/server/test/provider-corpus/allowlists/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,16 @@ BB_PROVIDER_CORPUS_ALLOWLIST=apps/server/test/provider-corpus/allowlists/<ws>.js
```

Entries use the same schema as `snapshots/allowlist.json` (scope, `path`
glob, `pr`, `reason`) and are merged after it. Never write a snapshot into
the shared `snapshots/rows` from a feature branch; point
`BB_PROVIDER_CORPUS_SNAPSHOT_DIR` at a shadow directory instead. When the
PR merges and `main` is re-minted, its entries go stale and the file is
deleted.
glob, `pr`, `reason`) and are merged after it.

A change that adds, removes, or moves rows cannot be expressed by pointer:
carry a row-class file (`<ws>-row-classes.json`, schema in
`../row-diff-classes.ts`) and compare with
`BB_PROVIDER_CORPUS_ROW_CLASSES=apps/server/test/provider-corpus/allowlists/<ws>-row-classes.json`
instead. The gate matches rows by identity and requires every change to
fall into a named class; see docs/debugging-and-qa.md, "Provider Corpus".

Never write a snapshot into the shared `snapshots/rows` from a feature
branch; point `BB_PROVIDER_CORPUS_SNAPSHOT_DIR` at a shadow directory
instead. When the PR merges and `main` is re-minted, its entries go stale
and the file is deleted.
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
[
{
"name": "legacy-plan-rows",
"reason": "#2232 (layer 5): persisted codex `turn/plan/updated` notifications decode into planSteps items at read time, so old codex threads show their plan snapshots (the window no longer excludes the event type).",
"match": {
"added": {
"kind": "work",
"workKind": "plan-steps"
}
}
},
{
"name": "unsuppressed-by-name",
"reason": "#2232 (layer 5): the tool-name suppression set (TodoWrite, TodoRead, ToolSearch, Task*, AskUserQuestion) is deleted; a persisted call without a bridge `presentation.suppress` renders like any other tool call.",
"match": {
"added": {
"kind": "work",
"workKind": "tool"
}
}
},
{
"name": "exploration-intent-by-name",
"reason": "#2232 (layer 5): the Read/Grep/Glob (and lowercase) name sets are deleted; a persisted bare tool call derives no read/search/list intent and titles from its name and arguments. Bridges emit fileRead/search items for new threads.",
"match": {
"changed": {
"kind": "work",
"workKind": "tool",
"fields": [
"activityIntents"
]
}
}
},
{
"name": "delegation-from-children",
"reason": "#2232 (layer 5): the Agent/Task/spawnAgent/resumeAgent name set is deleted; a tool call that other rows name as their parentToolCallId becomes the delegation row structurally.",
"match": {
"reshaped": {
"from": {
"kind": "work",
"workKind": "tool"
},
"to": {
"kind": "work",
"workKind": "delegation"
}
}
}
},
{
"name": "delegation-rows-gain-v3-fields",
"reason": "#2192 (layer 1): every delegation row carries `childRef` and `background`; a persisted tool-call delegation has no child ref and is foreground, so both are null/false against the main baseline.",
"match": {
"changed": {
"kind": "work",
"workKind": "delegation",
"fields": [
"background",
"childRef"
]
}
}
},
{
"name": "delegation-output-unstripped",
"reason": "#2232 (layer 5): core no longer strips `agentId:` / `<usage>` lines from a delegation's result by tool name; a persisted Agent result keeps those lines in the row's expanded output. The v3 delegation item carries a bridge-owned `summary` instead.",
"match": {
"changed": {
"kind": "work",
"workKind": "delegation",
"fields": [
"background",
"childRef",
"output"
]
}
}
},
{
"name": "parented-rows-surface",
"reason": "#2232 (layer 5): a row whose parentToolCallId named a call outside the delegation name set (Claude task notifications under Monitor/TaskOutput) was dropped at the root; it now nests under that call, which the structural rule makes the delegation row.",
"match": {
"added": {
"kind": "work",
"workKind": "workflow",
"nested": true
}
}
},
{
"name": "turn-segments-rejoined",
"reason": "#2232 (layer 5): a call the name set used to hide (ToolSearch, TodoWrite) now renders between two assistant texts, so the second text is no longer a visible response that splits the turn; the text folds into the single turn segment.",
"match": {
"resegmented": {
"kind": "turn"
}
}
},
{
"name": "turn-segments-rejoined",
"reason": "#2232 (layer 5): the assistant text that used to split the turn moves from the root into the turn's children (see the resegmented turn).",
"match": {
"moved": {
"kind": "conversation",
"role": "assistant"
}
}
},
{
"name": "unsuppressed-by-name",
"reason": "#2232 (layer 5): a turn whose only work was name-hidden calls (TaskUpdate, ToolSearch) projected no turn row; with the name set gone the turn renders with those tool rows.",
"match": {
"added": {
"kind": "turn"
}
}
},
{
"name": "unsuppressed-by-name",
"reason": "#2232 (layer 5): a name-hidden call that ended in an error or interruption only got a row at its completion event; it now spans from its start event.",
"match": {
"changed": {
"kind": "work",
"workKind": "tool",
"fields": [
"sourceSeqStart",
"startedAt"
]
}
}
}
]
9 changes: 4 additions & 5 deletions apps/server/test/provider-corpus/corpus-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type { ThreadTimelineResponse } from "@bb/server-contract";
import type { CorpusThread } from "@bb/test-helpers";
import { sql } from "drizzle-orm";
import { z } from "zod";
import { resolveRepoRelativeFile } from "./env-file-path.js";
import {
THREAD_TIMELINE_DEFAULT_SEGMENT_LIMIT,
buildThreadTimelineWithProfile,
Expand Down Expand Up @@ -561,11 +562,9 @@ export function readAllowlist(
}
const extraPath = env[ALLOWLIST_FILE_ENV];
if (extraPath !== undefined && extraPath !== "") {
const resolved = path.resolve(extraPath);
if (!fs.existsSync(resolved)) {
throw new Error(`${ALLOWLIST_FILE_ENV} names a missing file: ${resolved}`);
}
entries.push(...readAllowlistFile(resolved));
entries.push(
...readAllowlistFile(resolveRepoRelativeFile(ALLOWLIST_FILE_ENV, extraPath)),
);
}
return entries;
}
Expand Down
29 changes: 29 additions & 0 deletions apps/server/test/provider-corpus/env-file-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import fs from "node:fs";
import path from "node:path";

/**
* Turbo runs the suite with `apps/server` as the working directory while the
* documented invocations name files relative to the repository root. A
* relative path is tried against the working directory and then each
* ancestor, so both spellings work; a file that exists nowhere is an error
* rather than a silently empty gate.
*/
export function resolveRepoRelativeFile(envName: string, value: string): string {
if (path.isAbsolute(value)) {
if (!fs.existsSync(value)) {
throw new Error(`${envName} names a missing file: ${value}`);
}
return value;
}
let dir = process.cwd();
for (;;) {
const candidate = path.join(dir, value);
if (fs.existsSync(candidate)) return candidate;
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
throw new Error(
`${envName} names a missing file: ${value} (tried ${process.cwd()} and its ancestors)`,
);
}
Loading
Loading