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
2 changes: 1 addition & 1 deletion packages/domain/src/plugin-sdk-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
// PLUGIN_SDK_MAJOR is 0, so the major-only artifact gate cannot distinguish
// 0.x releases and is intentionally vacuous for them until a future 1.0.
// Rebuildable artifacts still rebuild on the exact sdkVersion-differs trigger.
export const PLUGIN_SDK_VERSION = "0.4.16";
export const PLUGIN_SDK_VERSION = "0.4.17";

/** Major of {@link PLUGIN_SDK_VERSION} — the plugin API compatibility number. */
export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]);
2 changes: 1 addition & 1 deletion packages/plugin-sdk/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@get-bb/plugin-sdk",
"version": "0.4.16",
"version": "0.4.17",
"homepage": "https://github.com/get-bb/bb#readme",
"bugs": {
"url": "https://github.com/get-bb/bb/issues"
Expand Down
303 changes: 303 additions & 0 deletions packages/provider-bridge-acp/src/bridge/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2202,6 +2202,309 @@ describe("acp bridge", () => {
startedProviderThreadIds.pop();
}, 15_000);

describe("agent-initiated turns (#2122)", () => {
/**
* Runs one prompted turn whose agent then streams unprompted work, and
* waits until that work has fully arrived (the closing chunk is on the
* wire) so each test observes the agent turn at a known point.
*/
async function promptThenAwaitAgentWork(
variant: string,
args?: StartThreadArgs,
): Promise<{ bbThreadId: string; providerThreadId: string }> {
const thread = await startThread(args);
const turnId = sendTurnRequest("turn/start", thread.providerThreadId, {
input: [
{ type: "text", text: `agent-initiated${variant}`, mentions: [] },
],
});
await waitForResponse(turnId);
await waitForTurnCompleted();
await waitFor(
() =>
agentMessageTexts().some((text) => text.includes("the answer is 42."))
? true
: undefined,
"agent-initiated work to arrive",
);
return thread;
}

it("brackets unprompted agent work as a turn and ends it when the agent goes quiet", async () => {
await promptThenAwaitAgentWork("");

// The work is a real turn with real items, not hidden raw-event rows.
expect(threadEventsOfType("turn/started")).toHaveLength(2);
expect(threadEventsOfType("provider/unhandled")).toHaveLength(0);
expect(agentMessageTexts().join("")).toContain(
"agent-initiated:job bg_4 finished, the answer is 42.",
);
const toolItems = threadEventsOfType("item/started").filter(
(event) =>
(event.item as { type: string }).type === "toolCall" ||
(event.item as { type: string }).type === "commandExecution" ||
(event.item as { type: string }).type === "fileRead",
);
expect(toolItems.length).toBeGreaterThan(0);
// The echoed job result stays noise: one accepted input (the user's),
// no phantom user row.
expect(
emittedDeltaKinds().filter((kind) => kind === "input.accepted"),
).toHaveLength(1);

// No end-of-turn signal exists; the quiet window closes it as completed.
const completed = await waitFor(() => {
const events = threadEventsOfType("turn/completed");
return events.length === 2 ? events[1] : undefined;
}, "agent turn to close after the quiet window");
expect(completed).toMatchObject({ status: "completed" });
}, 20_000);

/**
* The bridge's agent-turn quiet window (`AGENT_TURN_QUIET_WINDOW_MS`).
* Mirrored here so a test can step over it deliberately.
*/
const QUIET_WINDOW_MS = 5_000;

/** Assembled events that carry the slow tool call's real output. */
function eventsCarryingSlowToolOutput(
type: string,
): Record<string, unknown>[] {
return threadEventsOfType(type).filter((event) =>
JSON.stringify(event).includes("SLOW-TOOL-REAL-OUTPUT"),
);
}

/** Assembled events for the slow tool call's row, by event type. */
function slowToolEvents(type: string): Record<string, unknown>[] {
return threadEventsOfType(type).filter((event) =>
JSON.stringify(event).includes("sleep 7"),
);
}

/**
* Opens an agent turn whose tool call keeps running past the quiet
* window, and returns once the tool row is on the timeline.
*/
async function promptThenAwaitRunningTool(): Promise<{
bbThreadId: string;
providerThreadId: string;
}> {
const thread = await startThread();
const turnId = sendTurnRequest("turn/start", thread.providerThreadId, {
input: [
{ type: "text", text: "agent-initiated:slowtool", mentions: [] },
],
});
await waitForResponse(turnId);
await waitForTurnCompleted();
await waitFor(
() => (slowToolEvents("item/started").length > 0 ? true : undefined),
"the agent-initiated tool call to open",
);
return thread;
}

it("keeps the agent turn open while an announced tool call still runs", async () => {
await promptThenAwaitRunningTool();
expect(threadEventsOfType("turn/started")).toHaveLength(2);

// Past the quiet window with the call still running. A busy agent
// streams nothing until its tool finishes, so silence alone must not
// end the turn: settling here would close the row as completed with no
// output and flip the thread idle while the agent works.
await new Promise((resolveTick) =>
realSetTimeout(resolveTick, QUIET_WINDOW_MS + 1_000),
);
expect(threadEventsOfType("turn/completed")).toHaveLength(1);
expect(slowToolEvents("item/completed")).toHaveLength(0);

// The agent's own result settles the row, inside the turn that opened
// it — no second bracket, and the real output is not lost.
await waitFor(
() =>
eventsCarryingSlowToolOutput("item/completed").length > 0
? true
: undefined,
"the tool call's real result",
);
expect(slowToolEvents("item/completed")[0]?.item).toMatchObject({
type: "commandExecution",
command: "sleep 7",
status: "completed",
aggregatedOutput: "SLOW-TOOL-REAL-OUTPUT",
});
expect(threadEventsOfType("turn/started")).toHaveLength(2);

// Only now is the agent quiet, so the window closes the one turn.
const completed = await waitFor(() => {
const events = threadEventsOfType("turn/completed");
return events.length === 2 ? events[1] : undefined;
}, "agent turn to close once the tool finished");
expect(completed).toMatchObject({ status: "completed" });
expect(threadEventsOfType("turn/started")).toHaveLength(2);
}, 30_000);

it("interrupts, not completes, an agent turn a user turn cuts short mid-tool", async () => {
const { providerThreadId } = await promptThenAwaitRunningTool();
expect(threadEventsOfType("turn/completed")).toHaveLength(1);

// The real user path lands here: the server sees the thread active and
// steers, the bridge rejects the steer because the open turn is an
// agent one, and the daemon falls back to turn/start.
const nextId = sendTurnRequest("turn/start", providerThreadId, {
input: [{ type: "text", text: "hello there", mentions: [] }],
});
expect((await waitForResponse(nextId)).error).toBeUndefined();

// The agent turn ends because the user cut it off, not because the
// agent finished: neither it nor the command it left running may claim
// a result the agent never produced.
const settled = threadEventsOfType("turn/completed");
expect(settled).toHaveLength(2);
expect(settled[1]).toMatchObject({ status: "interrupted" });
expect(slowToolEvents("item/completed")[0]?.item).toMatchObject({
command: "sleep 7",
status: "interrupted",
});
expect(eventsCarryingSlowToolOutput("item/completed")).toHaveLength(0);
}, 20_000);

it("does not open a turn for unprompted non-work updates", async () => {
const { providerThreadId } = await startThread();
const turnId = sendTurnRequest("turn/start", providerThreadId, {
input: [{ type: "text", text: "agent-initiated:noise", mentions: [] }],
});
await waitForResponse(turnId);
await waitForTurnCompleted();
await waitFor(
() =>
emittedDeltaKinds().includes("contextWindow") ? true : undefined,
"idle usage_update to be processed",
);

expect(threadEventsOfType("turn/started")).toHaveLength(1);
});

it("settles the agent turn before the next user turn opens", async () => {
const { providerThreadId } = await promptThenAwaitAgentWork("");
expect(threadEventsOfType("turn/completed")).toHaveLength(1);

const nextId = sendTurnRequest("turn/start", providerThreadId, {
input: [{ type: "text", text: "hello there", mentions: [] }],
});
const response = await waitForResponse(nextId);
expect(response.error).toBeUndefined();
await waitFor(
() =>
threadEventsOfType("turn/completed").length === 3 ? true : undefined,
"all three turns to settle",
);

expect(threadEventsOfType("turn/started")).toHaveLength(3);
expect(
threadEventsOfType("turn/completed").map((event) => event.status),
).toEqual(["completed", "completed", "completed"]);
expect(agentMessageTexts().at(-1)).toBe("echo:hello there");
});

it("interrupts the agent turn on thread/stop", async () => {
const { providerThreadId } = await promptThenAwaitAgentWork("");

const stopId = sendRequest("thread/stop", {
threadId: bbThreadIdFor(providerThreadId),
providerThreadId,
intent: "interrupt",
activeTurnId: null,
});
const stopResponse = await waitForResponse(stopId);
expect(stopResponse.result).toEqual({ ok: true });

const completed = threadEventsOfType("turn/completed");
expect(completed).toHaveLength(2);
expect(completed[1]).toMatchObject({ status: "interrupted" });
startedProviderThreadIds.pop();
});

it("fails the agent turn when the agent process exits mid-turn", async () => {
const { bbThreadId } = await promptThenAwaitAgentWork(":exit");

const errors = await waitFor(() => {
const errorNotifications = notifications("error");
return errorNotifications.length > 0 ? errorNotifications : undefined;
}, "agent exit error notification");
expect(errors).toHaveLength(1);
expect(errors[0]?.params).toMatchObject({ threadId: bbThreadId });

// The turn reaches a terminal state instead of hanging "working".
const completed = threadEventsOfType("turn/completed");
expect(completed).toHaveLength(2);
expect(completed[1]).toMatchObject({ status: "failed" });
startedProviderThreadIds.pop();
});

it("auto-allows a permission request inside an agent turn in full mode", async () => {
await promptThenAwaitAgentWork(":permission", { permissionMode: "full" });

expect(
output.messages.filter(
(message) => message.method === "interaction/request",
),
).toHaveLength(0);
expect(agentMessageTexts().join("")).toContain("permission:yes ");
});

it("forwards a permission request inside an agent turn in ask mode", async () => {
const { bbThreadId, providerThreadId } = await startThread({
permissionMode: "accept-edits",
permissionEscalation: "ask",
});
const turnId = sendTurnRequest("turn/start", providerThreadId, {
input: [
{ type: "text", text: "agent-initiated:permission", mentions: [] },
],
});
await waitForResponse(turnId);
await waitForTurnCompleted();

const forwarded = await waitFor(
() =>
output.messages.find(
(message) =>
message.method === "interaction/request" &&
message.id !== undefined,
),
"forwarded permission request",
);
expect(forwarded.params).toMatchObject({
threadId: bbThreadId,
providerThreadId,
payload: {
kind: "approval",
subject: expect.objectContaining({ command: "rm -rf build" }),
},
});
handleLine(
JSON.stringify({
jsonrpc: "2.0",
id: forwarded.id,
result: { decision: "deny" },
}),
);

await waitFor(
() =>
agentMessageTexts().some((text) => text.includes("the answer is 42."))
? true
: undefined,
"agent-initiated work to finish after the decision",
);
expect(agentMessageTexts().join("")).toContain("permission:no ");
// The whole exchange lives in the one agent turn.
expect(threadEventsOfType("turn/started")).toHaveLength(2);
});
});

it("forks an advertised ACP session with the target cwd and MCP servers", async () => {
const forkLog = join(workspaceDir, "fork-params.json");
const forkId = sendRequest("thread/fork", {
Expand Down
Loading
Loading