Skip to content
Merged
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
@@ -1,5 +1,5 @@
import { eq } from "drizzle-orm";
import { events, getThread } from "@bb/db";
import { events, getThread, listQueuedThreadMessages } from "@bb/db";
import { threadScope, turnScope } from "@bb/domain";
import {
groupHostDaemonEvents,
Expand All @@ -18,6 +18,7 @@ import {
seedEvent,
seedHostSession,
seedProjectWithSource,
seedQueuedMessage,
seedThread,
seedThreadRuntimeState,
} from "../helpers/seed.js";
Expand Down Expand Up @@ -405,6 +406,75 @@ describe("internal event append ownership", () => {
}
});

it("auto-sends a queued message after a zero-work turn completes", async () => {
const { environment, harness, session, thread } = await setupEventRoute();
try {
seedThreadRuntimeState(harness.deps, {
environmentId: environment.id,
inputText: "/clear",
providerThreadId: "provider-zero-work",
threadId: thread.id,
});
seedQueuedMessage(harness.deps, {
content: [
{
type: "text",
text: "queued behind zero-work turn",
mentions: [],
},
],
threadId: thread.id,
});

const response = await postEventBatch({
harness,
sessionId: session.id,
events: [
{
threadId: thread.id,
event: {
type: "turn/started",
threadId: thread.id,
providerThreadId: "provider-zero-work",
scope: turnScope("turn-zero-work"),
},
},
{
threadId: thread.id,
event: {
type: "turn/completed",
threadId: thread.id,
providerThreadId: "provider-zero-work",
scope: turnScope("turn-zero-work"),
status: "completed",
},
},
],
});

expect(response.status).toBe(200);
const queuedTurn = await waitForQueuedCommand(
harness,
({ command }) =>
command.type === "turn.submit" && command.threadId === thread.id,
);
expect(queuedTurn.command).toMatchObject({
type: "turn.submit",
input: [
{
type: "text",
text: "queued behind zero-work turn",
mentions: [],
},
],
});
expect(listQueuedThreadMessages(harness.db, thread.id)).toEqual([]);
expect(getThread(harness.db, thread.id)?.status).toBe("active");
} finally {
await harness.cleanup();
}
});

it("rejects unowned thread events without blocking owned events in the same batch", async () => {
const { harness, session, thread } = await setupEventRoute();
try {
Expand Down
34 changes: 34 additions & 0 deletions packages/agent-runtime/src/acp/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,40 @@ describe("acp adapter event translation", () => {
]);
});

it("settles accepted input when completion arrives before an update", () => {
const adapter = createAdapter();
adapter.translateAcceptedCommand({
command: {
type: "turn/start",
clientRequestId: "creq_222222228e",
input: [promptTextInput({ text: "/agent-local-command" })],
options: fullProviderExecutionContext,
providerThreadId: "sess-1",
threadId: "thread-1",
},
});

const events = adapter.translateEvent(
{
jsonrpc: "2.0",
method: "acp/turn/completed",
params: { threadId: "thread-1", stopReason: "end_turn" },
},
THREAD_CONTEXT,
);

expect(events.map((event) => event.type)).toEqual([
"turn/started",
"turn/input/accepted",
"turn/completed",
]);
expect(events.at(-1)).toMatchObject({
type: "turn/completed",
scope: turnScope("turn-1"),
status: "completed",
});
});

it("translates ACP usage updates into exact context-window usage", () => {
const adapter = createAdapter();
startTurn(adapter);
Expand Down
12 changes: 9 additions & 3 deletions packages/agent-runtime/src/acp/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { createStandardAdapterMembers } from "../shared/standard-adapter-members
import { completeStartedToolItem } from "../shared/tool-item-translation.js";
import { buildUnhandledProviderEvents } from "../shared/provider-unhandled-event.js";
import { createScopedItemIdFactory } from "../shared/scoped-item-ids.js";
import { resolveProviderTerminalTurn } from "../shared/provider-terminal-turn.js";
import {
createProviderTurnStateRegistry,
finishOpenProviderTurn,
Expand Down Expand Up @@ -987,11 +988,16 @@ export function createAcpProviderAdapter(
state: AcpTurnState,
context?: ProviderTranslationContext,
): ThreadEvent[] {
const currentTurnId = state.currentTurnId;
if (!currentTurnId) {
const events: ThreadEvent[] = [];
const currentTurnId = resolveProviderTerminalTurn({
events,
registry: turnState,
state,
threadId: UNSTAMPED_THREAD_ID,
});
if (currentTurnId === undefined) {
return [];
}
const events: ThreadEvent[] = [];
const openToolCallStatus: ThreadEventItemStatus =
stopReason === "end_turn"
? "completed"
Expand Down
93 changes: 93 additions & 0 deletions packages/agent-runtime/src/claude-code/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2186,6 +2186,99 @@ describe("claude-code provider adapter", () => {
);
});

it("translateEvent settles a zero-work run that never started a turn", () => {
const adapter = createClaudeCodeProviderAdapter();

expect(
adapter.translateAcceptedCommand({
command: {
type: "turn/start",
threadId: "bb-thread-1",
providerThreadId: "claude-session-1",
clientRequestId: "creq_23456789af",
input: [promptTextInput({ text: "/clear" })],
options: fullProviderExecutionContext,
},
}),
).toEqual([]);

// The CLI resolves /clear locally: it emits conversation_reset and then a
// success result, with no model call and so no assistant message to start
// the turn. The result has to settle it, or the thread stays active.
const events = adapter.translateEvent(
{
type: "result",
subtype: "success",
is_error: false,
num_turns: 0,
result: "",
session_id: "claude-session-1",
},
{ threadId: "bb-thread-1" },
);

expect(events.map((event) => event.type)).toEqual([
"turn/started",
"turn/input/accepted",
"turn/completed",
]);
expect(events).toContainEqual(
expect.objectContaining({
type: "turn/completed",
scope: turnScope("turn-1"),
status: "completed",
}),
);
});

it("translateEvent ignores a trailing result once the turn has closed", () => {
const adapter = createClaudeCodeProviderAdapter();

adapter.translateAcceptedCommand({
command: {
type: "turn/start",
threadId: "bb-thread-1",
providerThreadId: "claude-session-1",
clientRequestId: "creq_23456789af",
input: [promptTextInput({ text: "please do this" })],
options: fullProviderExecutionContext,
},
});
adapter.translateEvent(
{
type: "assistant",
message: {
id: "msg-1",
role: "assistant",
content: [{ type: "text", text: "Hello world" }],
},
session_id: "claude-session-1",
},
{ threadId: "bb-thread-1" },
);
expect(
adapter.buildCommandPlan({
type: "thread/stop",
threadId: "bb-thread-1",
providerThreadId: "claude-session-1",
activeTurnId: "turn-1",
}),
).toEqual({
kind: "request",
method: "thread/stop",
params: { threadId: "bb-thread-1" },
});

// A stop finishes the open turn before the CLI's result lands, so a result
// with no open turn is routine. It must not open a second, empty turn.
expect(
adapter.translateEvent(
{ type: "result", subtype: "success", session_id: "claude-session-1" },
{ threadId: "bb-thread-1" },
),
).toEqual([]);
});

it("translateEvent completes a pending turn for wrapped Claude synthetic no-response messages", () => {
const adapter = createClaudeCodeProviderAdapter();

Expand Down
17 changes: 12 additions & 5 deletions packages/agent-runtime/src/claude-code/translate-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
ProviderTurnStateRegistry,
} from "../shared/turn-state.js";
import { createScopedItemIdFactory } from "../shared/scoped-item-ids.js";
import { resolveProviderTerminalTurn } from "../shared/provider-terminal-turn.js";
import { UNSTAMPED_THREAD_ID } from "../shared/unstamped-thread-id.js";
import type { ProviderTranslationContext } from "../provider-adapter.js";
import {
Expand Down Expand Up @@ -908,7 +909,13 @@ export function translateClaudeSdkMessage(
});
}
const message = parsedMessage.data;
if (state.currentTurnId) {
const turnId = resolveProviderTerminalTurn({
events,
registry: args.turnState,
state,
threadId,
});
if (turnId) {
const contextWindowUsage = extractClaudeContextWindowUsage({
fallbackModelContextWindow: state.selectedModelContextWindow,
latestRequestContextTokens: state.latestRequestContextTokens,
Expand All @@ -927,7 +934,7 @@ export function translateClaudeSdkMessage(
type: "thread/contextWindowUsage/updated",
threadId,
providerThreadId: "",
scope: turnScope(state.currentTurnId),
scope: turnScope(turnId),
contextWindowUsage,
});
}
Expand All @@ -936,7 +943,7 @@ export function translateClaudeSdkMessage(
type: "thread/tokenUsage/updated",
threadId,
providerThreadId: "",
scope: turnScope(state.currentTurnId),
scope: turnScope(turnId),
tokenUsage,
});
}
Expand Down Expand Up @@ -968,7 +975,7 @@ export function translateClaudeSdkMessage(
httpStatusCode: resultErrorInfo?.httpStatusCode ?? null,
},
threadId,
turnId: state.currentTurnId,
turnId,
}),
);
}
Expand All @@ -986,7 +993,7 @@ export function translateClaudeSdkMessage(
type: "turn/completed",
threadId,
providerThreadId: "",
scope: turnScope(state.currentTurnId),
scope: turnScope(turnId),
status: failed ? "failed" : "completed",
...(state.latestProviderCheckpointId !== undefined
? {
Expand Down
34 changes: 34 additions & 0 deletions packages/agent-runtime/src/pi/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,40 @@ describe("pi provider adapter", () => {
);
});

it("settles accepted input when the prompt resolves before agent_start", () => {
const adapter = createPiProviderAdapter();
adapter.translateAcceptedCommand({
command: {
type: "turn/start",
clientRequestId: "creq_222222228e",
input: [promptTextInput({ text: "/local-extension-command" })],
options: fullProviderExecutionContext,
providerThreadId: "pi-session-1",
threadId: "bb-t1",
},
});

const events = adapter.translateEvent(
{
jsonrpc: "2.0",
method: "pi/prompt/settled",
params: { threadId: "bb-t1", status: "completed" },
},
{ threadId: "bb-t1" },
);

expect(events.map((event) => event.type)).toEqual([
"turn/started",
"turn/input/accepted",
"turn/completed",
]);
expect(events.at(-1)).toMatchObject({
type: "turn/completed",
scope: turnScope("turn-1"),
status: "completed",
});
});

it("translateEvent keeps turn_start as internal noise while agent_start owns the bb turn", () => {
const adapter = createPiProviderAdapter();
adapter.translateEvent(loadFixture("agent-start.json"));
Expand Down
Loading