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
28 changes: 28 additions & 0 deletions devlog/_plan/260926_bug_train_6/040_batch8.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Batch 8 — plan

Previous D: #5936 (`bb3f3c2d0d`) fixed the title bar layout defects and the `tests/cli` batch hang. The next step is to
carry the new bug PRs that the post-merge triage (Sol, read-only) classified as carry-with-fix.

## Carry

- #5929 (@mdwsk88): CodeBuddy parallel tool-use blocks are serialized without corrupting calls. Prepared on
`codex/bug-train-8-prep`: `5edec14b73` (squashed carry with author and `Co-authored-by`) and `df7ca1df3d` (P1 fix).
The P1 was a bridge-init check that ran when a buffered tool call closed. It now runs on the raw start frame, before
buffering. The start → init → stop regression is red before the fix and green after it.
- #5929 audit follow-up: with more than one tool block open, an indexless tool-argument delta fails the turn instead
of being dropped (the parser's fail-closed contract).
- CI: the `macos control` lane hung in `tests/ci-workflows/ci-privacy-gate.test.ts` (`spawnSync` child) on `dev` at
`bb3f3c2d0d`; same bounded treatment as #5936 if the logs confirm the same class.

## Left out

- #5927: the independent security review failed, so it is not carried. The finding is kept in scratch space for the
maintainers, not in this tracked plan.
Comment on lines +19 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove open security triage from the tracked plan

This identifies the exact still-unfixed candidate (#5927) as having failed security review and records where its finding is being held, making it open security-triage metadata in the public devlog/. Remove this security-specific assessment from the tracked plan and retain it entirely in scratch space; a neutral statement that the PR was not carried can remain if necessary.

AGENTS.md reference: AGENTS.md:L129-L136

Useful? React with 👍 / 👎.

- #5925 duplicates #5929's four CodeBuddy files, and its MCP-only half needs a rebase and its own security review for
undeclared-tool admission.
- #5926 and #5928 are owner PRs.

## Check

Focused adapter and image suites, layout, file-size and structure guards, tsc, privacy. The #5927 security review is
recorded on the batch PR. Exact-head CI, then `--admin --match-head-commit`. Close the carried PRs with the batch note.
103 changes: 91 additions & 12 deletions src/adapters/coding-agent/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,9 +222,23 @@ export interface StreamParseState {
sawPartialText: boolean;
sawPartialThinking: boolean;
sawTerminalResult: boolean;
openToolCallId?: string;
/** A `message_stop` stream event arrived: the assistant message is complete. */
sawMessageStop?: boolean;
/**
* Open tool_use blocks keyed by content-block index. CodeBuddy parallel tool calls arrive
* as several tool_use blocks on ONE shared content-block index — intermediate blocks never
* receive a stop and only the final block does — while deltas for different indices (for
* example a long thinking block) interleave freely (observed 2026-09-25/26: four parallel
* calls, one counted stop, "incomplete tool call" 502 at message_stop). A single open-call
* slot both mis-attributes argument fragments and miscounts completions. Downstream
* assembly keeps a single open call, so each block is buffered and emitted atomically:
* when its own stop arrives, or when a new tool_use start reuses its index.
*/
openToolBlocks?: Map<number, OpenToolBlock>;
/** Synthetic decreasing keys for tool_use start frames that omit the block index. */
nextSyntheticToolBlockKey?: number;
/** Tool_use blocks opened in this stream, whether or not they have closed yet. */
toolBlockStarts?: number;
/** Completed tool_use content blocks observed in this stream. */
completedToolCalls?: number;
/** Tool IDs already captured through partial events, for complete-assistant deduplication. */
Expand Down Expand Up @@ -332,6 +346,53 @@ export function mapStreamMessageToEvents(message: StreamMessage, state: StreamPa
return events;
}

/** One in-flight tool_use block: identity plus its buffered argument fragments. */
export interface OpenToolBlock {
id: string;
name: string;
argParts: string[];
}

/** Key a tool_use start frame by content-block index, falling back to a synthetic key. */
function toolBlockKey(state: StreamParseState, event: StreamMessage): number {
const index = event.index;
if (typeof index === "number" && Number.isInteger(index)) return index;
const key = state.nextSyntheticToolBlockKey ?? -1;
state.nextSyntheticToolBlockKey = key - 1;
return key;
}

/**
* Resolve a delta/stop frame to an open tool block. An indexed frame only matches a block
* opened under the same index — CodeBuddy skips stop frames for thinking blocks, and such a
* stop must not close a tool block that happens to be open. An index-less frame resolves
* only when exactly one block is open. Ambiguous argument deltas are rejected before
* resolution; an unmatched stop cannot close a tool block.
*/
function resolveToolBlockKey(state: StreamParseState, event: StreamMessage): number | undefined {
const index = event.index;
if (typeof index === "number" && Number.isInteger(index)) {
return state.openToolBlocks?.has(index) ? index : undefined;
}
const blocks = state.openToolBlocks;
if (!blocks || blocks.size !== 1) return undefined;
return blocks.keys().next().value;
}

/**
* Emit a closed block atomically — start, the buffered fragments in arrival order, end —
* so the strictly sequential downstream bridge never sees two calls open at once.
*/
function closeToolBlock(state: StreamParseState, key: number, events: AdapterEvent[]): void {
const block = state.openToolBlocks?.get(key);
if (!block || !state.openToolBlocks) return;
state.openToolBlocks.delete(key);
events.push({ type: "tool_call_start", id: block.id, name: block.name });
for (const part of block.argParts) events.push({ type: "tool_call_delta", arguments: part });
events.push({ type: "tool_call_end" });
state.completedToolCalls = (state.completedToolCalls ?? 0) + 1;
}

/** Map a raw Anthropic SSE event (carried inside a `stream_event` frame) to AdapterEvents. */
function mapRawStreamEvent(event: StreamMessage, state: StreamParseState): AdapterEvent[] {
const events: AdapterEvent[] = [];
Expand All @@ -353,11 +414,22 @@ function mapRawStreamEvent(event: StreamMessage, state: StreamParseState): Adapt
events.push({ type: "thinking_delta", thinking });
}
} else if (deltaType === "input_json_delta") {
// Tool-input streaming. Live for capture-only bridge turns, where the advertised MCP
// catalog makes the CLI emit real tool_use blocks; parsed unconditionally so a stray
// frame on a tools-disabled turn is ignored rather than crashing.
// Tool-input streaming. Fragments are buffered under their own block index because
// CodeBuddy alternates deltas across interleaved parallel blocks; parsed
// unconditionally so a stray frame on a tools-disabled turn is ignored rather than
// crashing.
if (
(state.openToolBlocks?.size ?? 0) > 1
&& (typeof event.index !== "number" || !Number.isInteger(event.index))
) {
throw new CodingAgentProtocolError("Coding-agent CLI sent an unindexed tool argument delta with multiple tool blocks open.");
}
const partial = asString(delta?.partial_json);
if (partial && state.openToolCallId) events.push({ type: "tool_call_delta", arguments: partial });
if (partial) {
const key = resolveToolBlockKey(state, event);
const block = key === undefined ? undefined : state.openToolBlocks?.get(key);
if (block) block.argParts.push(partial);
}
}
return events;
}
Expand All @@ -368,20 +440,27 @@ function mapRawStreamEvent(event: StreamMessage, state: StreamParseState): Adapt
const id = asString(block?.id) ?? "";
const name = asString(block?.name) ?? "tool";
if (id) {
state.openToolCallId = id;
const key = toolBlockKey(state, event);
if (state.openToolBlocks?.has(key)) {
// CodeBuddy reuses one content-block index for a parallel batch: every call in the
// batch starts on the same index, intermediate blocks never receive a stop, and only
// the final block does (observed 2026-09-26: START 2 alpha, A's complete args, START 2
// beta, B's complete args, one STOP 2). Parallel calls stream their arguments
// sequentially — never interleaved — so the block already open on this index is
// complete, and the new start implicitly closes it.
closeToolBlock(state, key, events);
}
(state.openToolBlocks ??= new Map()).set(key, { id, name, argParts: [] });
state.toolBlockStarts = (state.toolBlockStarts ?? 0) + 1;
state.partialToolCallIds?.add(id);
events.push({ type: "tool_call_start", id, name });
}
}
return events;
}

if (eventType === "content_block_stop") {
if (state.openToolCallId) {
state.openToolCallId = undefined;
state.completedToolCalls = (state.completedToolCalls ?? 0) + 1;
events.push({ type: "tool_call_end" });
}
const key = resolveToolBlockKey(state, event);
if (key !== undefined) closeToolBlock(state, key, events);
return events;
}

Expand Down
59 changes: 32 additions & 27 deletions src/adapters/coding-agent/turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
sawPartialText: false,
sawPartialThinking: false,
sawTerminalResult: false,
openToolCallId: undefined,
openToolBlocks: new Map(),
partialToolCallIds: toolBridge ? new Set<string>() : undefined,
};

Expand Down Expand Up @@ -422,6 +422,29 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
break;
}
if (message.type === "system" && message.subtype === "init") initValidated = true;
const rawEvent = message.type === "stream_event" && message.event !== null && typeof message.event === "object"
? message.event as Record<string, unknown>
: undefined;
const rawBlock = rawEvent?.content_block;
if (
!initValidated
&& rawEvent?.type === "content_block_start"
&& rawBlock !== null
&& typeof rawBlock === "object"
&& !Array.isArray(rawBlock)
&& (rawBlock as Record<string, unknown>).type === "tool_use"
) {
emitOnce({
type: "error",
message: "Coding-agent CLI called a tool before the tool bridge init handshake completed.",
status: 502,
errorType: "upstream_error",
code: "tool_bridge_init_missing",
retryable: false,
});
kill();
break;
}
}
const mappedEvents = mapStreamMessageToEvents(message, state);
if (toolBridge && state.uncapturedToolUse) {
Expand Down Expand Up @@ -451,24 +474,6 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
break;
}
if (toolBridge && event.type === "tool_call_start") {
// The catalog is only advertised once the CLI has acknowledged the bridge server in
// its init handshake. A tool call that arrives before that acknowledgement means the
// model acted on a catalog this bridge never validated, so fail closed before the
// call is counted or renamed. Checking at arrival matters: a later init frame used to
// set initValidated and let an early call finish as a successful done(tool_use).
if (!initValidated) {
emitOnce({
type: "error",
message: "Coding-agent CLI called a tool before the tool bridge init handshake completed.",
status: 502,
errorType: "upstream_error",
code: "tool_bridge_init_missing",
retryable: false,
});
failClosed = true;
kill();
break;
}
toolCallStarts += 1;
if (toolCallStarts > toolBridge.maxTurnToolCalls) {
emitOnce({
Expand Down Expand Up @@ -527,8 +532,8 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
toolBridge
&& !terminalEmitted
&& event.type === "done"
&& toolCallStarts > 0
&& (state.completedToolCalls ?? 0) !== toolCallStarts
&& (state.toolBlockStarts ?? 0) > 0
&& (state.completedToolCalls ?? 0) !== (state.toolBlockStarts ?? 0)
) {
// A terminal result that arrives while a captured tool call is still open must not
// become a successful completion the client can accept. The message_stop check after
Expand All @@ -552,8 +557,8 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
toolBridge
&& !terminalEmitted
&& event.type === "done"
&& toolCallStarts > 0
&& (state.completedToolCalls ?? 0) === toolCallStarts
&& (state.toolBlockStarts ?? 0) > 0
&& (state.completedToolCalls ?? 0) === (state.toolBlockStarts ?? 0)
) {
// Every captured call completed and the CLI settled with a successful result before
// message_stop (instead of parking on the never-answering capture server). Emitting
Expand All @@ -573,8 +578,8 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
toolBridge
&& !terminalEmitted
&& state.sawMessageStop
&& toolCallStarts > 0
&& (state.completedToolCalls ?? 0) !== toolCallStarts
&& (state.toolBlockStarts ?? 0) > 0
&& (state.completedToolCalls ?? 0) !== (state.toolBlockStarts ?? 0)
) {
emitOnce({
type: "error",
Expand All @@ -588,8 +593,8 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
break;
}
if (toolBridge && !terminalEmitted && state.sawMessageStop && (state.completedToolCalls ?? 0) > 0) {
// No init re-check here: a completed call implies a tool_call_start was mapped, and the
// arrival-time gate above already refuses any start that lands before the handshake.
// Raw tool_use starts are gated before buffering, so every completed call was admitted
// after the bridge init handshake.
// The capture-only MCP handler never answers, so the CLI parks after message_stop.
// The completed tool_use blocks are this turn's structured output: end the leg here
// and terminate the tree; the client executes, and the next request continues.
Expand Down
9 changes: 9 additions & 0 deletions structure/providers-and-adapters.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Providers And Adapters

The coding-agent stream parser buffers each tool-use block by its content-block index
and emits a complete start/delta/end sequence on closure. A new start on an occupied
index closes the previous block; distinct indices can interleave. Turn completion
requires every opened block to close, preserving the downstream single-open-call contract.
An indexless argument delta belongs to the sole open block; with multiple blocks open,
the parser fails the turn before releasing their buffered calls.
The capture-only bridge checks each raw tool-use start against the init handshake before
buffering; a later init cannot authorize a call that started earlier.

RunTurn hosted search uses `src/web-search/run-turn-loop.ts`: synthetic calls remain private, progress reaches the bridge during collection, and a validated terminal precedes search execution. Complete search calls remain actionable at a truncated `done`; cancellation prevents subsequent queries and calls. OAuth preflight replay in `src/server/responses/run-turn-execution.ts` retains the synthetic tool while refreshing credential-scoped route state. In `src/server/responses/sidecar-execution.ts`, a search plan takes priority over image/video bridge execution for both transports; only fetch-capable adapters enter the fetch search loop.

Combo preflight allows the private search tool only while a search plan is active; client tool declaration checks and replay-unsafe heartbeat protection remain enforced.
Expand Down
Loading
Loading