From c79387474476be4d5c19b8b2fc50e5be7cc28fd4 Mon Sep 17 00:00:00 2001 From: cubebox <58514883+juzijia@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:26:34 +0800 Subject: [PATCH 1/7] feat(qoder): bridge Codex-owned tools through MCP --- .../src/content/docs/guides/providers.md | 2 +- scripts/test-layout/layout.json | 2 + src/adapters/codebuddy/mcp-server.ts | 27 +- src/adapters/codebuddy/tool-bridge.ts | 8 +- src/adapters/coding-agent/mcp-server.ts | 33 + src/adapters/coding-agent/protocol.ts | 52 +- src/adapters/coding-agent/tool-catalog.ts | 75 ++ src/adapters/coding-agent/turn.ts | 88 +- src/adapters/qoder/adapter.ts | 46 +- src/adapters/qoder/scaffold-guard.ts | 5 +- src/cli/index.ts | 16 +- src/responses/spill-store.ts | 5 +- src/responses/state.ts | 107 +-- src/responses/state/metrics.ts | 87 ++ src/responses/state/snapshot-codec.ts | 3 + src/responses/state/spill-queue.ts | 1 + src/responses/state/unforced-store-false.ts | 44 + src/server/responses/core-replay.ts | 5 + src/server/responses/run-turn-execution.ts | 11 +- src/server/responses/sidecar-execution.ts | 7 +- src/types.ts | 1 + src/types/tools.ts | 7 + structure/providers-and-adapters.md | 16 +- tests/fixtures/test-layout-expected.json | 4 +- tests/providers/qoder-adapter.test.ts | 837 +++++++++++++++++- tests/providers/qoder-mcp-server.test.ts | 28 + .../responses-state-store-false.test.ts | 340 +++++++ 27 files changed, 1687 insertions(+), 170 deletions(-) create mode 100644 src/adapters/coding-agent/mcp-server.ts create mode 100644 src/adapters/coding-agent/tool-catalog.ts create mode 100644 src/responses/state/metrics.ts create mode 100644 src/responses/state/unforced-store-false.ts create mode 100644 tests/providers/qoder-mcp-server.test.ts create mode 100644 tests/responses/responses-state-store-false.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 1d1384eecc5..c5874d802f1 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1052,7 +1052,7 @@ OpenCodex provides official adapter support for Qoder through the `qoder` (Globa ([Global](https://qoder.com/account/integrations), [CN](https://qoder.cn/account/integrations)) and paste it as the provider's API key. The stored key reaches the CLI only as `QODER_PERSONAL_ACCESS_TOKEN` (Global) or `QODERCN_PERSONAL_ACCESS_TOKEN` (CN) in a scoped child environment. - **Region Isolation:** Each preset accepts only its canonical destination (`https://qoder.com` or `https://qoder.cn`) and resolves its own executable. Credentials, model cache, usage, and health are independent; neither region falls back to the other. An older custom provider named `qoder` with a different destination keeps its existing adapter and URL. - **Model Discovery:** `qoder --list-models` is the authoritative entitlement roster for the current PAT. The cache is bound to an irreversible fingerprint of the token, so switching accounts never reuses another account's roster. If discovery fails, the provider degrades to a stale cache and then the documented static seed. -- **Tool Ownership:** The CLI runs single-turn `stream-json` with `--tools ""`, `--strict-mcp-config`, setting sources disabled, and session persistence disabled, so Codex keeps exclusive tool ownership. v1 is text and reasoning only; image input fails explicitly. +- **Tool Ownership:** The CLI runs single-turn `stream-json` with `--tools ""`, `--strict-mcp-config`, setting sources disabled, and session persistence disabled, so Codex keeps exclusive tool ownership. Client-declared tools are exposed through an isolated request-scoped MCP server; image input fails explicitly. - **Quota:** No public quota API is used, so totals and reset times are unavailable. Insufficient-credit errors (vendor code 118) surface as HTTP 429 `insufficient_quota`. - **Operators:** Qoder Global is operated by BRIGHT ZENITH PRIVATE LIMITED under the [product service terms](https://qoder.com/product-service); Qoder CN by 通义云启(杭州)信息技术有限公司 with Alibaba Cloud. Verify `ocx provider test qoder` (or `qoder-cn`) after configuring. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 1f0d2d4ccaa..c3848e350c7 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1434,6 +1434,7 @@ "proxy-liveness.test.ts": "server", "qoder-adapter.test.ts": "providers", "qoder-live-models.test.ts": "providers", + "qoder-mcp-server.test.ts": "providers", "qoder-scaffold-guard.test.ts": "providers", "quota-401-recovery-runtime.test.ts": "usage", "quota-401-recovery.test.ts": "usage", @@ -1601,6 +1602,7 @@ "responses-spill-shutdown-clock.test.ts": "responses", "responses-state-snapshot-select.test.ts": "responses", "responses-state-snapshot-stubs.test.ts": "responses", + "responses-state-store-false.test.ts": "responses", "responses-state-write-amplification.test.ts": "responses", "responses-state.test.ts": "responses", "responses-stateless-dangling-call-repair.test.ts": "responses", diff --git a/src/adapters/codebuddy/mcp-server.ts b/src/adapters/codebuddy/mcp-server.ts index ae471849c9c..82641e2e172 100644 --- a/src/adapters/codebuddy/mcp-server.ts +++ b/src/adapters/codebuddy/mcp-server.ts @@ -8,12 +8,7 @@ */ import { open } from "node:fs/promises"; -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { - CallToolRequestSchema, - ListToolsRequestSchema, -} from "@modelcontextprotocol/sdk/types.js"; +import { serveCodingAgentMcpTools } from "../coding-agent/mcp-server"; import { CODEBUDDY_TOOL_LIMITS } from "./tool-bridge"; interface ToolDefinition { @@ -156,25 +151,7 @@ async function loadTools(path: string): Promise { export async function runCodeBuddyMcpServer(catalogPath: string): Promise { if (!catalogPath) throw new Error("missing tool catalog"); - // The pinned SDK does not detect stdin EOF itself. The capture server must exit when - // the parent terminates its CLI, including after a captured message_stop. - const exitOnStdinClose = (): void => process.exit(0); - process.stdin.on("end", exitOnStdinClose); - process.stdin.on("close", exitOnStdinClose); - - const tools = await loadTools(catalogPath); - const advertisedNames = new Set(tools.map(tool => tool.name)); - const server = new Server( - { name: "opencodex-codebuddy-capture", version: "1.0.0" }, - { capabilities: { tools: {} } }, - ); - server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools })); - server.setRequestHandler(CallToolRequestSchema, async request => { - if (!advertisedNames.has(request.params.name)) throw new Error("unknown isolated tool"); - // The external Codex client retains approval and execution ownership. - return await new Promise(() => {}); - }); - await server.connect(new StdioServerTransport()); + await serveCodingAgentMcpTools(await loadTools(catalogPath), "opencodex-codebuddy-capture"); } if (import.meta.main) await runCodeBuddyMcpServer(process.argv[2] ?? ""); diff --git a/src/adapters/codebuddy/tool-bridge.ts b/src/adapters/codebuddy/tool-bridge.ts index 815121caaa5..5e858bc2447 100644 --- a/src/adapters/codebuddy/tool-bridge.ts +++ b/src/adapters/codebuddy/tool-bridge.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { namespacedToolName, + requiresToolCall, toolChoiceToolPredicate, type OcxParsedRequest, type OcxTool, @@ -475,13 +476,6 @@ function codeBuddyToolAliases(wireNames: readonly string[]): Map return aliases; } -function requiresToolCall(choice: OcxToolChoice | undefined): boolean { - return choice === "required" - || (typeof choice === "object" && choice !== null && ( - "name" in choice || ("mode" in choice && choice.mode === "required") - )); -} - function validateToolNamePart(value: unknown): value is string { return typeof value === "string" && value.length > 0 diff --git a/src/adapters/coding-agent/mcp-server.ts b/src/adapters/coding-agent/mcp-server.ts new file mode 100644 index 00000000000..c10344ab669 --- /dev/null +++ b/src/adapters/coding-agent/mcp-server.ts @@ -0,0 +1,33 @@ +import { readFile } from "node:fs/promises"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; + +export interface CodingAgentMcpTool { + name: string; + description: string; + inputSchema: Record; +} + +/** Advertise client tools without executing them: only Codex/client may execute a call. */ +export async function serveCodingAgentMcpTools(tools: CodingAgentMcpTool[], serverName: string): Promise { + const exitOnStdinClose = (): void => process.exit(0); + process.stdin.on("end", exitOnStdinClose); + process.stdin.on("close", exitOnStdinClose); + + const names = new Set(tools.map(tool => tool.name)); + const server = new Server({ name: serverName, version: "1.0.0" }, { capabilities: { tools: {} } }); + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools })); + server.setRequestHandler(CallToolRequestSchema, async request => { + if (!names.has(request.params.name)) throw new Error("unknown isolated tool"); + return new Promise(() => {}); + }); + await server.connect(new StdioServerTransport()); +} + +export async function runCodingAgentMcpServer(catalogPath: string): Promise { + const tools = JSON.parse(await readFile(catalogPath, "utf8")) as CodingAgentMcpTool[]; + await serveCodingAgentMcpTools(tools, "opencodex-coding-agent-tools"); +} + +if (import.meta.main) await runCodingAgentMcpServer(process.argv[2] ?? ""); diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts index ac7130db078..6f67d65fc39 100644 --- a/src/adapters/coding-agent/protocol.ts +++ b/src/adapters/coding-agent/protocol.ts @@ -1,4 +1,4 @@ -import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxUsage } from "../../types"; +import { namespacedToolName, type AdapterEvent, type OcxMessage, type OcxParsedRequest, type OcxUsage } from "../../types"; import type { TranslatorBudget } from "../../lib/translator-budget"; /** @@ -227,6 +227,8 @@ export interface StreamParseState { sawTerminalResult: boolean; /** A `message_stop` stream event arrived: the assistant message is complete. */ sawMessageStop?: boolean; + /** The final complete assistant block explicitly ends a tool-use response. */ + sawAssistantToolUseStop?: 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 @@ -258,6 +260,8 @@ export interface StreamParseState { partialToolCallBudgetIds?: string[]; /** A complete assistant tool block had no matching partial capture. */ uncapturedToolUse?: boolean; + /** Consume complete assistant tool_use blocks when the CLI does not stream partial tool events. */ + completeAssistantToolUse?: boolean; /** Highest-seen usage snapshot from `message_delta`/assistant frames before a terminal result. */ partialUsage?: OcxUsage; } @@ -283,6 +287,7 @@ export function mapStreamMessageToEvents(message: StreamMessage, state: StreamPa // Fallback path: a complete assistant message. Surface text and thinking independently // only when the partial delta stream did not already carry them (§十二). const messageRecord = asRecord(message.message); + if (messageRecord?.stop_reason === "tool_use") state.sawAssistantToolUseStop = true; const content = messageRecord?.content; if (Array.isArray(content)) { for (const block of content) { @@ -297,7 +302,23 @@ export function mapStreamMessageToEvents(message: StreamMessage, state: StreamPa if (thinking) events.push({ type: "thinking_delta", thinking }); } else if (blockType === "tool_use") { const id = asString(part.id); - if (!id || !state.partialToolCallIds?.has(id)) state.uncapturedToolUse = true; + if (state.completeAssistantToolUse && !state.partialToolCallIds?.has(id ?? "")) { + const name = asString(part.name); + const input = asRecord(part.input); + if (!id || !name || !input) throw new CodingAgentProtocolError("Coding-agent CLI returned a tool call without structured identity and input."); + const limit = Math.min(MAX_TOOL_BLOCK_STARTS, state.maxToolBlockStarts ?? MAX_TOOL_BLOCK_STARTS); + if ((state.toolBlockStarts ?? 0) >= limit) { + state.toolCallLimitExceeded = true; + return events; + } + state.toolBlockStarts = (state.toolBlockStarts ?? 0) + 1; + events.push( + { type: "tool_call_start", id, name }, + { type: "tool_call_delta", arguments: JSON.stringify(input) }, + { type: "tool_call_end" }, + ); + state.completedToolCalls = (state.completedToolCalls ?? 0) + 1; + } else if (!id || !state.partialToolCallIds?.has(id)) state.uncapturedToolUse = true; } } } @@ -625,7 +646,7 @@ function imagePart(imageUrl: string): WireContentPart | undefined { return undefined; } -function formatMessageForHistory(message: OcxMessage): string { +function formatMessageForHistory(message: OcxMessage, vendorNameByWire?: ReadonlyMap): string { if (message.role === "user") { const text = typeof message.content === "string" ? message.content @@ -640,8 +661,10 @@ function formatMessageForHistory(message: OcxMessage): string { } else if (part.type === "thinking" && part.thinking.trim()) { parts.push(`[Thinking: ${part.thinking.trim()}]`); } else if (part.type === "toolCall") { + const wireName = namespacedToolName(part.namespace, part.name); + const name = vendorNameByWire?.get(wireName) ?? wireName; const args = JSON.stringify(part.arguments ?? {}); - parts.push(`[Tool call: ${part.name} (call_id: ${part.id}) with args: ${args}]`); + parts.push(`[Tool call: ${name} (call_id: ${part.id}) with args: ${args}]`); } } return `ASSISTANT:\n${parts.join("\n") || "(empty response)"}`; @@ -663,7 +686,10 @@ function formatMessageForHistory(message: OcxMessage): string { * accepts `type: "user"` frames. Writing undocumented `type: "assistant"` frames is rejected. * Non-user messages are therefore projected into valid user frames. */ -export function buildInputLines(message: OcxMessage): string[] { +export function buildInputLines( + message: OcxMessage, + options: { vendorNameByWire?: ReadonlyMap } = {}, +): string[] { if (message.role === "developer") return []; const content: WireContentPart[] = []; @@ -684,7 +710,7 @@ export function buildInputLines(message: OcxMessage): string[] { } } } else { - const formatted = formatMessageForHistory(message); + const formatted = formatMessageForHistory(message, options.vendorNameByWire); if (formatted) content.push(textPart(formatted)); } @@ -718,14 +744,17 @@ export function buildSystemPrompt(parsed: OcxParsedRequest): string | undefined * prior conversation turns are structured as bounded context text with tool results as text, * clearly demarcated from the current user request. Codex retains tool control; vendor tools are never invoked. */ -export function buildConversationInput(parsed: OcxParsedRequest, options: { maxHistoryChars?: number } = {}): string[] { +export function buildConversationInput( + parsed: OcxParsedRequest, + options: { maxHistoryChars?: number; vendorNameByWire?: ReadonlyMap } = {}, +): string[] { const nonDev = parsed.context.messages.filter(m => m.role !== "developer"); if (nonDev.length === 0) { return [JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: "" }] } })]; } if (nonDev.length === 1 && nonDev[0]!.role === "user") { - return buildInputLines(nonDev[0]!); + return buildInputLines(nonDev[0]!, { vendorNameByWire: options.vendorNameByWire }); } // Multi-turn conversation or history with tool results: @@ -796,13 +825,16 @@ export function buildConversationInput(parsed: OcxParsedRequest, options: { maxH const status = currentMessage.isError ? " (error)" : ""; currentRequestText = `TOOL RESULT (call_id: ${currentMessage.toolCallId})${status}:\n${text}\n\nPlease proceed based on the above tool result.`; } else { - currentRequestText = formatMessageForHistory(currentMessage); + currentRequestText = formatMessageForHistory(currentMessage, options.vendorNameByWire); } const imageBlocks: WireContentPart[] = [...historyImageBlocks, ...currentImageBlocks]; const maxHistoryChars = options.maxHistoryChars ?? MAX_PROJECTED_HISTORY_CHARS; - let historyText = historyMessages.map(formatMessageForHistory).filter(Boolean).join("\n\n"); + let historyText = historyMessages + .map(msg => formatMessageForHistory(msg, options.vendorNameByWire)) + .filter(Boolean) + .join("\n\n"); if (historyText.length > maxHistoryChars) { historyText = `[Earlier conversation history truncated for length...]\n\n` + historyText.slice(historyText.length - maxHistoryChars); diff --git a/src/adapters/coding-agent/tool-catalog.ts b/src/adapters/coding-agent/tool-catalog.ts new file mode 100644 index 00000000000..9da511c4f5a --- /dev/null +++ b/src/adapters/coding-agent/tool-catalog.ts @@ -0,0 +1,75 @@ +import { namespacedToolName, requiresToolCall, toolChoiceToolPredicate, type OcxParsedRequest } from "../../types"; +import { stripResponsesOnlyEncryptedMarker } from "../responses-tool-schema"; + +// The selected catalog also supplies one comma-joined --allowed-tools argv value. +// Bound that full argument separately from individual names for Windows process creation. +export const CODING_AGENT_TOOL_LIMITS = Object.freeze({ + maxTools: 128, + maxNameBytes: 512, + maxAllowedToolsArgBytes: 8 * 1024, + maxToolBytes: 256 * 1024, + maxCatalogBytes: 2 * 1024 * 1024, +}); + +const encoder = new TextEncoder(); +const INVALID_NAME_PART = /[,\s\u0000-\u001f\u007f-\u009f]/u; + +export class CodingAgentToolCatalogError extends Error { + constructor(message: string) { + super(message); + this.name = "CodingAgentToolCatalogError"; + } +} + +function validNamePart(value: unknown): value is string { + if (typeof value !== "string" || !value || INVALID_NAME_PART.test(value)) return false; + // TextEncoder replaces lone surrogates, which would change tool identity on the MCP wire. + for (let i = 0; i < value.length; i++) { + const unit = value.charCodeAt(i); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(++i); + if (!(next >= 0xdc00 && next <= 0xdfff)) return false; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + return false; + } + } + return true; +} + +/** One client-owned catalog; the CLI spelling is only a projection of its wire names. */ +export function buildCodingAgentToolCatalog(parsed: OcxParsedRequest, serverName: string) { + const declared = parsed.context.tools ?? []; + const selected = declared.filter(toolChoiceToolPredicate(parsed.options.toolChoice, declared)); + if (selected.length > CODING_AGENT_TOOL_LIMITS.maxTools) throw new CodingAgentToolCatalogError("tool catalog exceeds tool count limit"); + const requireToolCall = requiresToolCall(parsed.options.toolChoice); + const emittedNameMap = new Map(); + // JSON array brackets and commas count too; avoid serializing the whole catalog twice. + let catalogBytes = 2; + let allowedToolsArgBytes = 0; + const tools = selected.map((tool, index) => { + if (!tool || !validNamePart(tool.name) || (tool.namespace !== undefined && !validNamePart(tool.namespace))) { + throw new CodingAgentToolCatalogError("invalid tool name or namespace"); + } + const wireName = namespacedToolName(tool.namespace, tool.name); + if (encoder.encode(wireName).byteLength > CODING_AGENT_TOOL_LIMITS.maxNameBytes) { + throw new CodingAgentToolCatalogError("tool name exceeds byte limit"); + } + const definition = { + name: wireName, + description: tool.description || `Tool: ${wireName}`, + inputSchema: stripResponsesOnlyEncryptedMarker(tool.parameters) as Record, + }; + const bytes = encoder.encode(JSON.stringify(definition)).byteLength; + if (bytes > CODING_AGENT_TOOL_LIMITS.maxToolBytes) throw new CodingAgentToolCatalogError("tool definition exceeds byte limit"); + catalogBytes += bytes + (index ? 1 : 0); + if (catalogBytes > CODING_AGENT_TOOL_LIMITS.maxCatalogBytes) throw new CodingAgentToolCatalogError("tool catalog exceeds byte limit"); + const emittedName = `mcp__${serverName}__${wireName}`; + allowedToolsArgBytes += encoder.encode(emittedName).byteLength + (index ? 1 : 0); + if (allowedToolsArgBytes > CODING_AGENT_TOOL_LIMITS.maxAllowedToolsArgBytes) { + throw new CodingAgentToolCatalogError("allowed-tools argument exceeds byte limit"); + } + emittedNameMap.set(emittedName, wireName); + return definition; + }); + return { tools, emittedNameMap, requireToolCall }; +} diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts index f60363108cb..fe59db81d4b 100644 --- a/src/adapters/coding-agent/turn.ts +++ b/src/adapters/coding-agent/turn.ts @@ -43,6 +43,9 @@ export interface CodingAgentDeps { killWindowsProcessTree?: KillWindowsProcessTreeFn; /** Test seam for a catalog/config write failure after private bridge-directory creation. */ writeToolBridgeFile?: typeof writeFile; + /** Injectable timer primitives for deterministic tests without sleeping. */ + setTimeout?: typeof setTimeout; + clearTimeout?: typeof clearTimeout; } const DEFAULT_TIMEOUT_MS = 300_000; @@ -113,28 +116,33 @@ export interface CodingAgentTurnInput { /** Family-specific scoped env builder (credential + region switch on top of baseScopedEnv). */ buildEnv: (profile: CodingAgentProviderProfile, apiKey: string) => Record; /** - * Opt-in capture-only tool bridge. When present with a non-empty catalog, the turn writes a - * validated catalog plus an MCP config to a private temp dir, passes `--mcp-config` (with exact - * `--allowedTools`) alongside the family's tools-disabled args, translates captured tool_use - * names back to request wire names, and terminates the process tree at `message_stop` because - * the capture-only MCP handler intentionally never answers. Execution stays with the client. + * Opt-in isolated MCP catalog. The turn writes a catalog and MCP config to a private temp dir, + * advertises only the selected names, and returns structured CLI tool intent to the client. + * The MCP handler never executes the client tool. */ toolBridge?: CodingAgentToolBridgeInput; deps: CodingAgentDeps; } -/** Opt-in capture-only tool bridge for one coding-agent CLI turn. */ +/** Opt-in isolated tool catalog for one coding-agent CLI turn. */ export interface CodingAgentToolBridgeInput { /** MCP server name advertised to the CLI; tool_use blocks render it as `mcp____`. */ serverName: string; - /** Absolute path of the capture-only MCP server module, run with the serving runtime. */ + /** Absolute path of the MCP server module, run with the serving runtime. */ serverModulePath: string; /** Validated tool catalog advertised over ListTools; the server never executes a call. */ tools: ReadonlyArray<{ name: string; description: string; inputSchema: Record }>; /** CLI-emitted tool name (`mcp____`) to the request's wire tool name. */ emittedNameMap: Map; - /** Captured tool_use blocks accepted in one assistant message. */ + /** Tool_use blocks accepted in one assistant message. */ maxTurnToolCalls: number; + /** Finish the turn when a complete assistant frame supplies structured tool_use blocks. */ + completeAssistantToolUse?: boolean; + /** Authoritative tool-turn stop signal; existing coding-agent bridges default to message_stop. */ + toolTurnCompletionSignal?: "message_stop" | "assistant_tool_use_stop"; + /** Family-specific CLI flag spelling and compiled MCP helper entrypoint. */ + allowedToolsFlag?: string; + standaloneEntrypoint?: string; /** * The request's `tool_choice` requires a tool call (`required`, or a named selection). * The nested CLI has no documented force-tool flag, so this is enforced locally: a @@ -164,6 +172,8 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise { + killTimer = setTimeoutFn(() => { try { child.kill("SIGKILL"); } catch { /* already gone */ } }, killGraceMs); }; @@ -352,7 +366,7 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise { + const timeoutTimer = setTimeoutFn(() => { kill(); stopStream(); emitOnce({ type: "error", message: `${profile.label} turn timed out.`, status: 504, errorType: "upstream_error", code: "timeout", retryable: true }); @@ -364,7 +378,7 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise { - clearTimeout(timeoutTimer); + clearTimeoutFn(timeoutTimer); incoming.abortSignal?.removeEventListener("abort", onAbort); try { child.stdin?.destroy(); } catch { /* ignore */ } // Termination is owned by the reap step below, not here: killing in cleanup would set @@ -374,9 +388,10 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise | undefined; @@ -389,7 +404,12 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise() : undefined, + completeAssistantToolUse: toolBridge?.completeAssistantToolUse, }; + const toolTurnCompletionSignal = toolBridge?.toolTurnCompletionSignal ?? "message_stop"; + const sawSelectedToolTurnStop = (): boolean => toolTurnCompletionSignal === "assistant_tool_use_stop" + ? state.sawAssistantToolUseStop === true + : state.sawMessageStop === true; try { // Write the replayed conversation, then close stdin so a single-shot turn can complete. @@ -403,14 +423,16 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise [wire, emitted])) + : undefined; + for (const line of buildConversationInput(parsed, { maxHistoryChars: historyCharLimit, vendorNameByWire })) stdin.write(`${line}\n`); stdin.end(); } const stdout = child.stdout; if (!stdout) throw new CodingAgentProtocolError(`${profile.label} CLI produced no stdout stream`); try { let initValidated = false; - let failClosed = false; for await (const message of readJsonLines(stdout)) { if (incoming.abortSignal?.aborted) break; if (toolBridge) { @@ -522,7 +544,7 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise 0 && (state.completedToolCalls ?? 0) !== (state.toolBlockStarts ?? 0) ) { @@ -596,9 +619,10 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise 0) { + if (toolBridge && !terminalEmitted && sawToolTurnStop && (state.toolBlockStarts ?? 0) > 0 + && (state.completedToolCalls ?? 0) === (state.toolBlockStarts ?? 0)) { // The raw-start gate above already refused any call opened before the handshake. - // The capture-only MCP handler never answers, so the CLI parks after message_stop. + // The capture-only MCP handler never answers, so the CLI parks after the tool-turn 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. // Pre-result usage snapshots keep this terminated leg accountable: no result frame @@ -633,17 +657,17 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise { kill(); }, killGraceMs); + const graceTimer = setTimeoutFn(() => { kill(); }, killGraceMs); let reapTimer: ReturnType | undefined; await Promise.race([ processLifecycle, new Promise(resolve => { - reapTimer = setTimeout(resolve, reapTimeoutMs); + reapTimer = setTimeoutFn(resolve, reapTimeoutMs); }), ]); - clearTimeout(graceTimer); - if (reapTimer) clearTimeout(reapTimer); - if (killTimer) clearTimeout(killTimer); + clearTimeoutFn(graceTimer); + if (reapTimer) clearTimeoutFn(reapTimer); + if (killTimer) clearTimeoutFn(killTimer); if (!terminalEmitted) { const stderr = redactSecrets(boundedStderr(stderrChunks), profile.tokenEnv, apiKey); @@ -686,10 +710,10 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise { const promptEnv = profile.region === "cn" ? "QODERCN_APPEND_SYSTEM_PROMPT" : "QODER_APPEND_SYSTEM_PROMPT"; return { ...baseScopedEnv(), NO_COLOR: "1", [profile.tokenEnv]: apiKey, ...(systemPrompt ? { [promptEnv]: systemPrompt } : {}) }; } -/** Single-shot, tools-disabled Qoder CLI invocation; Codex remains the tool owner. */ +/** Disable Qoder's built-in tools; only a request-scoped MCP catalog can be exposed. */ export function buildQoderArgs(parsed: OcxParsedRequest, provider: OcxProviderConfig): string[] { const args = [ "-p", @@ -35,8 +40,8 @@ export function buildQoderArgs(parsed: OcxParsedRequest, provider: OcxProviderCo /** * Wrap the turn's outbound channel with the scaffolding guard (#4190). * - * The vendor CLI can put its own agent layer into the text channel despite being launched - * with tools and MCP disabled, and the shared stream-json parser forwards a text delta + * The vendor CLI can put its own agent layer into the text channel despite built-in tools + * being disabled, and the shared stream-json parser forwards a text delta * without inspecting it. This is the last point that is still qoder-specific, so the guard * sits here rather than in the parser every coding-agent CLI shares. * @@ -123,7 +128,35 @@ export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapt }); return; } - // Qoder documents QODER_APPEND_SYSTEM_PROMPT for scoped child configuration. + let catalog: ReturnType; + try { + catalog = buildCodingAgentToolCatalog(parsed, QODER_MCP_SERVER_NAME); + } catch (error) { + if (!(error instanceof CodingAgentToolCatalogError)) throw error; + emit({ + type: "error", + message: "Invalid Qoder tool catalog.", + status: 400, + errorType: "invalid_request_error", + code: "tool_catalog_invalid", + retryable: false, + }); + return; + } + const toolBridge: CodingAgentToolBridgeInput | undefined = catalog.tools.length > 0 || catalog.requireToolCall + ? { + serverName: QODER_MCP_SERVER_NAME, + serverModulePath: QODER_MCP_SERVER_PATH, + tools: catalog.tools, + emittedNameMap: catalog.emittedNameMap, + maxTurnToolCalls: MAX_TOOL_BLOCK_STARTS, + completeAssistantToolUse: true, + toolTurnCompletionSignal: "assistant_tool_use_stop", + allowedToolsFlag: "--allowed-tools", + standaloneEntrypoint: "__qoder-mcp", + requireToolCall: catalog.requireToolCall, + } + : undefined; // Keep the folded prompt out of argv and never inherit an ambient vendor prompt. const system = buildSystemPrompt(parsed); await runCodingAgentTurn({ @@ -135,6 +168,7 @@ export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapt buildArgs: (_profile, req, prov) => buildQoderArgs(req, prov), buildEnv: (profile, apiKey) => buildQoderChildEnv(profile as QoderProfile, apiKey, system), deps, + ...(toolBridge ? { toolBridge } : {}), }); }, }; diff --git a/src/adapters/qoder/scaffold-guard.ts b/src/adapters/qoder/scaffold-guard.ts index 529a9c0c882..8ace7c93c72 100644 --- a/src/adapters/qoder/scaffold-guard.ts +++ b/src/adapters/qoder/scaffold-guard.ts @@ -1,9 +1,8 @@ /** * Vendor-scaffolding guard for the qoder route (#4190). * - * The qoder route is contractually a text and reasoning surface: the CLI is spawned with - * `--tools "" --strict-mcp-config --setting-sources ""`, and Codex keeps tool ownership. - * The vendor CLI does not always honour that. It has been observed emitting its own agent + * Even with built-in tools disabled and strict MCP isolation, Codex keeps tool execution + * ownership. The vendor CLI does not always honour that. It has been observed emitting its own agent * layer into the assistant text channel — an MCP lazy-loading `` block * listing the local machine's configured MCP servers, and framework tool-call markup with a * mismatched closer. Both reached the client verbatim, because the shared stream-json parser diff --git a/src/cli/index.ts b/src/cli/index.ts index 51f79655bc6..4a4112b29f6 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -209,11 +209,17 @@ initializeNodeLauncherContext(); // The compiled executable is also the capture-only MCP server's launcher. // Handle this private entrypoint before CLI preflight or command dispatch. if (process.argv[2] === "__keyring-load-check") { console.log(JSON.stringify((await import("../lib/keyring-native")).inspectKeyringBinding())); process.exit(0); } -if (process.argv[2] === "__codebuddy-mcp") { - const { runCodeBuddyMcpServer } = await import("../adapters/codebuddy/mcp-server"); - await runCodeBuddyMcpServer(process.argv[3] ?? ""); - // The MCP stdio loop owns this process until stdin closes; do not fall through - // to ordinary CLI dispatch or exit after the handshake completes. + +// The compiled executable also launches the isolated MCP servers. +// Handle these private entrypoints before CLI preflight or command dispatch. +if (process.argv[2] === "__codebuddy-mcp" || process.argv[2] === "__qoder-mcp") { + if (process.argv[2] === "__codebuddy-mcp") { + const { runCodeBuddyMcpServer } = await import("../adapters/codebuddy/mcp-server"); + await runCodeBuddyMcpServer(process.argv[3] ?? ""); + } else { + const { runCodingAgentMcpServer } = await import("../adapters/coding-agent/mcp-server"); + await runCodingAgentMcpServer(process.argv[3] ?? ""); + } await new Promise(() => {}); } diff --git a/src/responses/spill-store.ts b/src/responses/spill-store.ts index 9c2db457ec2..9188ca387a2 100644 --- a/src/responses/spill-store.ts +++ b/src/responses/spill-store.ts @@ -72,6 +72,7 @@ export interface ResponseSpillPayload { */ providerOutputStart?: number; providers?: OcxProviderContinuationState; + unforcedStoreFalse?: boolean; } export interface ResponseSpillRef { @@ -496,6 +497,7 @@ function serializedSpill( items: state.items, ...(state.providerOutputStart !== undefined ? { providerOutputStart: state.providerOutputStart } : {}), ...(state.providers ? { providers: state.providers } : {}), + ...(state.unforcedStoreFalse ? { unforcedStoreFalse: state.unforcedStoreFalse } : {}), }; const serialized = JSON.stringify(payload); if (serialized === undefined) throw new Error("Response spill serialization failed"); @@ -549,11 +551,12 @@ function validPayload(value: unknown, responseId: string): value is ResponseSpil if (!value || typeof value !== "object" || Array.isArray(value)) return false; const payload = value as Record; const keys = Object.keys(payload); - if (keys.some(key => !["version", "responseId", "createdAt", "clientThreadId", "items", "providerOutputStart", "providers"].includes(key))) return false; + if (keys.some(key => !["version", "responseId", "createdAt", "clientThreadId", "items", "providerOutputStart", "providers", "unforcedStoreFalse"].includes(key))) return false; if (payload.version !== 1 || payload.responseId !== responseId) return false; if (typeof payload.createdAt !== "number" || !Number.isFinite(payload.createdAt)) return false; if (payload.clientThreadId !== undefined && (typeof payload.clientThreadId !== "string" || payload.clientThreadId.trim().length === 0)) return false; + if (payload.unforcedStoreFalse !== undefined && typeof payload.unforcedStoreFalse !== "boolean") return false; if (!Array.isArray(payload.items)) return false; // A malformed boundary must degrade to "never skip", never to a bad index: reject the // payload outright so materialization treats it as corrupt rather than trusting it. diff --git a/src/responses/state.ts b/src/responses/state.ts index 892ba556e09..5328f71d744 100644 --- a/src/responses/state.ts +++ b/src/responses/state.ts @@ -21,6 +21,9 @@ import { import { collectReferencedSpillFileNames, snapshotReferencedSpillFileNames } from "./state/spill-inspect"; import { selectSnapshotEntries, snapshotPayload } from "./state/snapshot-select"; import { clientCarriedPrefixLength, providerIssuedIdentity } from "./state/replay-fingerprint"; +import { hasPendingFunctionCall, allowsUnforcedStoreFalseReplay } from "./state/unforced-store-false"; +import { computeResponseStateMetrics, type ResponseStateMetrics } from "./state/metrics"; +export type { ResponseStateMetrics } from "./state/metrics"; export type { ResponseStateTempRecoveryResult, ResponseStateTempRecoveryOptions } from "./state/temp-recovery"; export type { ResponseSpillDirInspection } from "./spill-store"; export { recoverStaleResponseStateTemps, reclaimAbandonedResponseStateTemps, inspectAbandonedResponseStateTemps, sweepAbandonedResponseStateTemps } from "./state/temp-recovery"; @@ -118,6 +121,7 @@ export interface ResidentResponseState { /** Index in `items` where provider output begins; see clientCarriedPrefixLength. */ providerOutputStart?: number; providers?: OcxProviderContinuationState; + unforcedStoreFalse?: boolean; sizeBytes: number; } @@ -128,6 +132,7 @@ export interface SpilledResponseState { /** Mirrors the spilled payload boundary so a spilled entry keeps its anchor. */ providerOutputStart?: number; providers?: OcxProviderContinuationState; + unforcedStoreFalse?: boolean; spill: ResponseSpillRef; sizeBytes: number; } @@ -315,6 +320,7 @@ function measureResidentEntry(id: string, entry: ResidentInput): ResidentRespons items: entry.items, ...(entry.providerOutputStart !== undefined ? { providerOutputStart: entry.providerOutputStart } : {}), ...(entry.providers ? { providers: entry.providers } : {}), + ...(entry.unforcedStoreFalse ? { unforcedStoreFalse: entry.unforcedStoreFalse } : {}), }); return sizeBytes === null ? null : { kind: "resident", ...entry, sizeBytes }; } @@ -417,7 +423,9 @@ function swapResidentForSpill(id: string, expected: ResidentResponseState, ref: kind: "spill", createdAt: expected.createdAt, ...(expected.clientThreadId ? { clientThreadId: expected.clientThreadId } : {}), + ...(expected.providerOutputStart !== undefined ? { providerOutputStart: expected.providerOutputStart } : {}), ...(expected.providers ? { providers: expected.providers } : {}), + ...(expected.unforcedStoreFalse ? { unforcedStoreFalse: expected.unforcedStoreFalse } : {}), spill: ref, }; const next: SpilledResponseState = { ...base, sizeBytes: stubSize(id, base) }; @@ -441,6 +449,7 @@ function replaceSpillEntryAtomically( items: candidate.items, ...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}), ...(candidate.providers ? { providers: candidate.providers } : {}), + ...(candidate.unforcedStoreFalse ? { unforcedStoreFalse: candidate.unforcedStoreFalse } : {}), }); const base: Omit = { kind: "spill", @@ -448,6 +457,7 @@ function replaceSpillEntryAtomically( ...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}), ...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}), ...(candidate.providers ? { providers: candidate.providers } : {}), + ...(candidate.unforcedStoreFalse ? { unforcedStoreFalse: candidate.unforcedStoreFalse } : {}), spill: ref, }; const next: SpilledResponseState = { ...base, sizeBytes: stubSize(id, base) }; @@ -533,6 +543,7 @@ function admitOversizedCandidate( items: candidate.items, ...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}), ...(candidate.providers ? { providers: candidate.providers } : {}), + ...(candidate.unforcedStoreFalse ? { unforcedStoreFalse: candidate.unforcedStoreFalse } : {}), }); // Enforce the ceiling against the REAL envelope: the spill payload adds // the {version, responseId, ...} wrapper, so a candidate within the @@ -547,7 +558,9 @@ function admitOversizedCandidate( kind: "spill", createdAt: candidate.createdAt, ...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}), + ...(candidate.providerOutputStart !== undefined ? { providerOutputStart: candidate.providerOutputStart } : {}), ...(candidate.providers ? { providers: candidate.providers } : {}), + ...(candidate.unforcedStoreFalse ? { unforcedStoreFalse: candidate.unforcedStoreFalse } : {}), spill: ref, }; const next: SpilledResponseState = { ...base, sizeBytes: stubSize(id, base) }; @@ -935,6 +948,7 @@ function pruneResponses(at = now()): void { items: entry.items, ...(entry.providerOutputStart !== undefined ? { providerOutputStart: entry.providerOutputStart } : {}), ...(entry.providers ? { providers: entry.providers } : {}), + ...(entry.unforcedStoreFalse ? { unforcedStoreFalse: entry.unforcedStoreFalse } : {}), }); if (swapResidentForSpill(oldestId, entry, ref)) noteSpillWriteSuccess(); } catch (error) { @@ -994,6 +1008,7 @@ export function evictOldestResponseContinuationForBudget(): number { items: entry.items, ...(entry.providerOutputStart !== undefined ? { providerOutputStart: entry.providerOutputStart } : {}), ...(entry.providers ? { providers: entry.providers } : {}), + ...(entry.unforcedStoreFalse ? { unforcedStoreFalse: entry.unforcedStoreFalse } : {}), }); if (swapResidentForSpill(id, entry, ref)) noteSpillWriteSuccess(); } catch (error) { @@ -1038,6 +1053,7 @@ function materializeEntry( ? { providerOutputStart: result.payload.providerOutputStart } : {}), ...(result.payload.providers ? { providers: result.payload.providers } : {}), + ...(result.payload.unforcedStoreFalse ? { unforcedStoreFalse: result.payload.unforcedStoreFalse } : {}), }); if (!state) { spillCounters.readFailures += 1; @@ -1075,6 +1091,15 @@ export function expandPreviousResponseInput(body: unknown, clientThreadId?: stri replayScopeMismatchDrops += 1; return body; } + const clientInput = inputItems(request.input); + const stored = materialized.state.items; + const carried = clientCarriedPrefixLength(stored, clientInput); + if ( + materialized.state.unforcedStoreFalse + && !allowsUnforcedStoreFalseReplay(stored, materialized.state.providerOutputStart, clientInput, carried) + ) { + return body; + } // The client already replayed this history verbatim. Prepending the stored copy would // double it, and the doubled turn is stored again, so the next turn triples (#1412 saw // 127k of real context reach 1.3M tokens this way). @@ -1087,10 +1112,7 @@ export function expandPreviousResponseInput(body: unknown, clientThreadId?: stri // There is no invariant that provider output always carries ids, so an entry whose output // has none simply never skips. { - const clientInput = inputItems(request.input); - const stored = materialized.state.items; const anchor = materialized.state.providerOutputStart; - const carried = clientCarriedPrefixLength(stored, clientInput); if ( carried === stored.length && anchor !== undefined @@ -1110,9 +1132,9 @@ export function expandPreviousResponseInput(body: unknown, clientThreadId?: stri } const expanded = { ...request, - input: [...materialized.state.items, ...inputItems(request.input)], + input: [...stored, ...clientInput], }; - replayedInputPrefixLengths.set(expanded, materialized.state.items.length); + replayedInputPrefixLengths.set(expanded, stored.length); return expanded; } @@ -1156,29 +1178,6 @@ export function previousResponseProviderState(responseId: string | undefined): O return providers ? structuredClone(providers) : undefined; } -export interface ResponseStateMetrics { - count: number; - residentCount: number; - spillStubCount: number; - tombstoneCount: number; - totalBytes: number; - spillPayloadBytes: number; - largestBytes: number; - oldestAgeMs: number; - spillWrites: number; - spillWriteFailures: number; - spillWriteStatus: ResponseSpillWriteStatus; - spillWriteConsecutiveFailures: number; - spillLastWriteFailureCode: ResponseSpillWriteFailureCode | null; - spillLastWriteFailureOrigin: ResponseSpillWriteFailureOrigin | null; - spillAclRetryReturnedTimeouts: number; - spillAclTimeoutMemoRefusals: number; - spillLastWriteFailureAt: number | null; - spillLastWriteSuccessAt: number | null; - spillReadFailures: number; - replayScopeMismatchDrops: number; -} - /** * Observe-only snapshot of the in-RAM continuation store, surfaced via GET /api/system/memory. * Additive and side-effect free — it does NOT lazy-load the disk snapshot, prune, or evict — so a @@ -1189,50 +1188,23 @@ export interface ResponseStateMetrics { * store (JS heap) or in the runtime allocator (native). */ export function responseStateMetrics(): ResponseStateMetrics { - const at = now(); - let largestBytes = 0; - let oldestCreatedAt = at; - let residentCount = 0; - let spillStubCount = 0; - let tombstoneCount = 0; - let spillPayloadBytes = 0; - for (const state of states.values()) { - const bytes = state.sizeBytes; - if (bytes > largestBytes) largestBytes = bytes; - if (state.createdAt < oldestCreatedAt) oldestCreatedAt = state.createdAt; - if (state.kind === "resident") { - residentCount += 1; - } else if (state.kind === "spill") { - spillStubCount += 1; - spillPayloadBytes += state.spill.payloadBytes; - } else tombstoneCount += 1; - } - return { - count: states.size, - residentCount, - spillStubCount, - tombstoneCount, + return computeResponseStateMetrics({ + states: states.values(), + stateCount: states.size, + now: now(), totalBytes: responseContinuationRetainedStoreSnapshot().bytes, - spillPayloadBytes, - largestBytes, - oldestAgeMs: states.size > 0 ? at - oldestCreatedAt : 0, spillWrites: spillCounters.writes, spillWriteFailures: spillCounters.writeFailures, - spillWriteStatus: spillWriteHealth.consecutiveFailures > 0 - ? "degraded" - : spillWriteHealth.lastSuccessAt !== null - ? "healthy" - : "initial", spillWriteConsecutiveFailures: spillWriteHealth.consecutiveFailures, - spillLastWriteFailureCode: spillWriteHealth.lastFailureCode, - spillLastWriteFailureOrigin: spillWriteHealth.lastFailureOrigin, + spillLastFailureCode: spillWriteHealth.lastFailureCode, + spillLastFailureOrigin: spillWriteHealth.lastFailureOrigin, + spillLastSuccessAt: spillWriteHealth.lastSuccessAt, spillAclRetryReturnedTimeouts: spillCounters.aclRetryReturnedTimeouts, spillAclTimeoutMemoRefusals: spillCounters.aclTimeoutMemoRefusals, - spillLastWriteFailureAt: spillWriteHealth.lastFailureAt, - spillLastWriteSuccessAt: spillWriteHealth.lastSuccessAt, + spillLastFailureAt: spillWriteHealth.lastFailureAt, spillReadFailures: spillCounters.readFailures, replayScopeMismatchDrops, - }; + }); } /** @@ -1256,7 +1228,7 @@ export function rememberResponseState( requestBody: unknown, response: { id?: unknown; output?: unknown; status?: unknown; incomplete_details?: unknown }, providerState?: OcxProviderContinuationState | string, - opts?: { force?: boolean; clientThreadId?: string }, + opts?: { force?: boolean; clientThreadId?: string; retainForToolContinuation?: boolean }, ): void { if (!requestBody || typeof requestBody !== "object" || Array.isArray(requestBody)) return; const request = requestBody as Record; @@ -1266,8 +1238,10 @@ export function rememberResponseState( // The passthrough branch records with force so those chains can be expanded locally; the // store stays in-memory under RESPONSE_TTL_MS, so this is a proxy-internal continuation cache, not // real server-side response storage. - if (request.store === false && !opts?.force) return; if (typeof response.id !== "string" || !Array.isArray(response.output)) return; + // Only an explicitly opted-in adapter may retain pending client calls despite store:false. + const unforcedStoreFalse = request.store === false && !opts?.force; + if (unforcedStoreFalse && !(opts?.retainForToolContinuation === true && hasPendingFunctionCall(response.output))) return; if (response.status === "incomplete") { const details = response.incomplete_details; if (!details || typeof details !== "object" || Array.isArray(details) @@ -1300,6 +1274,7 @@ export function rememberResponseState( // incomplete agent turn on the Cursor side (we suspended without a real mcpResult), so its // checkpoint must not be reused — but the conversation id string itself is still valid. ...(Object.keys(normalizedProviderState).length > 0 ? { providers: normalizedProviderState } : {}), + ...(unforcedStoreFalse ? { unforcedStoreFalse: true } : {}), }); enforceAppOwnedMemoryBudget(); schedulePersist(); diff --git a/src/responses/state/metrics.ts b/src/responses/state/metrics.ts new file mode 100644 index 00000000000..f43533d5cf0 --- /dev/null +++ b/src/responses/state/metrics.ts @@ -0,0 +1,87 @@ +import type { StoredResponseState } from "../state"; +import type { ResponseSpillWriteStatus, ResponseSpillWriteFailureCode, ResponseSpillWriteFailureOrigin } from "./spill-failure"; + +export interface ResponseStateMetrics { + count: number; + residentCount: number; + spillStubCount: number; + tombstoneCount: number; + totalBytes: number; + spillPayloadBytes: number; + largestBytes: number; + oldestAgeMs: number; + spillWrites: number; + spillWriteFailures: number; + spillWriteStatus: ResponseSpillWriteStatus; + spillWriteConsecutiveFailures: number; + spillLastWriteFailureCode: ResponseSpillWriteFailureCode | null; + spillLastWriteFailureOrigin: ResponseSpillWriteFailureOrigin | null; + spillAclRetryReturnedTimeouts: number; + spillAclTimeoutMemoRefusals: number; + spillLastWriteFailureAt: number | null; + spillLastWriteSuccessAt: number | null; + spillReadFailures: number; + replayScopeMismatchDrops: number; +} + +export function computeResponseStateMetrics(params: { + states: Iterable; + stateCount: number; + now: number; + totalBytes: number; + spillWrites: number; + spillWriteFailures: number; + spillWriteConsecutiveFailures: number; + spillLastFailureCode: ResponseSpillWriteFailureCode | null; + spillLastFailureOrigin: ResponseSpillWriteFailureOrigin | null; + spillLastSuccessAt: number | null; + spillAclRetryReturnedTimeouts: number; + spillAclTimeoutMemoRefusals: number; + spillLastFailureAt: number | null; + spillReadFailures: number; + replayScopeMismatchDrops: number; +}): ResponseStateMetrics { + let largestBytes = 0; + let oldestCreatedAt = params.now; + let residentCount = 0; + let spillStubCount = 0; + let tombstoneCount = 0; + let spillPayloadBytes = 0; + for (const state of params.states) { + const bytes = state.sizeBytes; + if (bytes > largestBytes) largestBytes = bytes; + if (state.createdAt < oldestCreatedAt) oldestCreatedAt = state.createdAt; + if (state.kind === "resident") { + residentCount += 1; + } else if (state.kind === "spill") { + spillStubCount += 1; + spillPayloadBytes += state.spill.payloadBytes; + } else tombstoneCount += 1; + } + return { + count: params.stateCount, + residentCount, + spillStubCount, + tombstoneCount, + totalBytes: params.totalBytes, + spillPayloadBytes, + largestBytes, + oldestAgeMs: params.stateCount > 0 ? params.now - oldestCreatedAt : 0, + spillWrites: params.spillWrites, + spillWriteFailures: params.spillWriteFailures, + spillWriteStatus: params.spillWriteConsecutiveFailures > 0 + ? "degraded" + : params.spillLastSuccessAt !== null + ? "healthy" + : "initial", + spillWriteConsecutiveFailures: params.spillWriteConsecutiveFailures, + spillLastWriteFailureCode: params.spillLastFailureCode, + spillLastWriteFailureOrigin: params.spillLastFailureOrigin, + spillAclRetryReturnedTimeouts: params.spillAclRetryReturnedTimeouts, + spillAclTimeoutMemoRefusals: params.spillAclTimeoutMemoRefusals, + spillLastWriteFailureAt: params.spillLastFailureAt, + spillLastWriteSuccessAt: params.spillLastSuccessAt, + spillReadFailures: params.spillReadFailures, + replayScopeMismatchDrops: params.replayScopeMismatchDrops, + }; +} diff --git a/src/responses/state/snapshot-codec.ts b/src/responses/state/snapshot-codec.ts index c5a53d1432a..76725e9ee31 100644 --- a/src/responses/state/snapshot-codec.ts +++ b/src/responses/state/snapshot-codec.ts @@ -24,6 +24,7 @@ interface LegacySnapshotState { providers?: OcxProviderContinuationState; conversationId?: unknown; cursorCheckpointUsable?: unknown; + unforcedStoreFalse?: unknown; } function isSpillRef(value: unknown): value is ResponseSpillRef { @@ -61,6 +62,7 @@ export function loadSnapshotEntry(id: string, value: unknown, store: SnapshotLoa // here; the spill payload validator re-checks it against the real array. ...(anchorFor(Number.MAX_SAFE_INTEGER) !== undefined ? { providerOutputStart: anchorFor(Number.MAX_SAFE_INTEGER) } : {}), ...(rec.providers ? { providers: rec.providers } : {}), + ...(rec.unforcedStoreFalse === true ? { unforcedStoreFalse: true } : {}), spill: rec.spill, }; store.replaceMapEntry(id, { ...base, sizeBytes: store.stubSize(id, base) }); @@ -88,6 +90,7 @@ export function loadSnapshotEntry(id: string, value: unknown, store: SnapshotLoa items: rec.items, ...(anchorFor(rec.items.length) !== undefined ? { providerOutputStart: anchorFor(rec.items.length) } : {}), ...(providers ? { providers } : {}), + ...(rec.unforcedStoreFalse === true ? { unforcedStoreFalse: true } : {}), }); if (!resident) { store.replaceMapEntry(id, store.tombstone(id, rec.createdAt)); diff --git a/src/responses/state/spill-queue.ts b/src/responses/state/spill-queue.ts index 33c4a292bca..76deaddf225 100644 --- a/src/responses/state/spill-queue.ts +++ b/src/responses/state/spill-queue.ts @@ -191,6 +191,7 @@ function spillPayloadForResident(candidate: ResidentResponseState): Parameters + !!item && typeof item === "object" && (item as { type?: unknown }).type === "function_call", + ); +} + +export function allowsUnforcedStoreFalseReplay( + stored: readonly unknown[], + providerOutputStart: number | undefined, + clientInput: readonly unknown[], + carried: number, +): boolean { + const anchor = providerOutputStart ?? 0; + const pendingCallIds = new Set(); + for (const item of stored.slice(anchor)) { + if (item && typeof item === "object" && (item as { type?: unknown }).type === "function_call") { + const callId = (item as { call_id?: unknown }).call_id; + if (typeof callId === "string" && callId) pendingCallIds.add(callId); + } + } + for (let i = 0; i < carried; i++) { + const item = clientInput[i]; + if (item && typeof item === "object" && (item as { type?: unknown }).type === "function_call_output") { + const callId = (item as { call_id?: unknown }).call_id; + if (typeof callId === "string") pendingCallIds.delete(callId); + } + } + return clientInput.slice(carried).some(item => { + if (item && typeof item === "object" && (item as { type?: unknown }).type === "function_call_output") { + const callId = (item as { call_id?: unknown }).call_id; + return typeof callId === "string" && pendingCallIds.has(callId); + } + return false; + }); +} diff --git a/src/server/responses/core-replay.ts b/src/server/responses/core-replay.ts index ec2dec2dc7d..efb5cd41587 100644 --- a/src/server/responses/core-replay.ts +++ b/src/server/responses/core-replay.ts @@ -37,6 +37,11 @@ export function adapterNeedsForcedContinuation(name: string): boolean { return name === "kiro" || name === "cursor"; } +/** Adapters opting into restricted store:false replay for pending client function calls. */ +export function adapterNeedsToolCallContinuation(name: string): boolean { + return name === "qoder"; +} + export type ContinuationOwnerRead = | { kind: "missing" } diff --git a/src/server/responses/run-turn-execution.ts b/src/server/responses/run-turn-execution.ts index 463c250c4a7..92acb6531bd 100644 --- a/src/server/responses/run-turn-execution.ts +++ b/src/server/responses/run-turn-execution.ts @@ -10,6 +10,7 @@ import { createAdapterEventQueue, preflightAdapterEvents, type AdapterEventPrefl import { bindRouteReasoningReplayScope, adapterNeedsForcedContinuation, + adapterNeedsToolCallContinuation, adapterResponseReachedServingTerminal, } from "./core-replay"; import { noteAttemptRecoveryWithheld, sealRequestAttemptIdentity, recordAttemptCredentialSource } from "../request-log"; @@ -790,7 +791,10 @@ export async function executeResponsesRunTurn( parsed._rawBody, response, continuationStateForResponse(providerState), - responseStateOptions(adapterNeedsForcedContinuation(transportState.adapter.name)), + { + ...responseStateOptions(adapterNeedsForcedContinuation(transportState.adapter.name)), + retainForToolContinuation: adapterNeedsToolCallContinuation(transportState.adapter.name), + }, ); } notifyResponseComplete(response); @@ -935,7 +939,10 @@ export async function executeResponsesRunTurn( parsed._rawBody, json, continuationStateForResponse(providerState), - responseStateOptions(adapterNeedsForcedContinuation(transportState.adapter.name)), + { + ...responseStateOptions(adapterNeedsForcedContinuation(transportState.adapter.name)), + retainForToolContinuation: adapterNeedsToolCallContinuation(transportState.adapter.name), + }, ); } // #1926 gap 2: the buffered path queued its signature persists inside diff --git a/src/server/responses/sidecar-execution.ts b/src/server/responses/sidecar-execution.ts index e34b4e80ef0..8a6ddcf62fb 100644 --- a/src/server/responses/sidecar-execution.ts +++ b/src/server/responses/sidecar-execution.ts @@ -37,7 +37,7 @@ import { formatAnthropicProviderForLog, } from "../../oauth/anthropic-routing"; import { resolveWireProtocolOverride } from "../adapter-resolve"; -import { bindRouteReasoningReplayScope, adapterNeedsForcedContinuation } from "./core-replay"; +import { bindRouteReasoningReplayScope, adapterNeedsForcedContinuation, adapterNeedsToolCallContinuation } from "./core-replay"; import { namespacedToolName } from "../../types"; import { providerFetch } from "./fetch-helpers"; import type { AttemptRecoveryKind } from "../../usage/log"; @@ -440,7 +440,10 @@ export async function executeResponsesSidecars( parsed._rawBody, response, continuationStateForResponse(providerState), - responseStateOptions(adapterNeedsForcedContinuation(transportState.adapter.name)), + { + ...responseStateOptions(adapterNeedsForcedContinuation(transportState.adapter.name)), + retainForToolContinuation: adapterNeedsToolCallContinuation(transportState.adapter.name), + }, ); notifyResponseComplete(response); }, diff --git a/src/types.ts b/src/types.ts index b44a67b0c69..d81f95b332c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,6 +16,7 @@ export { resolveToolChoiceWireName, modelInList, isAllowedToolChoice, + requiresToolCall, toolChoiceToolPredicate, declaresCodeModeExec, toolRestrictsCallers, diff --git a/src/types/tools.ts b/src/types/tools.ts index 0c285ef86f1..380d26f2960 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -399,6 +399,13 @@ export function isAllowedToolChoice(value: OcxToolChoice | undefined): value is return typeof value === "object" && value !== null && "allowedTools" in value; } +export function requiresToolCall(choice: OcxToolChoice | undefined): boolean { + return choice === "required" + || (typeof choice === "object" && choice !== null && ( + "name" in choice || ("mode" in choice && choice.mode === "required") + )); +} + /** Compile the request's tool-choice policy into a reusable advertisement/restoration predicate. */ export function toolChoiceToolPredicate( choice: OcxToolChoice | undefined, diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index e4184e562b7..8dc53fb84d9 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -22,14 +22,16 @@ and argument fragments charge the request's translator budget while buffered; cl replacement, and turn cleanup release those reservations. A new start on an occupied index closes the previous block only when its arguments form a complete JSON object; an unindexed delta or stop cannot be attributed to an indexed block, and a nonempty -argument delta that cannot be attributed fails immediately. Turn completion -requires every opened block to close, preserving the downstream single-open-call contract. +argument delta that cannot be attributed fails immediately. Tool-bridge completion requires every opened block to close, preserving the downstream single-open-call contract. +The authoritative completion signal is adapter-selected: existing shared coding-agent bridges default to `message_stop`, while Qoder selects only the final complete assistant frame's `message.stop_reason === "tool_use"` (its last sibling only). Qoder does not treat `message_stop` as its completion signal. Silence never ends the tool leg. An incomplete call fails closed with `protocol_error`; otherwise the bridge emits one `done(tool_use)` with usage and terminates the parked CLI; the external client owns execution. 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. The 8 MiB JSONL line ceiling is independent of the retained tool-block budget. +Qoder alone opts into restricted `store:false` function-call continuation through `adapterNeedsToolCallContinuation` in `src/server/responses/core-replay.ts`. RunTurn SSE/JSON and sidecar completion pass `retainForToolContinuation` using the serving adapter; `src/responses/state.ts` admits an unforced `store:false` response only with that opt-in and an actual `function_call`. Text-only Qoder responses and unforced non-opted-in providers, including CodeBuddy, are not retained. Saved entries keep the existing `unforcedStoreFalse` matching-tool-result replay gate through TTL, snapshot, spill, reload and memory-budget controls; the admission option is not persisted. Kiro/Cursor forced continuation and passthrough `force:true` remain unchanged. + Direct MCP names emitted in a verified custom code-mode catalog follow the [Responses restoration boundary](transports/responses-wire-shapes.md#direct-mcp-calls-in-code-mode). Ordinary structured functions named `exec` do not opt into this compatibility path. @@ -59,7 +61,15 @@ Failure prevents CLI spawn and settles the bridge's private directory; the CodeB also settles its prompt-file directory. Catalog and MCP-config write failures cover both owners. In a compiled executable, the bridge launches the private `__codebuddy-mcp` CLI entrypoint; source execution launches the MCP module with Bun. Both paths advertise only the request's -isolated catalog and leave tool execution to the external client. Qoder appends the folded +isolated catalog and leave tool execution to the external client. Qoder's selected catalog +projection in `src/adapters/coding-agent/tool-catalog.ts` rejects empty, control-bearing, comma-bearing, +or unpaired-surrogate name/namespace components and wire names over 512 UTF-8 bytes before staging. +It admits at most 128 selected tools, 256 KiB per serialized definition, and 2 MiB for the serialized +array (including brackets and separators); the comma-joined `--allowed-tools` argument is separately +capped at 8 KiB of UTF-8, counting full `mcp____` names and commas. Invalid catalogs return a fixed local 400 +without spawning Qoder. Valid wire identities and order are unchanged. Schemas are unchanged except for the +Responses-only encrypted marker stripped during projection. Duplicate and ambiguous wire +identities remain the Responses parser's responsibility. Qoder appends the folded system prompt through its documented scoped `QODER_APPEND_SYSTEM_PROMPT` or `QODERCN_APPEND_SYSTEM_PROMPT` child environment, never through command-line arguments or inherited vendor variables. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 5e648d1b623..634f85a8479 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -823,7 +823,7 @@ "provider-workspace-state.test.ts": "gui", "proxy-env-macos.test.ts": "server", "proxy-env.test.ts": "server", "proxy-liveness-package-tree-fence.test.ts": "server", "proxy-liveness.test.ts": "server", "qoder-adapter.test.ts": "providers", - "qoder-live-models.test.ts": "providers", "qoder-scaffold-guard.test.ts": "providers", + "qoder-live-models.test.ts": "providers", "qoder-mcp-server.test.ts": "providers", "qoder-scaffold-guard.test.ts": "providers", "quota-401-recovery-runtime.test.ts": "usage", "quota-401-recovery.test.ts": "usage", "quota-bars-rows.test.ts": "gui", "quota-reset-account-key.test.ts": "usage", "quota-reset-core-boundary.test.ts": "usage", "quota-reset-detector.test.ts": "usage", @@ -909,7 +909,7 @@ "responses-spend-capacity-guard.test.ts": "responses", "responses-spill-acl-recovery.test.ts": "responses", "responses-spill-inspection.test.ts": "responses", "responses-spill-orphan-sweep.test.ts": "responses", "responses-spill-shutdown-clock.test.ts": "responses", "responses-state-snapshot-select.test.ts": "responses", - "responses-state-snapshot-stubs.test.ts": "responses", "responses-state-write-amplification.test.ts": "responses", + "responses-state-snapshot-stubs.test.ts": "responses", "responses-state-store-false.test.ts": "responses", "responses-state-write-amplification.test.ts": "responses", "responses-state.test.ts": "responses", "responses-stateless-dangling-call-repair.test.ts": "responses", "responses-stream-tool-events.test.ts": "responses", "responses-terminal-repair.test.ts": "responses", "responses-tool-conformance.test.ts": "responses", "responses-tool-groups.test.ts": "responses", diff --git a/tests/providers/qoder-adapter.test.ts b/tests/providers/qoder-adapter.test.ts index 908b173f8e7..39c0b6812a7 100644 --- a/tests/providers/qoder-adapter.test.ts +++ b/tests/providers/qoder-adapter.test.ts @@ -1,8 +1,13 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { EventEmitter } from "node:events"; -import { Readable, Writable } from "node:stream"; +import { readFileSync } from "node:fs"; +import { PassThrough, Readable, Writable } from "node:stream"; import type { ChildProcess } from "node:child_process"; import { buildQoderArgs, buildQoderChildEnv, createQoderAdapter } from "../../src/adapters/qoder/adapter"; +import { buildConversationInput, mapStreamMessageToEvents, type StreamParseState } from "../../src/adapters/coding-agent/protocol"; +import type { CodingAgentDeps } from "../../src/adapters/coding-agent/turn"; +import { buildCodingAgentToolCatalog, CODING_AGENT_TOOL_LIMITS } from "../../src/adapters/coding-agent/tool-catalog"; +import { buildResponseJSON } from "../../src/bridge/response-json"; import { clearQoderBinaryCache, QODER_CN_PROFILE, QODER_GLOBAL_PROFILE, resolveQoderProfile } from "../../src/adapters/qoder/profiles"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; import { createTestTranslatorBudget } from "../helpers/translator-budget"; @@ -18,19 +23,187 @@ function parsed(overrides: Partial = {}): OcxParsedRequest { return { modelId: "Qwen3.8-Max", stream: true, options: {}, context: { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, ...overrides } as OcxParsedRequest; } -function fakeChild(frames: string[]): ChildProcess { +function fakeChild(frames: string[], options: { parked?: boolean } = {}): ChildProcess { const child = new EventEmitter() as ChildProcess & { killed: boolean; exitCode: number | null }; - child.stdout = Readable.from(frames.map(frame => enc.encode(frame))); + const stdout = new PassThrough(); + for (const frame of frames) stdout.write(enc.encode(frame)); + if (!options.parked) stdout.end(); + child.stdout = stdout; child.stderr = Readable.from([]); child.stdin = new Writable({ write(_chunk, _encoding, callback) { callback(); } }); child.killed = false; child.exitCode = null; - child.kill = () => { child.killed = true; return true; }; - setTimeout(() => { child.exitCode = 0; child.emit("close", 0); }, 2); + child.kill = () => { + child.killed = true; + child.exitCode = 0; + queueMicrotask(() => { + stdout.destroy(); + child.emit("close", 0); + }); + return true; + }; + if (!options.parked) { + setTimeout(() => { child.exitCode = 0; child.emit("close", 0); }, 2); + } return child; } describe("qoder adapter", () => { + test("normalizes the observed complete assistant tool_use without replacing its native ID", () => { + const state: StreamParseState = { + sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false, + completeAssistantToolUse: true, + }; + const events = mapStreamMessageToEvents({ + type: "assistant", message: { content: [{ + type: "tool_use", id: "call_42cfba72566547bda98a74bc", + name: "mcp__opencodex__probe_echo", input: { value: "FACT1" }, + }] }, + }, state); + expect(events).toEqual([ + { type: "tool_call_start", id: "call_42cfba72566547bda98a74bc", name: "mcp__opencodex__probe_echo" }, + { type: "tool_call_delta", arguments: '{"value":"FACT1"}' }, + { type: "tool_call_end" }, + ]); + expect(state.completedToolCalls).toBe(1); + }); + + test("advertises only the selected client catalog and forwards the stdout ID as Responses call_id", async () => { + const callId = "call_42cfba72566547bda98a74bc"; + let cliArgs: readonly string[] = []; + let catalog: Array<{ name: string; inputSchema: Record }> = []; + const adapter = createQoderAdapter(provider(), { + which: () => "/bin/qoder", + spawn: (_binary, args) => { + cliArgs = args; + const configPath = args[args.indexOf("--mcp-config") + 1]!; + const config = JSON.parse(readFileSync(configPath, "utf8")); + const catalogPath = config.mcpServers.opencodex.args.at(-1); + catalog = JSON.parse(readFileSync(catalogPath, "utf8")); + return fakeChild([ + JSON.stringify({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }) + "\n", + JSON.stringify({ type: "assistant", message: { stop_reason: "tool_use", content: [{ type: "tool_use", id: callId, + name: "mcp__opencodex__probe_echo", input: { value: "FACT1" } }] } }) + "\n", + ], { parked: true }); + }, + killGraceMs: 10, + }); + const request = parsed({ + options: { toolChoice: { allowedTools: ["probe_echo"], mode: "auto" } }, + context: { messages: [{ role: "user", content: "call probe_echo", timestamp: 0 }], tools: [ + { name: "probe_echo", description: "Return a marker", parameters: { type: "object", properties: { value: { type: "string" } } } }, + { name: "excluded", description: "Not allowed", parameters: { type: "object" } }, + ] }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(cliArgs[cliArgs.indexOf("--allowed-tools") + 1]).toBe("mcp__opencodex__probe_echo"); + // H7 regression pin: the bridge must keep the single-turn guard when tools are present. + expect(cliArgs[cliArgs.indexOf("--tools") + 1]).toBe(""); + expect(cliArgs[cliArgs.indexOf("--max-turns") + 1]).toBe("1"); + expect(catalog).toEqual([{ name: "probe_echo", description: "Return a marker", + inputSchema: { type: "object", properties: { value: { type: "string" } } } }]); + expect(events).toContainEqual({ type: "tool_call_start", id: callId, name: "probe_echo" }); + expect(events).toContainEqual({ type: "tool_call_delta", arguments: '{"value":"FACT1"}' }); + expect(events.at(-1)).toMatchObject({ type: "done", stopReason: "tool_use", endTurn: false }); + const response = buildResponseJSON(events, request.modelId, { declaredToolNames: new Set(["probe_echo"]) }); + expect(response.output).toContainEqual(expect.objectContaining({ type: "function_call", call_id: callId, + name: "probe_echo", arguments: '{"value":"FACT1"}' })); + }); + + test("keeps valid wire identities, order, schema and emitted names without aliases or hashing", () => { + const schema = { type: "object", properties: { value: { type: "string" } } }; + const tools = [ + { namespace: "linear", name: "create.issue", description: "Create issue", parameters: schema }, + { name: "probe_echo", description: "Return a marker", parameters: { type: "object" } }, + ]; + const catalog = buildCodingAgentToolCatalog(parsed({ context: { messages: [], tools } }), "opencodex"); + expect(catalog.tools).toEqual([ + { name: "linear__create.issue", description: "Create issue", inputSchema: schema }, + { name: "probe_echo", description: "Return a marker", inputSchema: { type: "object" } }, + ]); + expect(catalog.tools[0]?.inputSchema).toEqual(schema); + expect([...catalog.emittedNameMap]).toEqual([ + ["mcp__opencodex__linear__create.issue", "linear__create.issue"], + ["mcp__opencodex__probe_echo", "probe_echo"], + ]); + // Duplicate/colliding identities are resolved by the Responses parser, not this projection. + const duplicate = buildCodingAgentToolCatalog(parsed({ context: { messages: [], tools: [tools[1]!, tools[1]!] } }), "opencodex"); + expect(duplicate.tools).toHaveLength(2); + expect(duplicate.emittedNameMap.size).toBe(1); + }); + + test("rejects malformed or unbounded selected catalogs locally before Qoder spawn", async () => { + const base = { name: "probe_echo", description: "Return a marker", parameters: { type: "object" } }; + const limits = CODING_AGENT_TOOL_LIMITS; + const argvOverflowCatalog = Array.from({ length: 32 }, (_, i) => ({ ...base, name: `tool_${i}_${"x".repeat(240)}` })); + expect(enc.encode(argvOverflowCatalog.map(tool => tool.name).join(",")).byteLength).toBeLessThanOrEqual(limits.maxAllowedToolsArgBytes); + expect(enc.encode(argvOverflowCatalog.map(tool => `mcp__opencodex__${tool.name}`).join(",")).byteLength).toBeGreaterThan(limits.maxAllowedToolsArgBytes); + const catalogs = [ + [{ ...base, name: "" }], + [{ ...base, name: "probe,echo" }], + [{ ...base, name: "probe\n--allowed-tools" }], + [{ ...base, namespace: "bad\u0000namespace" }], + [{ ...base, namespace: "" }], + [{ ...base, name: "bad\ud800" }], + [{ ...base, name: "界".repeat(Math.floor(limits.maxNameBytes / 3) + 1) }], + [{ ...base, description: "x".repeat(limits.maxToolBytes) }], + Array.from({ length: 9 }, (_, i) => ({ ...base, name: `tool_${i}`, description: "x".repeat(240 * 1024) })), + Array.from({ length: limits.maxTools + 1 }, (_, i) => ({ ...base, name: `tool_${i}` })), + argvOverflowCatalog, + ]; + let spawns = 0; + const adapter = createQoderAdapter(provider(), { + which: () => "/bin/qoder", + spawn: () => { spawns++; return fakeChild([]); }, + }); + for (const tools of catalogs) { + const events: AdapterEvent[] = []; + await adapter.runTurn!(parsed({ context: { messages: [{ role: "user", content: "hello", timestamp: 0 }], tools } }), + { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(events).toEqual([{ + type: "error", message: "Invalid Qoder tool catalog.", status: 400, + errorType: "invalid_request_error", code: "tool_catalog_invalid", retryable: false, + }]); + expect(spawns).toBe(0); + } + }); + + test("does not rewrite unexpected catalog exceptions as client input errors", async () => { + const unexpected = new Error("unexpected catalog failure"); + const parameters = new Proxy>({}, { + ownKeys() { throw unexpected; }, + }); + let spawns = 0; + const adapter = createQoderAdapter(provider(), { + which: () => "/bin/qoder", + spawn: () => { spawns++; return fakeChild([]); }, + }); + const request = parsed({ context: { + messages: [{ role: "user", content: "hello", timestamp: 0 }], + tools: [{ name: "probe_echo", description: "Return a marker", parameters }], + } }); + + await expect(adapter.runTurn!( + request, + { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, + () => {}, + )).rejects.toBe(unexpected); + expect(spawns).toBe(0); + }); + + test("validates only selected definitions", () => { + const tools = [ + { name: "probe_echo", description: "ok", parameters: { type: "object" } }, + { name: "other", description: "x".repeat(CODING_AGENT_TOOL_LIMITS.maxToolBytes), parameters: { type: "object" } }, + ]; + const catalog = buildCodingAgentToolCatalog(parsed({ + options: { toolChoice: { allowedTools: ["probe_echo"], mode: "auto" } }, + context: { messages: [], tools }, + }), "opencodex"); + expect(catalog.tools).toEqual([{ name: "probe_echo", description: "ok", inputSchema: { type: "object" } }]); + }); + test("uses only the Global PAT and disables tools, MCP, settings hooks, and persistence", () => { const env = buildQoderChildEnv(QODER_GLOBAL_PROFILE, "qoder-pat"); expect(env.QODER_PERSONAL_ACCESS_TOKEN).toBe("qoder-pat"); @@ -185,4 +358,658 @@ describe("qoder adapter", () => { expect(events.some(event => event.type === "tool_call_start" || event.type === "done")).toBe(false); expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0, overflows: 1 }); }); + + test("replays prior assistant tool calls using the active vendor-facing MCP name", async () => { + let stdinData = ""; + const callId = "call_test_123"; + const adapter = createQoderAdapter(provider(), { + which: () => "/bin/qoder", + spawn: () => { + const child = fakeChild([ + JSON.stringify({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }) + "\n", + JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "done" }] } }) + "\n", + JSON.stringify({ type: "result", subtype: "success", is_error: false }) + "\n", + ]); + child.stdin = new Writable({ + write(chunk, _encoding, callback) { + stdinData += String(chunk); + callback(); + }, + }); + return child; + }, + killGraceMs: 10, + }); + const request = parsed({ + options: { toolChoice: "auto" }, + context: { + messages: [ + { role: "user", content: "run probe", timestamp: 0 }, + { role: "assistant", content: [{ type: "toolCall", id: callId, name: "probe_echo", arguments: { value: "HELLO" } }], timestamp: 1 }, + { role: "toolResult", toolCallId: callId, content: "result text", timestamp: 2 }, + { role: "user", content: "what next?", timestamp: 3 }, + ], + tools: [ + { name: "probe_echo", description: "Return a marker", parameters: { type: "object", properties: { value: { type: "string" } } } }, + ], + }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(stdinData).toContain(`[Tool call: mcp__opencodex__probe_echo (call_id: ${callId})`); + expect(stdinData).not.toContain(`[Tool call: probe_echo (call_id: ${callId})`); + const parsedPayload = JSON.parse(stdinData.trim()); + const text = parsedPayload.message.content[0].text; + expect(text).toContain(`[Tool call: mcp__opencodex__probe_echo (call_id: ${callId}) with args: {"value":"HELLO"}]`); + expect(text).toContain(`TOOL RESULT (call_id: ${callId}):\nresult text`); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("shared history serialization without vendorNameByWire retains canonical wire names", () => { + const request = parsed({ + context: { + messages: [ + { role: "user", content: "run probe", timestamp: 0 }, + { role: "assistant", content: [{ type: "toolCall", id: "call_test_123", name: "probe_echo", arguments: { value: "HELLO" } }], timestamp: 1 }, + { role: "toolResult", toolCallId: "call_test_123", content: "result text", timestamp: 2 }, + { role: "user", content: "what next?", timestamp: 3 }, + ], + }, + }); + const [line] = buildConversationInput(request); + expect(line).toContain("[Tool call: probe_echo (call_id: call_test_123)"); + expect(line).not.toContain("mcp__"); + const parsedPayload = JSON.parse(line!); + const text = parsedPayload.message.content[0].text; + expect(text).toContain('[Tool call: probe_echo (call_id: call_test_123) with args: {"value":"HELLO"}]'); + }); + + test("replays multiple mapped tools to their respective vendor-facing names", async () => { + let stdinData = ""; + const adapter = createQoderAdapter(provider(), { + which: () => "/bin/qoder", + spawn: () => { + const child = fakeChild([ + JSON.stringify({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }) + "\n", + JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "done" }] } }) + "\n", + JSON.stringify({ type: "result", subtype: "success", is_error: false }) + "\n", + ]); + child.stdin = new Writable({ + write(chunk, _encoding, callback) { + stdinData += String(chunk); + callback(); + }, + }); + return child; + }, + killGraceMs: 10, + }); + const request = parsed({ + options: { toolChoice: "auto" }, + context: { + messages: [ + { role: "user", content: "run commands", timestamp: 0 }, + { role: "assistant", content: [ + { type: "toolCall", id: "call_exec_1", name: "exec", arguments: { cmd: "ls" } }, + { type: "toolCall", id: "call_read_2", name: "read_file", arguments: { path: "a.txt" } }, + ], timestamp: 1 }, + { role: "toolResult", toolCallId: "call_exec_1", content: "file.txt", timestamp: 2 }, + { role: "toolResult", toolCallId: "call_read_2", content: "hello", timestamp: 3 }, + { role: "user", content: "next step", timestamp: 4 }, + ], + tools: [ + { name: "exec", description: "Run a command", parameters: { type: "object", properties: { cmd: { type: "string" } } } }, + { name: "read_file", description: "Read a file", parameters: { type: "object", properties: { path: { type: "string" } } } }, + ], + }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(stdinData).toContain("[Tool call: mcp__opencodex__exec (call_id: call_exec_1)"); + expect(stdinData).toContain("[Tool call: mcp__opencodex__read_file (call_id: call_read_2)"); + expect(stdinData).not.toContain("[Tool call: exec (call_id: call_exec_1)"); + expect(stdinData).not.toContain("[Tool call: read_file (call_id: call_read_2)"); + const parsedPayload = JSON.parse(stdinData.trim()); + const text = parsedPayload.message.content[0].text; + expect(text).toContain('[Tool call: mcp__opencodex__exec (call_id: call_exec_1) with args: {"cmd":"ls"}]'); + expect(text).toContain('[Tool call: mcp__opencodex__read_file (call_id: call_read_2) with args: {"path":"a.txt"}]'); + expect(text).toContain("TOOL RESULT (call_id: call_exec_1):\nfile.txt"); + expect(text).toContain("TOOL RESULT (call_id: call_read_2):\nhello"); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("tool_choice required fails closed with neutral diagnostic when Qoder outputs no tool call", async () => { + const adapter = createQoderAdapter(provider(), { + which: () => "/bin/qoder", + spawn: () => fakeChild([ + JSON.stringify({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }) + "\n", + JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "no tools needed" }] } }) + "\n", + JSON.stringify({ type: "result", subtype: "success", is_error: false }) + "\n", + ]), + killGraceMs: 10, + }); + const request = parsed({ + options: { toolChoice: "required" }, + context: { + messages: [{ role: "user", content: "must call tool", timestamp: 0 }], + tools: [{ name: "probe_echo", description: "Echo marker", parameters: { type: "object" } }], + }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(events.at(-1)).toEqual({ + type: "error", + message: "Coding-agent CLI finished without calling the required tool.", + status: 502, + errorType: "upstream_error", + code: "tool_call_required", + retryable: false, + }); + }); + + test("tool_choice allowedTools with mode required propagates requireToolCall obligation", async () => { + const adapter = createQoderAdapter(provider(), { + which: () => "/bin/qoder", + spawn: () => fakeChild([ + JSON.stringify({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }) + "\n", + JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "text only" }] } }) + "\n", + JSON.stringify({ type: "result", subtype: "success", is_error: false }) + "\n", + ]), + killGraceMs: 10, + }); + const request = parsed({ + options: { toolChoice: { allowedTools: ["probe_echo"], mode: "required" } }, + context: { + messages: [{ role: "user", content: "must call probe_echo", timestamp: 0 }], + tools: [{ name: "probe_echo", description: "Echo marker", parameters: { type: "object" } }], + }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(events.at(-1)).toMatchObject({ + type: "error", + code: "tool_call_required", + message: "Coding-agent CLI finished without calling the required tool.", + }); + }); + + test("named tool choice with no selected tool cannot complete as text", async () => { + const adapter = createQoderAdapter(provider(), { + which: () => "/bin/qoder", + spawn: () => { throw new Error("empty required catalog must fail before spawn"); }, + }); + const request = parsed({ + options: { toolChoice: { name: "missing_tool" } }, + context: { + messages: [{ role: "user", content: "call missing_tool", timestamp: 0 }], + tools: [{ name: "probe_echo", description: "Echo", parameters: { type: "object" } }], + }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(events.at(-1)).toMatchObject({ type: "error", code: "tool_bridge_empty" }); + }); + + test("replays namespaced tool calls in history with canonical wire identity", async () => { + let stdinData = ""; + const adapter = createQoderAdapter(provider(), { + which: () => "/bin/qoder", + spawn: () => { + const child = fakeChild([ + JSON.stringify({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }) + "\n", + JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "done" }] } }) + "\n", + JSON.stringify({ type: "result", subtype: "success", is_error: false }) + "\n", + ]); + child.stdin = new Writable({ + write(chunk, _encoding, callback) { + stdinData += String(chunk); + callback(); + }, + }); + return child; + }, + killGraceMs: 10, + }); + const request = parsed({ + options: { toolChoice: "auto" }, + context: { + messages: [ + { role: "user", content: "start", timestamp: 0 }, + { role: "assistant", content: [ + { type: "toolCall", id: "call_ns_1", namespace: "custom_mcp", name: "fetch", arguments: { url: "http://example.com" } }, + ], timestamp: 1 }, + { role: "toolResult", toolCallId: "call_ns_1", content: "ok", timestamp: 2 }, + { role: "user", content: "next", timestamp: 3 }, + ], + tools: [ + { namespace: "custom_mcp", name: "fetch", description: "Fetch url", parameters: { type: "object" } }, + ], + }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(stdinData).toContain("[Tool call: mcp__opencodex__custom_mcp__fetch (call_id: call_ns_1)"); + }); +}); + +function createTestClock() { + let currentTime = 0; + let nextId = 1; + interface Scheduled { + id: number; + callback: () => void; + dueTime: number; + delay: number; + } + const scheduled: Scheduled[] = []; + + const fakeSetTimeout = ((callback: () => void, ms = 0): any => { + const id = nextId++; + scheduled.push({ id, callback, dueTime: currentTime + ms, delay: ms }); + scheduled.sort((a, b) => a.dueTime - b.dueTime); + return id; + }) as typeof setTimeout; + + const fakeClearTimeout = ((id: any): void => { + const idx = scheduled.findIndex(item => item.id === id); + if (idx !== -1) scheduled.splice(idx, 1); + }) as typeof clearTimeout; + + const advanceTimersByTime = async (ms: number) => { + currentTime += ms; + while (scheduled.length > 0 && scheduled[0]!.dueTime <= currentTime) { + const item = scheduled.shift()!; + item.callback(); + await new Promise(r => setImmediate(r)); + } + }; + + return { + setTimeout: fakeSetTimeout, + clearTimeout: fakeClearTimeout, + advanceTimersByTime, + get pendingCount() { return scheduled.length; }, + get scheduled() { return scheduled; }, + get currentTime() { return currentTime; }, + }; +} + +function createParkedChild() { + const child = fakeChild([], { parked: true }) as ChildProcess & { killed: boolean; exitCode: number | null }; + const stdout = child.stdout as PassThrough; + return { + child, + pushFrame: (obj: Record) => { + stdout.write(enc.encode(JSON.stringify(obj) + "\n")); + }, + pushRaw: (str: string) => { + stdout.write(enc.encode(str)); + }, + end: () => { + stdout.end(); + child.exitCode = 0; + child.emit("close", 0); + }, + }; +} + +function toolRequest(tools = ["probe_echo"]) { + return parsed({ + options: { toolChoice: { allowedTools: tools, mode: "auto" } }, + context: { + messages: [{ role: "user", content: "call tools", timestamp: 0 }], + tools: tools.map(name => ({ + name, + description: `Tool ${name}`, + parameters: { type: "object", properties: { value: { type: "string" } } }, + })), + }, + }); +} + +function setupParkedQoder(tools = ["probe_echo"], overrides: Partial = {}) { + const spawned = Promise.withResolvers(); + const clock = createTestClock(); + const parked = createParkedChild(); + const adapter = createQoderAdapter(provider(), { + which: () => "/bin/qoder", + spawn: () => { + spawned.resolve(); + return parked.child; + }, + killGraceMs: 10, + setTimeout: clock.setTimeout, + clearTimeout: clock.clearTimeout, + ...overrides, + }); + return { spawned, clock, parked, adapter }; +} + +describe("qoder authoritative tool-turn completion", () => { + test("single tool stop_reason completes immediately and reaps with identity/input intact", async () => { + const { spawned, clock, parked, adapter } = setupParkedQoder(); + const events: AdapterEvent[] = []; + const turn = adapter.runTurn!(toolRequest(), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, e => events.push(e)); + await spawned.promise; + parked.pushFrame({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }); + parked.pushFrame({ type: "assistant", message: { stop_reason: "tool_use", usage: { input_tokens: 12, output_tokens: 3 }, content: [ + { type: "tool_use", id: "call_1", name: "mcp__opencodex__probe_echo", input: { value: "A" } }, + ] } }); + await turn; + expect(clock.currentTime).toBe(0); + expect(events.filter(e => e.type === "tool_call_start")).toEqual([{ type: "tool_call_start", id: "call_1", name: "probe_echo" }]); + expect(events.filter(e => e.type === "tool_call_delta")).toEqual([{ type: "tool_call_delta", arguments: '{"value":"A"}' }]); + expect(events.filter(e => e.type === "tool_call_end")).toHaveLength(1); + expect(events.filter(e => e.type === "done")).toHaveLength(1); + expect(events.at(-1)).toMatchObject({ type: "done", stopReason: "tool_use", endTurn: false, usage: { inputTokens: 12, outputTokens: 3 } }); + expect(parked.child.killed).toBe(true); + expect(clock.pendingCount).toBe(0); + }); + + for (const delay of [0, 1000]) { + test(`two sibling tools survive ${delay}ms until final stop_reason`, async () => { + const { spawned, clock, parked, adapter } = setupParkedQoder(); + const events: AdapterEvent[] = []; + const turn = adapter.runTurn!(toolRequest(), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, e => events.push(e)); + await spawned.promise; + parked.pushFrame({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }); + parked.pushFrame({ type: "assistant", message: { stop_reason: null, content: [ + { type: "tool_use", id: "call_A", name: "mcp__opencodex__probe_echo", input: { value: "A" } }, + ] } }); + await new Promise(r => setImmediate(r)); + await new Promise(r => setImmediate(r)); + await clock.advanceTimersByTime(delay); + expect(events.filter(e => e.type === "tool_call_end")).toHaveLength(1); + expect(events.some(e => e.type === "done")).toBe(false); + expect(parked.child.killed).toBe(false); + parked.pushFrame({ type: "assistant", message: { stop_reason: "tool_use", content: [ + { type: "tool_use", id: "call_B", name: "mcp__opencodex__probe_echo", input: { value: "B" } }, + ] } }); + await turn; + expect(clock.currentTime).toBe(delay); + expect(events.filter(e => e.type === "tool_call_start")).toEqual([ + { type: "tool_call_start", id: "call_A", name: "probe_echo" }, + { type: "tool_call_start", id: "call_B", name: "probe_echo" }, + ]); + expect(events.filter(e => e.type === "tool_call_delta")).toEqual([ + { type: "tool_call_delta", arguments: '{"value":"A"}' }, + { type: "tool_call_delta", arguments: '{"value":"B"}' }, + ]); + expect(events.filter(e => e.type === "tool_call_end")).toHaveLength(2); + expect(events.filter(e => e.type === "done")).toHaveLength(1); + expect(events.at(-1)).toMatchObject({ type: "done", stopReason: "tool_use", endTurn: false }); + expect(parked.child.killed).toBe(true); + }); + } + + for (const stopReason of [undefined, null, "end_turn", "max_tokens"]) { + test(`silence alone must not complete a tool turn (${stopReason}); EOF fails closed`, async () => { + const { spawned, clock, parked, adapter } = setupParkedQoder(); + const events: AdapterEvent[] = []; + const turn = adapter.runTurn!(toolRequest(), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, e => events.push(e)); + await spawned.promise; + parked.pushFrame({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }); + parked.pushFrame({ type: "assistant", message: { stop_reason: stopReason, content: [ + { type: "tool_use", id: "call_no_stop", name: "mcp__opencodex__probe_echo", input: {} }, + ] } }); + await new Promise(r => setImmediate(r)); + await new Promise(r => setImmediate(r)); + await clock.advanceTimersByTime(1000); + expect(events.some(e => e.type === "done")).toBe(false); + expect(parked.child.killed).toBe(false); + parked.end(); + await turn; + expect(events.at(-1)).toMatchObject({ type: "error", code: "protocol_error", status: 502 }); + expect(events.some(e => e.type === "done")).toBe(false); + }); + } + + test("abort while awaiting authoritative stop fails closed", async () => { + const abortController = new AbortController(); + const { spawned, clock, parked, adapter } = setupParkedQoder(); + const events: AdapterEvent[] = []; + const turnPromise = adapter.runTurn!(toolRequest(), { + headers: new Headers(), + translatorBudget: createTestTranslatorBudget(), + abortSignal: abortController.signal, + }, e => events.push(e)); + + await spawned.promise; + parked.pushFrame({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }); + parked.pushFrame({ + type: "assistant", + message: { content: [{ type: "tool_use", id: "call_abort_1", name: "mcp__opencodex__probe_echo", input: { value: "A" } }] }, + }); + await new Promise(r => setImmediate(r)); + await new Promise(r => setImmediate(r)); + + abortController.abort(); + await turnPromise; + + expect(events.some(e => e.type === "error" && e.message.includes("aborted"))).toBe(true); + expect(events.some(e => e.type === "done")).toBe(false); + + await clock.advanceTimersByTime(1000); + expect(events.filter(e => e.type === "done")).toHaveLength(0); + expect(events.filter(e => e.type === "error")).toHaveLength(1); + }); + + test("global timeout without authoritative stop cannot emit success", async () => { + const { spawned, clock, parked, adapter } = setupParkedQoder(["probe_echo"], { timeoutMs: 100 }); + const events: AdapterEvent[] = []; + const turnPromise = adapter.runTurn!(toolRequest(), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, e => events.push(e)); + + await spawned.promise; + parked.pushFrame({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }); + parked.pushFrame({ + type: "assistant", + message: { content: [{ type: "tool_use", id: "call_to_1", name: "mcp__opencodex__probe_echo", input: { value: "T" } }] }, + }); + await new Promise(r => setImmediate(r)); + await new Promise(r => setImmediate(r)); + + await clock.advanceTimersByTime(100); + await turnPromise; + + expect(events.some(e => e.type === "error" && e.code === "timeout")).toBe(true); + expect(events.some(e => e.type === "done")).toBe(false); + + await clock.advanceTimersByTime(200); + expect(events.filter(e => e.type === "done")).toHaveLength(0); + }); + + test("protocol error before authoritative stop prevents success", async () => { + const { spawned, clock, parked, adapter } = setupParkedQoder(); + const events: AdapterEvent[] = []; + const turnPromise = adapter.runTurn!(toolRequest(), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, e => events.push(e)); + + await spawned.promise; + parked.pushFrame({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }); + parked.pushFrame({ + type: "assistant", + message: { content: [{ type: "tool_use", id: "call_proto_1", name: "mcp__opencodex__probe_echo", input: { value: "P" } }] }, + }); + await new Promise(r => setImmediate(r)); + await new Promise(r => setImmediate(r)); + + parked.pushRaw("not valid json\n"); + await new Promise(r => setImmediate(r)); + await new Promise(r => setImmediate(r)); + await turnPromise; + + expect(events.some(e => e.type === "error")).toBe(true); + expect(events.some(e => e.type === "done")).toBe(false); + }); + + test("undeclared tool call before authoritative stop fails closed", async () => { + const { spawned, clock, parked, adapter } = setupParkedQoder(); + const events: AdapterEvent[] = []; + const turnPromise = adapter.runTurn!(toolRequest(), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, e => events.push(e)); + + await spawned.promise; + parked.pushFrame({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }); + parked.pushFrame({ + type: "assistant", + message: { content: [{ type: "tool_use", id: "call_decl_1", name: "mcp__opencodex__probe_echo", input: { value: "D" } }] }, + }); + await new Promise(r => setImmediate(r)); + await new Promise(r => setImmediate(r)); + + parked.pushFrame({ + type: "assistant", + message: { content: [{ type: "tool_use", id: "call_decl_2", name: "mcp__opencodex__undeclared", input: {} }] }, + }); + await new Promise(r => setImmediate(r)); + await new Promise(r => setImmediate(r)); + await turnPromise; + + expect(events.at(-1)).toMatchObject({ type: "error", code: "undeclared_tool_call", status: 502 }); + expect(events.some(e => e.type === "done")).toBe(false); + }); + + test("authoritative assistant stop with incomplete tool block fails closed", async () => { + const { spawned, clock, parked, adapter } = setupParkedQoder(); + const events: AdapterEvent[] = []; + const turnPromise = adapter.runTurn!(toolRequest(), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, e => events.push(e)); + + await spawned.promise; + parked.pushFrame({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }); + parked.pushFrame({ + type: "assistant", + message: { content: [{ type: "tool_use", id: "call_inc_1", name: "mcp__opencodex__probe_echo", input: { value: "OK" } }] }, + }); + await new Promise(r => setImmediate(r)); + await new Promise(r => setImmediate(r)); + + parked.pushFrame({ + type: "stream_event", + event: { type: "content_block_start", index: 1, content_block: { type: "tool_use", id: "call_inc_2", name: "exec" } }, + }); + await new Promise(r => setImmediate(r)); + await new Promise(r => setImmediate(r)); + + await clock.advanceTimersByTime(300); + + expect(events.some(e => e.type === "done")).toBe(false); + expect(parked.child.killed).toBe(false); + + parked.pushFrame({ type: "assistant", message: { stop_reason: "tool_use", content: [] } }); + await turnPromise; + + expect(events.at(-1)).toMatchObject({ type: "error", code: "protocol_error", status: 502, message: "Coding-agent CLI ended with an incomplete tool call." }); + expect(events.some(e => e.type === "done")).toBe(false); + }); + + test("12. sixteen separate assistant tool calls all survive to downstream events", async () => { + const toolNames = Array.from({ length: 16 }, (_, i) => `tool_${i}`); + const { spawned, clock, parked, adapter } = setupParkedQoder(toolNames); + const events: AdapterEvent[] = []; + const turnPromise = adapter.runTurn!(toolRequest(toolNames), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, e => events.push(e)); + + await spawned.promise; + parked.pushFrame({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }); + for (let i = 0; i < 16; i++) { + parked.pushFrame({ + type: "assistant", + message: { stop_reason: i === 15 ? "tool_use" : null, content: [{ type: "tool_use", id: `call_16_${i}`, name: `mcp__opencodex__tool_${i}`, input: { value: String(i) } }] }, + }); + } + await new Promise(r => setImmediate(r)); + await new Promise(r => setImmediate(r)); + + await clock.advanceTimersByTime(300); + await turnPromise; + + const starts = events.filter(e => e.type === "tool_call_start"); + expect(starts).toHaveLength(16); + for (let i = 0; i < 16; i++) { + expect(starts[i]).toMatchObject({ id: `call_16_${i}`, name: `tool_${i}` }); + } + expect(events.at(-1)).toMatchObject({ type: "done", stopReason: "tool_use", endTurn: false }); + }); + + test("scaffold refusal suppresses authoritative success terminal", async () => { + const { spawned, clock, parked, adapter } = setupParkedQoder(); + const events: AdapterEvent[] = []; + const turnPromise = adapter.runTurn!(toolRequest(), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, e => events.push(e)); + + await spawned.promise; + parked.pushFrame({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }); + parked.pushFrame({ + type: "assistant", + message: { content: [{ type: "text", text: "tail" }] }, + }); + parked.pushFrame({ + type: "assistant", + message: { stop_reason: "tool_use", content: [{ type: "tool_use", id: "call_scaffold_1", name: "mcp__opencodex__probe_echo", input: { value: "S" } }] }, + }); + await new Promise(r => setImmediate(r)); + await new Promise(r => setImmediate(r)); + + await clock.advanceTimersByTime(300); + await turnPromise; + + expect(events.some(e => e.type === "done")).toBe(false); + expect(events.at(-1)).toMatchObject({ type: "error", status: 502, code: "vendor_scaffold_detected" }); + }); + + test("EOF after a non-selected message_stop still fails without Qoder stop_reason", async () => { + const { spawned, parked, adapter } = setupParkedQoder(); + const events: AdapterEvent[] = []; + const turnPromise = adapter.runTurn!( + toolRequest(), + { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, + e => events.push(e), + ); + + await spawned.promise; + parked.pushFrame({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }); + parked.pushFrame({ + type: "assistant", + message: { + stop_reason: null, + content: [{ type: "tool_use", id: "call_eof_1", name: "mcp__opencodex__probe_echo", input: { value: "EOF" } }], + }, + }); + parked.pushFrame({ type: "result", subtype: "success", is_error: false }); + parked.pushFrame({ type: "stream_event", event: { type: "message_stop" } }); + parked.end(); + await turnPromise; + + expect(events.some(e => e.type === "done")).toBe(false); + expect(events.at(-1)).toMatchObject({ + type: "error", + code: "protocol_error", + status: 502, + message: expect.stringContaining("authoritative tool-turn stop"), + }); + }); + + test("required tool without tool call fails with tool_call_required", async () => { + const { spawned, clock, parked, adapter } = setupParkedQoder(); + const events: AdapterEvent[] = []; + const req = parsed({ + options: { toolChoice: "required" }, + context: { + messages: [{ role: "user", content: "must call", timestamp: 0 }], + tools: [{ name: "probe_echo", description: "Echo", parameters: { type: "object" } }], + }, + }); + const turnPromise = adapter.runTurn!(req, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, e => events.push(e)); + + await spawned.promise; + parked.pushFrame({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }); + parked.pushFrame({ + type: "assistant", + message: { content: [{ type: "text", text: "text only" }] }, + }); + parked.pushFrame({ type: "result", subtype: "success", is_error: false }); + await new Promise(r => setImmediate(r)); + await new Promise(r => setImmediate(r)); + await turnPromise; + + expect(clock.scheduled.filter(s => s.delay === 300)).toHaveLength(0); + expect(events.at(-1)).toMatchObject({ type: "error", code: "tool_call_required", status: 502 }); + }); + }); diff --git a/tests/providers/qoder-mcp-server.test.ts b/tests/providers/qoder-mcp-server.test.ts new file mode 100644 index 00000000000..4b04520cbad --- /dev/null +++ b/tests/providers/qoder-mcp-server.test.ts @@ -0,0 +1,28 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const serverPath = join(import.meta.dir, "../../src/adapters/coding-agent/mcp-server.ts"); + +describe("Qoder isolated MCP catalog", () => { + test("lists the selected client tool name and unchanged schema", async () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-qoder-tools-")); + const schema = { type: "object", properties: { value: { type: "string" } }, required: ["value"] }; + const catalogPath = join(dir, "catalog.json"); + writeFileSync(catalogPath, JSON.stringify([{ name: "probe_echo", description: "Return a marker", inputSchema: schema }])); + const transport = new StdioClientTransport({ command: process.execPath, args: [serverPath, catalogPath] }); + const client = new Client({ name: "qoder-catalog-test", version: "1.0.0" }); + try { + await client.connect(transport); + expect((await client.listTools()).tools).toEqual([ + { name: "probe_echo", description: "Return a marker", inputSchema: schema }, + ]); + } finally { + await client.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/responses/responses-state-store-false.test.ts b/tests/responses/responses-state-store-false.test.ts new file mode 100644 index 00000000000..bde7d126d3a --- /dev/null +++ b/tests/responses/responses-state-store-false.test.ts @@ -0,0 +1,340 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setPlatformForTests } from "../../src/lib/windows-secret-acl"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { buildConversationInput } from "../../src/adapters/coding-agent/protocol"; +import { buildResponseJSON } from "../../src/bridge"; +import { parseRequest } from "../../src/responses/parser"; +import type { ProviderAdapter } from "../../src/adapters/base"; +import type { AdapterEvent, OcxConfig, OcxProviderConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { adapterNeedsForcedContinuation, adapterNeedsToolCallContinuation } from "../../src/server/responses/core-replay"; + +import { + clearResponseStateForTests, + clearResponseStateMemoryForTests, + evictOldestResponseContinuationForBudget, + expandPreviousResponseInput, + flushResponseState, + rememberResponseState, + responseStateMetrics, + setResponseStateByteCapForTests, +} from "../../src/responses/state"; + +const qoderStateOptions = { + clientThreadId: "qoder-task", + retainForToolContinuation: adapterNeedsToolCallContinuation("qoder"), +}; + +describe("Responses unforced store:false ephemeral function call state", () => { + const priorHome = process.env["OPENCODEX_HOME"]; + let home: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-store-false-test-")); + process.env["OPENCODEX_HOME"] = home; + clearResponseStateMemoryForTests(); + setPlatformForTests("linux"); + }); + + afterEach(() => { + setPlatformForTests(null); + setResponseStateByteCapForTests(null); + clearResponseStateForTests(); + removeTreeWithRetry(home); + if (priorHome === undefined) delete process.env["OPENCODEX_HOME"]; + else process.env["OPENCODEX_HOME"] = priorHome; + }); + + for (const stream of [false, true]) { + for (const [name, toolTurn] of [["qoder", true], ["qoder", false], ["codebuddy", true]] as const) { + test(`execution records only opted-in tool turns (${name}, tools=${toolTurn}, stream=${stream})`, async () => { + const resolver = { ...await import("../../src/server/adapter-resolve") }; + const release = acquireOwnedSpendHome(); + const events: AdapterEvent[] = toolTurn ? [ + { type: "tool_call_start", id: "qoder_native_execution", name: "probe_echo" }, + { type: "tool_call_delta", arguments: "{}" }, { type: "tool_call_end" }, + { type: "done", stopReason: "tool_use", endTurn: false }, + ] : [{ type: "text_delta", text: "text-only answer" }, { type: "done" }]; + mock.module("../../src/server/adapter-resolve", () => ({ ...resolver, + resolveAdapter(provider: OcxProviderConfig): ProviderAdapter { + if (provider.adapter !== name) throw new Error("unexpected fixture adapter"); + return { + name, + buildRequest() { throw new Error("fixture must use runTurn"); }, + async *parseStream() { throw new Error("fixture must use runTurn"); }, + async runTurn(_parsed, _incoming, emit) { for (const event of events) emit(event); }, + }; + }, + })); + try { + const { handleResponses } = await import("../../src/server/responses"); + // The configured provider id is NOT the adapter name: policy uses the serving adapter. + const config = { port: 0, defaultProvider: "fixture", providers: { + fixture: { adapter: name, baseUrl: "https://fixture.invalid", authMode: "key", apiKey: "fixture-key", + models: ["fixture-model"], fetch: async () => { throw new Error("no provider I/O allowed"); } }, + } } as OcxConfig; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json", "x-codex-parent-thread-id": "qoder-task" }, + body: JSON.stringify({ model: "fixture/fixture-model", input: "use probe_echo", store: false, stream, + tools: [{ type: "function", name: "probe_echo", parameters: { type: "object", properties: {} } }] }), + }), config, { provider: "", model: "" }); + expect(response.status).toBe(200); + const text = await response.text(); + const result = stream + ? text.split("\n").filter(line => line.startsWith("data: {")).map(line => JSON.parse(line.slice(6))) + .find(event => event.type === "response.completed")?.response + : JSON.parse(text); + expect(result?.status).toBe("completed"); + expect(responseStateMetrics().count).toBe(name === "qoder" && toolTurn ? 1 : 0); + const next = { model: "fixture-model", previous_response_id: result.id, input: [ + { type: "function_call_output", call_id: "qoder_native_execution", output: "OK" }, + ], store: false }; + const expanded = expandPreviousResponseInput(next, "qoder-task"); + if (name === "qoder" && toolTurn) { + expect(parseRequest(expanded).context.messages.at(-1)).toMatchObject({ + role: "toolResult", toolCallId: "qoder_native_execution", toolName: "probe_echo", content: "OK", + }); + } else expect(expanded).toEqual(next); + } finally { + mock.module("../../src/server/adapter-resolve", () => resolver); + release(); + } + }); + } + } + + test("only Qoder opts into tool-call retention; forced eligibility is unchanged", () => { + expect(adapterNeedsToolCallContinuation("qoder")).toBe(true); + expect(adapterNeedsForcedContinuation("qoder")).toBe(false); + for (const name of ["codebuddy", "coding-agent", "gemini", "volcengine", "openai", ""]) { + expect(adapterNeedsToolCallContinuation(name)).toBe(false); + expect(adapterNeedsForcedContinuation(name)).toBe(false); + } + for (const name of ["kiro", "cursor"]) { + expect(adapterNeedsForcedContinuation(name)).toBe(true); + expect(adapterNeedsToolCallContinuation(name)).toBe(false); + } + }); + + for (const retainForToolContinuation of [undefined, false]) { + test(`unforced function calls without opt-in are not retained (${retainForToolContinuation})`, () => { + const response = { id: "resp_no_opt_in", status: "completed", output: [ + { type: "function_call", call_id: "call_native", name: "probe_echo", arguments: "{}" }, + ] }; + rememberResponseState({ input: "call probe_echo", store: false }, response, undefined, { + clientThreadId: "codebuddy-task", retainForToolContinuation, + }); + expect(responseStateMetrics().count).toBe(0); + const next = { previous_response_id: response.id, input: [ + { type: "function_call_output", call_id: "call_native", output: "OK" }, + ], store: false }; + expect(expandPreviousResponseInput(next, "codebuddy-task")).toEqual(next); + }); + } + + for (const name of ["kiro", "cursor", "passthrough"]) { + test(`${name} force still retains store:false text and allows text continuation`, () => { + const response = buildResponseJSON([ + { type: "text_delta", text: "forced answer" }, { type: "done" }, + ], "fixture-model"); + rememberResponseState({ input: "question", store: false }, response, undefined, { + force: name === "passthrough" || adapterNeedsForcedContinuation(name), + retainForToolContinuation: adapterNeedsToolCallContinuation(name), + }); + expect(responseStateMetrics().count).toBe(1); + expect(expandPreviousResponseInput({ previous_response_id: response.id, input: "continue" })) + .toMatchObject({ input: [{ role: "user", content: "question" }, ...(response.output as unknown[]), { role: "user", content: "continue" }] }); + }); + } + + test("Qoder multi-tool continuation preserves both native call IDs and results", () => { + const calls = [ + { type: "function_call", call_id: "qoder_native_A", name: "probe_a", arguments: '{"value":"A"}' }, + { type: "function_call", call_id: "qoder_native_B", name: "probe_b", arguments: '{"value":"B"}' }, + ]; + const response = { id: "resp_multi_tool", status: "completed", output: calls }; + const results = calls.map(call => ({ type: "function_call_output", call_id: call.call_id, output: `${call.name}_OK` })); + rememberResponseState({ input: "use both tools", store: false }, response, undefined, qoderStateOptions); + const expanded = expandPreviousResponseInput({ model: "fixture-model", previous_response_id: response.id, input: results, store: false }, "qoder-task"); + expect(expanded).toMatchObject({ input: [{ role: "user", content: "use both tools" }, ...calls, ...results] }); + const parsed = parseRequest(expanded); + expect(parsed.context.messages.filter(message => message.role === "toolResult")).toMatchObject([ + { toolCallId: "qoder_native_A", toolName: "probe_a", content: "probe_a_OK" }, + { toolCallId: "qoder_native_B", toolName: "probe_b", content: "probe_b_OK" }, + ]); + const projected = JSON.parse(buildConversationInput(parsed)[0]!); + for (const call of calls) { + expect(projected.message.content[0].text).toContain(`call_id: ${call.call_id}`); + expect(projected.message.content[0].text).toContain(`${call.name}_OK`); + } + }); + + test("retains a client-owned function call for store:false tool-result continuation", () => { + const callId = "call_42cfba72566547bda98a74bc"; + const request = { model: "Qwen3.8-Flash", input: "use probe_echo", store: false }; + const response = buildResponseJSON([ + { type: "tool_call_start", id: callId, name: "probe_echo" }, + { type: "tool_call_delta", arguments: '{"value":"FACT1"}' }, + { type: "tool_call_end" }, + { type: "done", stopReason: "tool_use", endTurn: false }, + ], "Qwen3.8-Flash"); + rememberResponseState(request, response, undefined, qoderStateOptions); + const expanded = expandPreviousResponseInput({ + model: "Qwen3.8-Flash", + previous_response_id: response.id, + input: [{ type: "function_call_output", call_id: callId, output: "PROBE_OK" }], + store: false, + }, "qoder-task"); + const parsed = parseRequest(expanded); + expect(parsed.context.messages.at(-1)).toMatchObject({ + role: "toolResult", + toolCallId: callId, + toolName: "probe_echo", + content: "PROBE_OK", + }); + const projected = JSON.parse(buildConversationInput(parsed)[0]!); + expect(projected.message.content[0].text).toContain(`TOOL RESULT (call_id: ${callId}):\nPROBE_OK`); + }); + + test("refuses flagged replay when new incoming input does not match pending function call", () => { + const callId = "call_test_pending_1"; + const request = { model: "Qwen3.8-Flash", input: "run task", store: false }; + const response = buildResponseJSON([ + { type: "tool_call_start", id: callId, name: "probe_echo" }, + { type: "tool_call_delta", arguments: '{"value":"FACT1"}' }, + { type: "tool_call_end" }, + { type: "done", stopReason: "tool_use", endTurn: false }, + ], "Qwen3.8-Flash"); + rememberResponseState(request, response, undefined, qoderStateOptions); + + // Client sends plain text instead of function_call_output + const textRequest = { + model: "Qwen3.8-Flash", + previous_response_id: response.id, + input: "continue without output", + store: false, + }; + expect(expandPreviousResponseInput(textRequest, "qoder-task")).toEqual(textRequest); + + // Client sends output for an unrelated/mismatched call_id + const mismatchedRequest = { + model: "Qwen3.8-Flash", + previous_response_id: response.id, + input: [{ type: "function_call_output", call_id: "call_different_999", output: "OTHER" }], + store: false, + }; + expect(expandPreviousResponseInput(mismatchedRequest, "qoder-task")).toEqual(mismatchedRequest); + }); + + test("unforced store:false without function_call is never stored", () => { + const request = { model: "Qwen3.8-Flash", input: "hello world", store: false }; + const response = buildResponseJSON([ + { type: "text_delta", text: "hi there" }, + { type: "done" }, + ], "Qwen3.8-Flash"); + rememberResponseState(request, response, undefined, qoderStateOptions); + expect(responseStateMetrics().count).toBe(0); + + const followUp = { + model: "Qwen3.8-Flash", + previous_response_id: response.id, + input: "next turn", + store: false, + }; + expect(expandPreviousResponseInput(followUp, "qoder-task")).toEqual(followUp); + }); + + test("unflagged store:true and forced replay remain unchanged", () => { + const request = { model: "gpt-5.5", input: "initial query", store: true }; + const response = buildResponseJSON([ + { type: "text_delta", text: "response text" }, + { type: "done" }, + ], "gpt-5.5"); + rememberResponseState(request, response, undefined, { clientThreadId: "task-normal" }); + + const followUp = { + model: "gpt-5.5", + previous_response_id: response.id, + input: "follow-up text", + store: true, + }; + const expanded = expandPreviousResponseInput(followUp, "task-normal") as { input: unknown[] }; + expect(Array.isArray(expanded.input)).toBe(true); + expect(expanded.input.length).toBeGreaterThan(1); + }); + + test("preserves flagged unforcedStoreFalse provenance through snapshot flush and reload", async () => { + const callId = "call_persisted_tool_1"; + const request = { model: "Qwen3.8-Flash", input: "use probe_echo", store: false }; + const response = buildResponseJSON([ + { type: "tool_call_start", id: callId, name: "probe_echo" }, + { type: "tool_call_delta", arguments: '{"value":"FACT1"}' }, + { type: "tool_call_end" }, + { type: "done", stopReason: "tool_use", endTurn: false }, + ], "Qwen3.8-Flash"); + rememberResponseState(request, response, undefined, qoderStateOptions); + await flushResponseState(); + + // Simulate process restart + clearResponseStateMemoryForTests(); + + // Replay with matching output succeeds after reload + const validFollowUp = { + model: "Qwen3.8-Flash", + previous_response_id: response.id, + input: [{ type: "function_call_output", call_id: callId, output: "RELOADED_OK" }], + store: false, + }; + const expanded = expandPreviousResponseInput(validFollowUp, "qoder-task"); + const parsed = parseRequest(expanded); + expect(parsed.context.messages.at(-1)).toMatchObject({ + role: "toolResult", + toolCallId: callId, + content: "RELOADED_OK", + }); + + // Replay with text only still refused after reload + const invalidFollowUp = { + model: "Qwen3.8-Flash", + previous_response_id: response.id, + input: "some text", + store: false, + }; + expect(expandPreviousResponseInput(invalidFollowUp, "qoder-task")).toEqual(invalidFollowUp); + expect(expandPreviousResponseInput({ + ...validFollowUp, input: [{ type: "function_call_output", call_id: "wrong_call_id", output: "OTHER" }], + }, "qoder-task")).toMatchObject({ input: [{ type: "function_call_output", call_id: "wrong_call_id", output: "OTHER" }] }); + }); + + test("carries flagged provenance through resident demotion, spill stub, and snapshot reload", async () => { + const callId = "call_spilled_pending_1"; + const response = buildResponseJSON([ + { type: "tool_call_start", id: callId, name: "probe_echo" }, + { type: "tool_call_delta", arguments: '{}' }, + { type: "tool_call_end" }, + { type: "done", stopReason: "tool_use", endTurn: false }, + ], "Qwen3.8-Flash"); + rememberResponseState({ input: "call probe_echo", store: false }, response, undefined, qoderStateOptions); + expect(responseStateMetrics().residentCount).toBe(1); + evictOldestResponseContinuationForBudget(); + expect(responseStateMetrics().spillStubCount).toBe(1); + await flushResponseState(); + const snapshot = readFileSync(join(home, "responses-state.json"), "utf8"); + expect(snapshot).toContain('"unforcedStoreFalse":true'); + expect(snapshot).not.toContain("retainForToolContinuation"); + clearResponseStateMemoryForTests(); + + const invalid = { previous_response_id: response.id, input: "plain text", store: false }; + expect(expandPreviousResponseInput(invalid, "qoder-task")).toEqual(invalid); + const wrong = { previous_response_id: response.id, input: [ + { type: "function_call_output", call_id: "wrong_call_id", output: "OTHER" }, + ], store: false }; + expect(expandPreviousResponseInput(wrong, "qoder-task")).toEqual(wrong); + const valid = { previous_response_id: response.id, + input: [{ type: "function_call_output", call_id: callId, output: "OK" }], store: false }; + expect(expandPreviousResponseInput(valid, "qoder-task")).not.toEqual(valid); + }); +}); From 864d4b4bf5777c28a917125d420fa70883f8887f Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 8 Oct 2026 09:59:28 +0900 Subject: [PATCH 2/7] fix(qoder): keep CodeBuddy history names and fit the merged size budgets Only Qoder opts into projecting prior tool calls with CLI-emitted names; without a vendor map the history keeps the bare request name as before. Compact the two new test-layout registrations and rewrap the Qoder catalog paragraph so the merge with dev stays under the layout.json and structure doc line budgets. --- scripts/test-layout/layout.json | 6 ++---- src/adapters/coding-agent/protocol.ts | 3 ++- src/adapters/coding-agent/turn.ts | 4 +++- src/adapters/qoder/adapter.ts | 1 + structure/providers-and-adapters.md | 22 ++++++++++------------ 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index c3848e350c7..93df15f075b 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1433,8 +1433,7 @@ "proxy-liveness-package-tree-fence.test.ts": "server", "proxy-liveness.test.ts": "server", "qoder-adapter.test.ts": "providers", - "qoder-live-models.test.ts": "providers", - "qoder-mcp-server.test.ts": "providers", + "qoder-live-models.test.ts": "providers", "qoder-mcp-server.test.ts": "providers", "qoder-scaffold-guard.test.ts": "providers", "quota-401-recovery-runtime.test.ts": "usage", "quota-401-recovery.test.ts": "usage", @@ -1601,8 +1600,7 @@ "responses-spill-orphan-sweep.test.ts": "responses", "responses-spill-shutdown-clock.test.ts": "responses", "responses-state-snapshot-select.test.ts": "responses", - "responses-state-snapshot-stubs.test.ts": "responses", - "responses-state-store-false.test.ts": "responses", + "responses-state-snapshot-stubs.test.ts": "responses", "responses-state-store-false.test.ts": "responses", "responses-state-write-amplification.test.ts": "responses", "responses-state.test.ts": "responses", "responses-stateless-dangling-call-repair.test.ts": "responses", diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts index 6f67d65fc39..64d3219d345 100644 --- a/src/adapters/coding-agent/protocol.ts +++ b/src/adapters/coding-agent/protocol.ts @@ -661,7 +661,8 @@ function formatMessageForHistory(message: OcxMessage, vendorNameByWire?: Readonl } else if (part.type === "thinking" && part.thinking.trim()) { parts.push(`[Thinking: ${part.thinking.trim()}]`); } else if (part.type === "toolCall") { - const wireName = namespacedToolName(part.namespace, part.name); + // Without a vendor map the history keeps the bare request name, byte-for-byte as before. + const wireName = vendorNameByWire ? namespacedToolName(part.namespace, part.name) : part.name; const name = vendorNameByWire?.get(wireName) ?? wireName; const args = JSON.stringify(part.arguments ?? {}); parts.push(`[Tool call: ${name} (call_id: ${part.id}) with args: ${args}]`); diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts index fe59db81d4b..ca98cadd5e7 100644 --- a/src/adapters/coding-agent/turn.ts +++ b/src/adapters/coding-agent/turn.ts @@ -136,6 +136,8 @@ export interface CodingAgentToolBridgeInput { emittedNameMap: Map; /** Tool_use blocks accepted in one assistant message. */ maxTurnToolCalls: number; + /** Render prior tool calls with the CLI-emitted names. Off by default so CodeBuddy history is unchanged. */ + projectHistoryToolNames?: boolean; /** Finish the turn when a complete assistant frame supplies structured tool_use blocks. */ completeAssistantToolUse?: boolean; /** Authoritative tool-turn stop signal; existing coding-agent bridges default to message_stop. */ @@ -423,7 +425,7 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise [wire, emitted])) : undefined; for (const line of buildConversationInput(parsed, { maxHistoryChars: historyCharLimit, vendorNameByWire })) stdin.write(`${line}\n`); diff --git a/src/adapters/qoder/adapter.ts b/src/adapters/qoder/adapter.ts index 64dab6df471..da134afd5e4 100644 --- a/src/adapters/qoder/adapter.ts +++ b/src/adapters/qoder/adapter.ts @@ -150,6 +150,7 @@ export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapt tools: catalog.tools, emittedNameMap: catalog.emittedNameMap, maxTurnToolCalls: MAX_TOOL_BLOCK_STARTS, + projectHistoryToolNames: true, completeAssistantToolUse: true, toolTurnCompletionSignal: "assistant_tool_use_stop", allowedToolsFlag: "--allowed-tools", diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 8dc53fb84d9..e92874bc6c8 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -61,18 +61,16 @@ Failure prevents CLI spawn and settles the bridge's private directory; the CodeB also settles its prompt-file directory. Catalog and MCP-config write failures cover both owners. In a compiled executable, the bridge launches the private `__codebuddy-mcp` CLI entrypoint; source execution launches the MCP module with Bun. Both paths advertise only the request's -isolated catalog and leave tool execution to the external client. Qoder's selected catalog -projection in `src/adapters/coding-agent/tool-catalog.ts` rejects empty, control-bearing, comma-bearing, -or unpaired-surrogate name/namespace components and wire names over 512 UTF-8 bytes before staging. -It admits at most 128 selected tools, 256 KiB per serialized definition, and 2 MiB for the serialized -array (including brackets and separators); the comma-joined `--allowed-tools` argument is separately -capped at 8 KiB of UTF-8, counting full `mcp____` names and commas. Invalid catalogs return a fixed local 400 -without spawning Qoder. Valid wire identities and order are unchanged. Schemas are unchanged except for the -Responses-only encrypted marker stripped during projection. Duplicate and ambiguous wire -identities remain the Responses parser's responsibility. Qoder appends the folded -system prompt through its documented scoped `QODER_APPEND_SYSTEM_PROMPT` or -`QODERCN_APPEND_SYSTEM_PROMPT` child environment, -never through command-line arguments or inherited vendor variables. +isolated catalog and leave tool execution to the external client. Qoder's selected catalog projection in +`src/adapters/coding-agent/tool-catalog.ts` rejects empty, control-bearing, comma-bearing, or unpaired-surrogate +name/namespace components and wire names over 512 UTF-8 bytes before staging. It admits at most 128 selected tools, +256 KiB per serialized definition, and 2 MiB for the serialized array (including brackets and separators); the +comma-joined `--allowed-tools` argument is separately capped at 8 KiB of UTF-8, counting full +`mcp____` names and commas. Invalid catalogs return a fixed local 400 without spawning Qoder. Valid +wire identities, order and schemas are unchanged, apart from the Responses-only encrypted marker stripped during +projection. Duplicate and ambiguous wire identities remain the Responses parser's responsibility. Qoder appends the +folded system prompt through its documented scoped `QODER_APPEND_SYSTEM_PROMPT` or `QODERCN_APPEND_SYSTEM_PROMPT` +child environment, never through command-line arguments or inherited vendor variables. Coding-agent stdout is framed as bounded JSONL directly from decoded stream segments. The framer tracks the current line's UTF-8 byte count incrementally, searches each decoded segment once, and From c05091b9662bec9313bcbd6a2cd1999cb2e0f609 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 8 Oct 2026 10:05:46 +0900 Subject: [PATCH 3/7] fix(qoder): enforce complete tool bridge and custom continuation guards --- src/adapters/coding-agent/protocol.ts | 34 ++++++- src/adapters/coding-agent/turn.ts | 24 +++-- src/responses/state.ts | 4 +- src/responses/state/unforced-store-false.ts | 31 +++--- structure/providers-and-adapters.md | 8 +- .../codebuddy-tool-bridge-turn.test.ts | 31 ++++++ tests/providers/qoder-adapter.test.ts | 96 ++++++++++++++++++- .../responses-state-store-false.test.ts | 74 ++++++++++++-- 8 files changed, 271 insertions(+), 31 deletions(-) diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts index 64d3219d345..a843c20b912 100644 --- a/src/adapters/coding-agent/protocol.ts +++ b/src/adapters/coding-agent/protocol.ts @@ -262,6 +262,8 @@ export interface StreamParseState { uncapturedToolUse?: boolean; /** Consume complete assistant tool_use blocks when the CLI does not stream partial tool events. */ completeAssistantToolUse?: boolean; + /** Complete-only snapshots retained for exact-repeat suppression and conflicting-ID rejection. */ + completeToolCalls?: Map; /** Highest-seen usage snapshot from `message_delta`/assistant frames before a terminal result. */ partialUsage?: OcxUsage; } @@ -306,15 +308,41 @@ export function mapStreamMessageToEvents(message: StreamMessage, state: StreamPa const name = asString(part.name); const input = asRecord(part.input); if (!id || !name || !input) throw new CodingAgentProtocolError("Coding-agent CLI returned a tool call without structured identity and input."); + const argumentsJson = JSON.stringify(input); + const previous = state.completeToolCalls?.get(id); + if (previous) { + if (previous.name !== name || previous.argumentsJson !== argumentsJson) { + throw new CodingAgentProtocolError("Coding-agent CLI reused a completed tool call ID with conflicting name or input."); + } + continue; + } const limit = Math.min(MAX_TOOL_BLOCK_STARTS, state.maxToolBlockStarts ?? MAX_TOOL_BLOCK_STARTS); if ((state.toolBlockStarts ?? 0) >= limit) { state.toolCallLimitExceeded = true; return events; } + const budget = state.translatorBudget; + const budgetCallIds: string[] = []; + if (budget) { + try { + // Match partial capture: ID has its own call limit; name and args share one. + for (const bytes of [Buffer.byteLength(id), Buffer.byteLength(name) + Buffer.byteLength(argumentsJson)]) { + const leaseId = `coding-agent-complete:${++nextBudgetCallOrdinal}`; + budget.openCall(leaseId); + budgetCallIds.push(leaseId); + budget.chargeRetained(bytes, { kind: "tool_args", callId: leaseId }); + } + } catch (error) { + for (const leaseId of budgetCallIds) budget.closeCall(leaseId); + throw error; + } + } + // Retain snapshot bytes until turn cleanup: dedup still owns name/input after emit. + (state.completeToolCalls ??= new Map()).set(id, { name, argumentsJson, budgetCallIds }); state.toolBlockStarts = (state.toolBlockStarts ?? 0) + 1; events.push( { type: "tool_call_start", id, name }, - { type: "tool_call_delta", arguments: JSON.stringify(input) }, + { type: "tool_call_delta", arguments: argumentsJson }, { type: "tool_call_end" }, ); state.completedToolCalls = (state.completedToolCalls ?? 0) + 1; @@ -459,6 +487,10 @@ export function releaseOpenToolBlocks(state: StreamParseState): void { state.openToolBlocks?.clear(); for (const leaseId of state.partialToolCallBudgetIds ?? []) state.translatorBudget?.closeCall(leaseId); state.partialToolCallBudgetIds = undefined; + for (const call of state.completeToolCalls?.values() ?? []) { + for (const leaseId of call.budgetCallIds) state.translatorBudget?.closeCall(leaseId); + } + state.completeToolCalls?.clear(); } /** Map a raw Anthropic SSE event (carried inside a `stream_event` frame) to AdapterEvents. */ diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts index ca98cadd5e7..ad72eb98ce6 100644 --- a/src/adapters/coding-agent/turn.ts +++ b/src/adapters/coding-agent/turn.ts @@ -456,13 +456,19 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise : undefined; const rawBlock = rawEvent?.content_block; + const assistantMessage = toolBridge.completeAssistantToolUse && message.type === "assistant" + && message.message !== null && typeof message.message === "object" + ? message.message as Record : undefined; + const completeToolUse = Array.isArray(assistantMessage?.content) && assistantMessage.content.some(block => + block !== null && typeof block === "object" && (block as Record).type === "tool_use", + ); if ( !initValidated - && rawEvent?.type === "content_block_start" - && rawBlock !== null - && typeof rawBlock === "object" - && !Array.isArray(rawBlock) - && (rawBlock as Record).type === "tool_use" + && (completeToolUse || (rawEvent?.type === "content_block_start" + && rawBlock !== null + && typeof rawBlock === "object" + && !Array.isArray(rawBlock) + && (rawBlock as Record).type === "tool_use")) ) { emitOnce({ type: "error", @@ -623,7 +629,13 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise 0 && (state.completedToolCalls ?? 0) === (state.toolBlockStarts ?? 0)) { - // The raw-start gate above already refused any call opened before the handshake. + if (!initValidated) { + emitOnce({ type: "error", message: "Coding-agent CLI ended before the tool bridge init handshake completed.", + status: 502, errorType: "upstream_error", code: "tool_bridge_init_missing", retryable: false }); + kill(); + break; + } + // Both partial and complete tool calls require the validated handshake. // The capture-only MCP handler never answers, so the CLI parks after the tool-turn 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. diff --git a/src/responses/state.ts b/src/responses/state.ts index 5328f71d744..94c658e143f 100644 --- a/src/responses/state.ts +++ b/src/responses/state.ts @@ -21,7 +21,7 @@ import { import { collectReferencedSpillFileNames, snapshotReferencedSpillFileNames } from "./state/spill-inspect"; import { selectSnapshotEntries, snapshotPayload } from "./state/snapshot-select"; import { clientCarriedPrefixLength, providerIssuedIdentity } from "./state/replay-fingerprint"; -import { hasPendingFunctionCall, allowsUnforcedStoreFalseReplay } from "./state/unforced-store-false"; +import { hasPendingClientToolCall, allowsUnforcedStoreFalseReplay } from "./state/unforced-store-false"; import { computeResponseStateMetrics, type ResponseStateMetrics } from "./state/metrics"; export type { ResponseStateMetrics } from "./state/metrics"; export type { ResponseStateTempRecoveryResult, ResponseStateTempRecoveryOptions } from "./state/temp-recovery"; @@ -1241,7 +1241,7 @@ export function rememberResponseState( if (typeof response.id !== "string" || !Array.isArray(response.output)) return; // Only an explicitly opted-in adapter may retain pending client calls despite store:false. const unforcedStoreFalse = request.store === false && !opts?.force; - if (unforcedStoreFalse && !(opts?.retainForToolContinuation === true && hasPendingFunctionCall(response.output))) return; + if (unforcedStoreFalse && !(opts?.retainForToolContinuation === true && hasPendingClientToolCall(response.output))) return; if (response.status === "incomplete") { const details = response.incomplete_details; if (!details || typeof details !== "object" || Array.isArray(details) diff --git a/src/responses/state/unforced-store-false.ts b/src/responses/state/unforced-store-false.ts index 30a221f602f..f03b67bec5d 100644 --- a/src/responses/state/unforced-store-false.ts +++ b/src/responses/state/unforced-store-false.ts @@ -1,15 +1,15 @@ /** - * Closed provenance and replay gating for unforced store:false function call responses. + * Closed provenance and replay gating for unforced store:false client tool call responses. * * An unforced store:false response is cached exclusively to allow the client to send a matching - * function_call_output continuation on the next turn. Normal replay, text continuation, or + * function/custom tool output continuation on the next turn. Normal replay, text continuation, or * mismatched output without a matching pending call fails replay and requires the client to * supply the full conversation. */ -export function hasPendingFunctionCall(output: readonly unknown[]): boolean { +export function hasPendingClientToolCall(output: readonly unknown[]): boolean { return output.some(item => - !!item && typeof item === "object" && (item as { type?: unknown }).type === "function_call", + !!item && typeof item === "object" && clientToolOutputType((item as { type?: unknown }).type) !== undefined, ); } @@ -20,25 +20,34 @@ export function allowsUnforcedStoreFalseReplay( carried: number, ): boolean { const anchor = providerOutputStart ?? 0; - const pendingCallIds = new Set(); + const pendingCallIds = new Map(); for (const item of stored.slice(anchor)) { - if (item && typeof item === "object" && (item as { type?: unknown }).type === "function_call") { + if (item && typeof item === "object") { + const outputType = clientToolOutputType((item as { type?: unknown }).type); const callId = (item as { call_id?: unknown }).call_id; - if (typeof callId === "string" && callId) pendingCallIds.add(callId); + if (outputType && typeof callId === "string" && callId) pendingCallIds.set(callId, outputType); } } for (let i = 0; i < carried; i++) { const item = clientInput[i]; - if (item && typeof item === "object" && (item as { type?: unknown }).type === "function_call_output") { + if (item && typeof item === "object") { const callId = (item as { call_id?: unknown }).call_id; - if (typeof callId === "string") pendingCallIds.delete(callId); + if (typeof callId === "string" && pendingCallIds.get(callId) === (item as { type?: unknown }).type) pendingCallIds.delete(callId); } } return clientInput.slice(carried).some(item => { - if (item && typeof item === "object" && (item as { type?: unknown }).type === "function_call_output") { + if (item && typeof item === "object") { const callId = (item as { call_id?: unknown }).call_id; - return typeof callId === "string" && pendingCallIds.has(callId); + return typeof callId === "string" && pendingCallIds.has(callId) + && pendingCallIds.get(callId) === (item as { type?: unknown }).type; } return false; }); } + +/** Closed call/output pairs supported by the client-owned continuation path. */ +function clientToolOutputType(type: unknown): string | undefined { + if (type === "function_call") return "function_call_output"; + if (type === "custom_tool_call") return "custom_tool_call_output"; + return undefined; +} diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index e92874bc6c8..2497ffbfa75 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -15,22 +15,22 @@ through initial builds, retries, continuations, and sidecar builds. 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. Distinct indices can interleave. -For the CodeBuddy capture-only bridge, the init handshake is checked before buffering. +For capture-only bridges, the init handshake is checked before buffering or emitting tool calls. The shared parser admits a valid-ID tool start before allocating its block, with a 16-call ceiling for CodeBuddy and Qoder and any tighter bridge ceiling applied there. IDs, names, and argument fragments charge the request's translator budget while buffered; closing, -replacement, and turn cleanup release those reservations. A new start on an occupied +replacement, and turn cleanup release reservations; Qoder complete snapshots retain budgeted ID/name/input until cleanup, suppress exact repeats, and reject conflicting ID reuse. A new start on an occupied index closes the previous block only when its arguments form a complete JSON object; an unindexed delta or stop cannot be attributed to an indexed block, and a nonempty argument delta that cannot be attributed fails immediately. Tool-bridge completion requires every opened block to close, preserving the downstream single-open-call contract. The authoritative completion signal is adapter-selected: existing shared coding-agent bridges default to `message_stop`, while Qoder selects only the final complete assistant frame's `message.stop_reason === "tool_use"` (its last sibling only). Qoder does not treat `message_stop` as its completion signal. Silence never ends the tool leg. An incomplete call fails closed with `protocol_error`; otherwise the bridge emits one `done(tool_use)` with usage and terminates the parked CLI; the external client owns execution. 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 +The capture-only bridge checks raw starts, complete tool-use frames and synthesized completion against the init handshake before buffering; a later init cannot authorize a call that started earlier. The 8 MiB JSONL line ceiling is independent of the retained tool-block budget. -Qoder alone opts into restricted `store:false` function-call continuation through `adapterNeedsToolCallContinuation` in `src/server/responses/core-replay.ts`. RunTurn SSE/JSON and sidecar completion pass `retainForToolContinuation` using the serving adapter; `src/responses/state.ts` admits an unforced `store:false` response only with that opt-in and an actual `function_call`. Text-only Qoder responses and unforced non-opted-in providers, including CodeBuddy, are not retained. Saved entries keep the existing `unforcedStoreFalse` matching-tool-result replay gate through TTL, snapshot, spill, reload and memory-budget controls; the admission option is not persisted. Kiro/Cursor forced continuation and passthrough `force:true` remain unchanged. +Qoder alone opts into restricted `store:false` function/custom-tool continuation through `adapterNeedsToolCallContinuation` in `src/server/responses/core-replay.ts`. RunTurn SSE/JSON and sidecar completion pass `retainForToolContinuation` using the serving adapter; `src/responses/state.ts` admits an unforced `store:false` response only with that opt-in and an actual `function_call` or `custom_tool_call`. Text-only Qoder responses and unforced non-opted-in providers, including CodeBuddy, are not retained. Saved entries keep the existing `unforcedStoreFalse` matching-call/output-type replay gate (any matching pending result admits a partial batch) through TTL, snapshot, spill, reload and memory-budget controls; the admission option is not persisted. Kiro/Cursor forced continuation and passthrough `force:true` remain unchanged. Direct MCP names emitted in a verified custom code-mode catalog follow the [Responses restoration boundary](transports/responses-wire-shapes.md#direct-mcp-calls-in-code-mode). diff --git a/tests/providers/codebuddy-tool-bridge-turn.test.ts b/tests/providers/codebuddy-tool-bridge-turn.test.ts index 254a09f012d..0f01cf53720 100644 --- a/tests/providers/codebuddy-tool-bridge-turn.test.ts +++ b/tests/providers/codebuddy-tool-bridge-turn.test.ts @@ -93,6 +93,37 @@ const BLOCK_STOP = { type: "stream_event", event: { type: "content_block_stop" } const MESSAGE_STOP = { type: "stream_event", event: { type: "message_stop" } }; describe("CodeBuddy capture-only tool bridge turn", () => { + test("active bridge continuation keeps prior tool calls under the bare request name", async () => { + const p = parsed([tool("probe_echo")]); + const callId = "cb_prior_call"; + p.context.messages = [ + { role: "user", content: "run probe", timestamp: 0 }, + { role: "assistant", content: [{ type: "toolCall", id: callId, name: "probe_echo", arguments: { a: 1 } }], timestamp: 1 }, + { role: "toolResult", toolCallId: callId, content: "PROBE_OK", timestamp: 2 }, + ]; + const cliName = [...buildCodeBuddyToolBridge(p).emittedNameMap.keys()][0]!; + expect(cliName).toMatch(/^mcp__/); + let stdin = ""; + let args: readonly string[] = []; + const adapter = createCodeBuddyAdapter(provider(), { + which: () => "/usr/bin/codebuddy", + spawn: (_command, childArgs) => { + args = childArgs; + const child = fakeChild(frameLines([INIT_OK, { type: "result", subtype: "success", is_error: false }])); + child.stdin = new Writable({ write(chunk, _encoding, callback) { stdin += String(chunk); callback(); } }); + return child as unknown as ChildProcess; + }, + }); + const events = await run(adapter, p); + expect(args).toContain("--mcp-config"); + expect(args[args.indexOf("--allowedTools") + 1]).toBe(cliName); + const text = JSON.parse(stdin.trim()).message.content[0].text; + expect(text).toContain(`[Tool call: probe_echo (call_id: ${callId}) with args: {"a":1}]`); + expect(text).not.toContain(`[Tool call: ${cliName}`); + expect(text).toContain(`TOOL RESULT (call_id: ${callId}):\nPROBE_OK`); + expect(events.at(-1)?.type).toBe("done"); + }); + test.each(["catalog.json", "mcp.json"])("bridge staging failure in %s is private and cleans both staging directories", async failedFile => { const before = new Set(readdirSync(tmpdir())); const promptDirs: string[] = []; diff --git a/tests/providers/qoder-adapter.test.ts b/tests/providers/qoder-adapter.test.ts index 39c0b6812a7..dcacf554670 100644 --- a/tests/providers/qoder-adapter.test.ts +++ b/tests/providers/qoder-adapter.test.ts @@ -4,7 +4,7 @@ import { readFileSync } from "node:fs"; import { PassThrough, Readable, Writable } from "node:stream"; import type { ChildProcess } from "node:child_process"; import { buildQoderArgs, buildQoderChildEnv, createQoderAdapter } from "../../src/adapters/qoder/adapter"; -import { buildConversationInput, mapStreamMessageToEvents, type StreamParseState } from "../../src/adapters/coding-agent/protocol"; +import { buildConversationInput, mapStreamMessageToEvents, releaseOpenToolBlocks, type StreamParseState } from "../../src/adapters/coding-agent/protocol"; import type { CodingAgentDeps } from "../../src/adapters/coding-agent/turn"; import { buildCodingAgentToolCatalog, CODING_AGENT_TOOL_LIMITS } from "../../src/adapters/coding-agent/tool-catalog"; import { buildResponseJSON } from "../../src/bridge/response-json"; @@ -49,6 +49,100 @@ function fakeChild(frames: string[], options: { parked?: boolean } = {}): ChildP } describe("qoder adapter", () => { + test.each([false, true])("complete tool calls before init fail closed (late init=%s)", async lateInit => { + const frames: unknown[] = [{ type: "assistant", message: { stop_reason: "tool_use", content: [ + { type: "tool_use", id: "call_pre_init", name: "mcp__opencodex__probe_echo", input: {} }, + ] } }]; + if (lateInit) frames.push({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }); + const child = fakeChild(frames.map(frame => JSON.stringify(frame) + "\n"), { parked: true }); + const adapter = createQoderAdapter(provider(), { which: () => "/bin/qoder", spawn: () => child }); + const events: AdapterEvent[] = []; + const budget = createTestTranslatorBudget(); + await adapter.runTurn!(toolRequest(), { headers: new Headers(), translatorBudget: budget }, e => events.push(e)); + expect(events).toEqual([expect.objectContaining({ type: "error", code: "tool_bridge_init_missing", status: 502 })]); + expect(child.killed).toBe(true); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + }); + + for (const field of ["id", "name", "arguments", "turn"] as const) { + test(`complete tool ${field} overflow uses translation_buffer_limit and releases leases`, async () => { + const block = { type: "tool_use", id: "call_budget", name: "mcp__opencodex__probe_echo", input: { value: "ok" } }; + if (field === "id") block.id = "x".repeat(65); + if (field === "name") block.name = "x".repeat(65); + if (field === "arguments") block.input.value = "x".repeat(65); + const child = fakeChild([ + JSON.stringify({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }) + "\n", + JSON.stringify({ type: "assistant", message: { stop_reason: "tool_use", content: [block] } }) + "\n", + ], { parked: true }); + const adapter = createQoderAdapter(provider(), { which: () => "/bin/qoder", spawn: () => child }); + const budget = createTestTranslatorBudget(field === "turn" ? { maxTurnBytes: 40 } : { maxCallArgumentBytes: 64 }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(toolRequest(), { headers: new Headers(), translatorBudget: budget }, e => events.push(e)); + expect(events).toEqual([expect.objectContaining({ type: "error", code: "translation_buffer_limit", status: 502 })]); + expect(child.killed).toBe(true); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0, overflows: 1 }); + }); + } + + test("repeated complete snapshots emit once, retain bounded identity, and release on cleanup", () => { + const budget = createTestTranslatorBudget(); + const state: StreamParseState = { + sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false, + completeAssistantToolUse: true, partialToolCallIds: new Set(), translatorBudget: budget, + maxToolBlockStarts: 1, + }; + const frame = { type: "assistant", message: { content: [ + { type: "tool_use", id: "call_snapshot", name: "mcp__opencodex__probe_echo", input: { value: "A" } }, + ] } }; + expect(mapStreamMessageToEvents(frame, state).map(event => event.type)).toEqual(["tool_call_start", "tool_call_delta", "tool_call_end"]); + const retained = budget.snapshot(); + expect(retained.currentBytes).toBeGreaterThanOrEqual(Buffer.byteLength("call_snapshotmcp__opencodex__probe_echo") + Buffer.byteLength('{"value":"A"}')); + expect(mapStreamMessageToEvents(structuredClone(frame), state)).toEqual([]); + expect(budget.snapshot()).toEqual(retained); + expect(state.toolBlockStarts).toBe(1); + expect(state.completedToolCalls).toBe(1); + expect(state.toolCallLimitExceeded).toBeUndefined(); + releaseOpenToolBlocks(state); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + }); + + test.each(["name", "input"])("conflicting complete snapshot %s reuse fails with protocol_error", async field => { + const first = { type: "tool_use", id: "call_reused", name: "mcp__opencodex__probe_echo", input: { value: "A" } }; + const second = structuredClone(first); + if (field === "name") second.name = "mcp__opencodex__other"; + else second.input.value = "B"; + const child = fakeChild([ + JSON.stringify({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }) + "\n", + JSON.stringify({ type: "assistant", message: { content: [first] } }) + "\n", + JSON.stringify({ type: "assistant", message: { stop_reason: "tool_use", content: [second] } }) + "\n", + ], { parked: true }); + const adapter = createQoderAdapter(provider(), { which: () => "/bin/qoder", spawn: () => child }); + const budget = createTestTranslatorBudget(); + const events: AdapterEvent[] = []; + await adapter.runTurn!(toolRequest(), { headers: new Headers(), translatorBudget: budget }, e => events.push(e)); + expect(events.filter(event => event.type === "tool_call_start")).toEqual([{ type: "tool_call_start", id: "call_reused", name: "probe_echo" }]); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([{ type: "tool_call_delta", arguments: '{"value":"A"}' }]); + expect(events.at(-1)).toMatchObject({ type: "error", code: "protocol_error", status: 502 }); + expect(events.some(event => event.type === "done")).toBe(false); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + }); + + test("repeated snapshot at the authoritative stop completes a single call", async () => { + const call = { type: "tool_use", id: "call_repeat", name: "mcp__opencodex__probe_echo", input: {} }; + const child = fakeChild([ + JSON.stringify({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }) + "\n", + JSON.stringify({ type: "assistant", message: { content: [call] } }) + "\n", + JSON.stringify({ type: "assistant", message: { stop_reason: "tool_use", content: [call] } }) + "\n", + ], { parked: true }); + const adapter = createQoderAdapter(provider(), { which: () => "/bin/qoder", spawn: () => child }); + const events: AdapterEvent[] = []; + const budget = createTestTranslatorBudget(); + await adapter.runTurn!(toolRequest(), { headers: new Headers(), translatorBudget: budget }, e => events.push(e)); + expect(events.map(event => event.type)).toEqual(["tool_call_start", "tool_call_delta", "tool_call_end", "done"]); + expect(events.at(-1)).toMatchObject({ type: "done", stopReason: "tool_use", endTurn: false }); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + }); + test("normalizes the observed complete assistant tool_use without replacing its native ID", () => { const state: StreamParseState = { sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false, diff --git a/tests/responses/responses-state-store-false.test.ts b/tests/responses/responses-state-store-false.test.ts index bde7d126d3a..d945b4351e6 100644 --- a/tests/responses/responses-state-store-false.test.ts +++ b/tests/responses/responses-state-store-false.test.ts @@ -49,13 +49,15 @@ describe("Responses unforced store:false ephemeral function call state", () => { }); for (const stream of [false, true]) { - for (const [name, toolTurn] of [["qoder", true], ["qoder", false], ["codebuddy", true]] as const) { + for (const [name, toolTurn] of [["qoder", true], ["qoder", "custom"], ["qoder", false], ["codebuddy", true], ["codebuddy", "custom"]] as const) { test(`execution records only opted-in tool turns (${name}, tools=${toolTurn}, stream=${stream})`, async () => { const resolver = { ...await import("../../src/server/adapter-resolve") }; const release = acquireOwnedSpendHome(); + const custom = toolTurn === "custom"; + const toolName = custom ? "exec" : "probe_echo"; const events: AdapterEvent[] = toolTurn ? [ - { type: "tool_call_start", id: "qoder_native_execution", name: "probe_echo" }, - { type: "tool_call_delta", arguments: "{}" }, { type: "tool_call_end" }, + { type: "tool_call_start", id: "qoder_native_execution", name: toolName }, + { type: "tool_call_delta", arguments: custom ? '{"input":"echo marker"}' : "{}" }, { type: "tool_call_end" }, { type: "done", stopReason: "tool_use", endTurn: false }, ] : [{ type: "text_delta", text: "text-only answer" }, { type: "done" }]; mock.module("../../src/server/adapter-resolve", () => ({ ...resolver, @@ -79,7 +81,8 @@ describe("Responses unforced store:false ephemeral function call state", () => { const response = await handleResponses(new Request("http://localhost/v1/responses", { method: "POST", headers: { "content-type": "application/json", "x-codex-parent-thread-id": "qoder-task" }, body: JSON.stringify({ model: "fixture/fixture-model", input: "use probe_echo", store: false, stream, - tools: [{ type: "function", name: "probe_echo", parameters: { type: "object", properties: {} } }] }), + tools: [custom ? { type: "custom", name: "exec", format: { type: "text" } } + : { type: "function", name: "probe_echo", parameters: { type: "object", properties: {} } }] }), }), config, { provider: "", model: "" }); expect(response.status).toBe(200); const text = await response.text(); @@ -88,14 +91,15 @@ describe("Responses unforced store:false ephemeral function call state", () => { .find(event => event.type === "response.completed")?.response : JSON.parse(text); expect(result?.status).toBe("completed"); + if (toolTurn) expect(result.output).toContainEqual(expect.objectContaining({ type: custom ? "custom_tool_call" : "function_call" })); expect(responseStateMetrics().count).toBe(name === "qoder" && toolTurn ? 1 : 0); const next = { model: "fixture-model", previous_response_id: result.id, input: [ - { type: "function_call_output", call_id: "qoder_native_execution", output: "OK" }, + { type: custom ? "custom_tool_call_output" : "function_call_output", call_id: "qoder_native_execution", output: "OK" }, ], store: false }; const expanded = expandPreviousResponseInput(next, "qoder-task"); if (name === "qoder" && toolTurn) { expect(parseRequest(expanded).context.messages.at(-1)).toMatchObject({ - role: "toolResult", toolCallId: "qoder_native_execution", toolName: "probe_echo", content: "OK", + role: "toolResult", toolCallId: "qoder_native_execution", toolName, content: "OK", }); } else expect(expanded).toEqual(next); } finally { @@ -150,6 +154,64 @@ describe("Responses unforced store:false ephemeral function call state", () => { }); } + test("custom-only store:false continuation is admitted only with opt-in and survives reload", async () => { + const call = { type: "custom_tool_call", call_id: "qoder_custom", name: "exec", input: "echo marker" }; + const response = { id: "resp_custom", status: "completed", output: [call] }; + rememberResponseState({ input: "run exec", store: false }, response, undefined, { retainForToolContinuation: false }); + expect(responseStateMetrics().count).toBe(0); + rememberResponseState({ input: "run exec", store: false }, response, undefined, qoderStateOptions); + expect(responseStateMetrics().count).toBe(1); + await flushResponseState(); + clearResponseStateMemoryForTests(); + const result = { type: "custom_tool_call_output", call_id: call.call_id, output: "CUSTOM_OK" }; + const next = { model: "fixture-model", previous_response_id: response.id, input: [result], store: false }; + const expanded = expandPreviousResponseInput(next, "qoder-task"); + expect(expanded).toMatchObject({ input: [{ role: "user", content: "run exec" }, call, result] }); + expect(parseRequest(expanded).context.messages.at(-1)).toMatchObject({ + role: "toolResult", toolCallId: call.call_id, toolName: "exec", content: "CUSTOM_OK", + }); + for (const input of ["continue", [{ ...result, call_id: "wrong" }], [{ ...result, type: "function_call_output" }]]) { + const invalid = { ...next, input }; + expect(expandPreviousResponseInput(invalid, "qoder-task")).toEqual(invalid); + } + }); + + test.each([false, true])("mixed function/custom batch allows matching results (custom-only=%s)", customOnly => { + const calls = [ + { type: "function_call", call_id: "qoder_function", name: "probe_echo", arguments: "{}" }, + { type: "custom_tool_call", call_id: "qoder_custom", name: "exec", input: "echo marker" }, + ]; + const results = [ + { type: "function_call_output", call_id: "qoder_function", output: "FUNCTION_OK" }, + { type: "custom_tool_call_output", call_id: "qoder_custom", output: "CUSTOM_OK" }, + ]; + const response = { id: "resp_mixed", status: "completed", output: calls }; + rememberResponseState({ input: "use both tools", store: false }, response, undefined, qoderStateOptions); + // Existing function batches require ANY matching pending result, not all results. + // Preserve that contract: a custom-only result admits replay of the whole mixed batch. + const input = customOnly ? results.slice(1) : results; + const next = { model: "fixture-model", previous_response_id: response.id, input, store: false }; + const expanded = expandPreviousResponseInput(next, "qoder-task"); + expect(expanded).toMatchObject({ input: [{ role: "user", content: "use both tools" }, ...calls, ...input] }); + const parsed = parseRequest(expanded); + expect(parsed.context.messages.filter(message => message.role === "toolResult")).toMatchObject(customOnly ? [ + { toolCallId: "qoder_custom", toolName: "exec", content: "CUSTOM_OK" }, + ] : [ + { toolCallId: "qoder_function", toolName: "probe_echo", content: "FUNCTION_OK" }, + { toolCallId: "qoder_custom", toolName: "exec", content: "CUSTOM_OK" }, + ]); + const wrongPair = { ...next, input: [{ type: "custom_tool_call_output", call_id: "qoder_function", output: "WRONG_KIND" }] }; + expect(expandPreviousResponseInput(wrongPair, "qoder-task")).toEqual(wrongPair); + }); + + test("function-only partial result sets continue to admit whole-batch replay", () => { + const calls = ["a", "b"].map(call_id => ({ type: "function_call", call_id, name: "probe_echo", arguments: "{}" })); + rememberResponseState({ input: "use both", store: false }, { id: "resp_function_partial", output: calls }, undefined, qoderStateOptions); + const result = { type: "function_call_output", call_id: "b", output: "OK" }; + expect(expandPreviousResponseInput({ previous_response_id: "resp_function_partial", input: [result] }, "qoder-task")) + .toMatchObject({ input: [{ role: "user", content: "use both" }, ...calls, result] }); + }); + test("Qoder multi-tool continuation preserves both native call IDs and results", () => { const calls = [ { type: "function_call", call_id: "qoder_native_A", name: "probe_a", arguments: '{"value":"A"}' }, From 8b6ca4d9a8e0e4f1bc1c785fa5fa150e8e9928b5 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 8 Oct 2026 10:19:45 +0900 Subject: [PATCH 4/7] fix(qoder): unify tool identities and require replay provenance --- src/adapters/coding-agent/protocol.ts | 62 ++++++++++++------ src/responses/spill-store.ts | 1 + src/responses/state.ts | 9 +++ src/responses/state/snapshot-codec.ts | 3 + src/responses/state/unforced-store-false.ts | 4 +- structure/providers-and-adapters.md | 4 +- tests/providers/qoder-adapter.test.ts | 41 ++++++++++++ .../responses-state-store-false.test.ts | 63 ++++++++++++++++++- 8 files changed, 165 insertions(+), 22 deletions(-) diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts index a843c20b912..fc281a28975 100644 --- a/src/adapters/coding-agent/protocol.ts +++ b/src/adapters/coding-agent/protocol.ts @@ -262,8 +262,8 @@ export interface StreamParseState { uncapturedToolUse?: boolean; /** Consume complete assistant tool_use blocks when the CLI does not stream partial tool events. */ completeAssistantToolUse?: boolean; - /** Complete-only snapshots retained for exact-repeat suppression and conflicting-ID rejection. */ - completeToolCalls?: Map; + /** Shared complete/partial snapshots retained for exact-repeat suppression and conflicting-ID rejection. */ + emittedToolCalls?: Map; /** Highest-seen usage snapshot from `message_delta`/assistant frames before a terminal result. */ partialUsage?: OcxUsage; } @@ -304,18 +304,12 @@ export function mapStreamMessageToEvents(message: StreamMessage, state: StreamPa if (thinking) events.push({ type: "thinking_delta", thinking }); } else if (blockType === "tool_use") { const id = asString(part.id); - if (state.completeAssistantToolUse && !state.partialToolCallIds?.has(id ?? "")) { + if (state.completeAssistantToolUse) { const name = asString(part.name); const input = asRecord(part.input); if (!id || !name || !input) throw new CodingAgentProtocolError("Coding-agent CLI returned a tool call without structured identity and input."); const argumentsJson = JSON.stringify(input); - const previous = state.completeToolCalls?.get(id); - if (previous) { - if (previous.name !== name || previous.argumentsJson !== argumentsJson) { - throw new CodingAgentProtocolError("Coding-agent CLI reused a completed tool call ID with conflicting name or input."); - } - continue; - } + if (isEmittedToolCallRepeat(state, id, name, argumentsJson)) continue; const limit = Math.min(MAX_TOOL_BLOCK_STARTS, state.maxToolBlockStarts ?? MAX_TOOL_BLOCK_STARTS); if ((state.toolBlockStarts ?? 0) >= limit) { state.toolCallLimitExceeded = true; @@ -338,7 +332,8 @@ export function mapStreamMessageToEvents(message: StreamMessage, state: StreamPa } } // Retain snapshot bytes until turn cleanup: dedup still owns name/input after emit. - (state.completeToolCalls ??= new Map()).set(id, { name, argumentsJson, budgetCallIds }); + (state.emittedToolCalls ??= new Map()).set(id, { name, argumentsJson, budgetCallIds }); + state.partialToolCallIds?.add(id); // The ledger's lease already owns this ID. state.toolBlockStarts = (state.toolBlockStarts ?? 0) + 1; events.push( { type: "tool_call_start", id, name }, @@ -448,6 +443,16 @@ function resolveToolBlockKey(state: StreamParseState, event: StreamMessage): num return state.strictToolBlockCapture && block.indexed ? undefined : key; } +/** One emitted identity regardless of whether it arrived complete or in partial blocks. */ +function isEmittedToolCallRepeat(state: StreamParseState, id: string, name: string, argumentsJson: string): boolean { + const previous = state.emittedToolCalls?.get(id); + if (!previous) return false; + if (previous.name !== name || previous.argumentsJson !== argumentsJson) { + throw new CodingAgentProtocolError("Coding-agent CLI reused a completed tool call ID with conflicting name or input."); + } + return true; +} + /** * 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. @@ -455,24 +460,41 @@ function resolveToolBlockKey(state: StreamParseState, event: StreamMessage): num function closeToolBlock(state: StreamParseState, key: number, events: AdapterEvent[], implicit = false): void { const block = state.openToolBlocks?.get(key); if (!block || !state.openToolBlocks) return; - if (state.strictToolBlockCapture && (implicit || block.argParts.length > 0)) { + let argumentsJson: string | undefined; + if (state.completeAssistantToolUse || (state.strictToolBlockCapture && (implicit || block.argParts.length > 0))) { // A second start on this index is an implicit stop only when the previous call's // arguments are already complete. Otherwise a later delta could be assigned to the // wrong call and still produce a superficially successful tool-use turn. An explicit // stop with no deltas retains the CLI's existing empty-arguments representation. - const argumentsJson = block.argParts.join(""); + const rawArgumentsJson = block.argParts.join(""); let parsedArguments: unknown; try { - parsedArguments = JSON.parse(argumentsJson); + parsedArguments = JSON.parse(!implicit && block.argParts.length === 0 ? "{}" : rawArgumentsJson); } catch { throw new CodingAgentProtocolError("Coding-agent CLI ended a tool call with incomplete JSON arguments."); } if (!asRecord(parsedArguments)) { throw new CodingAgentProtocolError("Coding-agent CLI ended a tool call with non-object JSON arguments."); } + if (state.completeAssistantToolUse) { + argumentsJson = JSON.stringify(parsedArguments); + if (isEmittedToolCallRepeat(state, block.id, block.name, argumentsJson)) { + state.openToolBlocks.delete(key); + if (block.budgetCallId) state.translatorBudget?.closeCall(block.budgetCallId); + state.toolBlockStarts = (state.toolBlockStarts ?? 1) - 1; + return; + } + // Canonical serialization can expand numeric literals; admit the retained snapshot too. + const extraBytes = Buffer.byteLength(argumentsJson) - Buffer.byteLength(rawArgumentsJson); + if (extraBytes > 0) state.translatorBudget?.chargeRetained(extraBytes, { kind: "tool_args", callId: block.budgetCallId }); + } } state.openToolBlocks.delete(key); - if (block.budgetCallId) state.translatorBudget?.closeCall(block.budgetCallId); + if (argumentsJson !== undefined) { + (state.emittedToolCalls ??= new Map()).set(block.id, { + name: block.name, argumentsJson, budgetCallIds: block.budgetCallId ? [block.budgetCallId] : [], + }); + } else if (block.budgetCallId) state.translatorBudget?.closeCall(block.budgetCallId); 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" }); @@ -487,10 +509,10 @@ export function releaseOpenToolBlocks(state: StreamParseState): void { state.openToolBlocks?.clear(); for (const leaseId of state.partialToolCallBudgetIds ?? []) state.translatorBudget?.closeCall(leaseId); state.partialToolCallBudgetIds = undefined; - for (const call of state.completeToolCalls?.values() ?? []) { + for (const call of state.emittedToolCalls?.values() ?? []) { for (const leaseId of call.budgetCallIds) state.translatorBudget?.closeCall(leaseId); } - state.completeToolCalls?.clear(); + state.emittedToolCalls?.clear(); } /** Map a raw Anthropic SSE event (carried inside a `stream_event` frame) to AdapterEvents. */ @@ -550,8 +572,12 @@ function mapRawStreamEvent(event: StreamMessage, state: StreamParseState): Adapt const id = asString(block?.id) ?? ""; const name = asString(block?.name) ?? "tool"; if (id) { + const previous = state.emittedToolCalls?.get(id); + if (previous && previous.name !== name) { + throw new CodingAgentProtocolError("Coding-agent CLI reused a completed tool call ID with conflicting name or input."); + } const limit = Math.min(MAX_TOOL_BLOCK_STARTS, state.maxToolBlockStarts ?? MAX_TOOL_BLOCK_STARTS); - if ((state.toolBlockStarts ?? 0) >= limit) { + if (!previous && (state.toolBlockStarts ?? 0) >= limit) { state.toolCallLimitExceeded = true; return events; } diff --git a/src/responses/spill-store.ts b/src/responses/spill-store.ts index 9188ca387a2..b7813649ab6 100644 --- a/src/responses/spill-store.ts +++ b/src/responses/spill-store.ts @@ -558,6 +558,7 @@ function validPayload(value: unknown, responseId: string): value is ResponseSpil && (typeof payload.clientThreadId !== "string" || payload.clientThreadId.trim().length === 0)) return false; if (payload.unforcedStoreFalse !== undefined && typeof payload.unforcedStoreFalse !== "boolean") return false; if (!Array.isArray(payload.items)) return false; + if (payload.unforcedStoreFalse === true && payload.providerOutputStart === undefined) return false; // A malformed boundary must degrade to "never skip", never to a bad index: reject the // payload outright so materialization treats it as corrupt rather than trusting it. if (payload.providerOutputStart !== undefined) { diff --git a/src/responses/state.ts b/src/responses/state.ts index 94c658e143f..8594e2d1e40 100644 --- a/src/responses/state.ts +++ b/src/responses/state.ts @@ -1045,6 +1045,15 @@ function materializeEntry( schedulePersist(); return { ok: false, failure }; } + // A restricted snapshot stub must agree with the validated spill's provenance. + if (entry.unforcedStoreFalse && (result.payload.unforcedStoreFalse !== true + || entry.providerOutputStart === undefined || entry.providerOutputStart > result.payload.items.length + || entry.providerOutputStart !== result.payload.providerOutputStart)) { + spillCounters.readFailures += 1; + replaceWithSpillFailure(id, entry); + schedulePersist(); + return { ok: false, failure: { code: "previous_response_not_found", reason: "spill_corrupt" } }; + } const state = measureResidentEntry(id, { createdAt: result.payload.createdAt, ...(result.payload.clientThreadId ? { clientThreadId: result.payload.clientThreadId } : {}), diff --git a/src/responses/state/snapshot-codec.ts b/src/responses/state/snapshot-codec.ts index 76725e9ee31..d4575e9ed14 100644 --- a/src/responses/state/snapshot-codec.ts +++ b/src/responses/state/snapshot-codec.ts @@ -52,6 +52,9 @@ export function loadSnapshotEntry(id: string, value: unknown, store: SnapshotLoa ? raw as number : undefined; }; + // Restricted retention requires proven provider output; legacy unrestricted rows stay readable. + if (rec.unforcedStoreFalse === true + && anchorFor(rec.kind === "spill" ? Number.MAX_SAFE_INTEGER : Array.isArray(rec.items) ? rec.items.length : 0) === undefined) return; if (rec.kind === "spill") { if (!isSpillRef(rec.spill)) return; const base: Omit = { diff --git a/src/responses/state/unforced-store-false.ts b/src/responses/state/unforced-store-false.ts index f03b67bec5d..af2447544bc 100644 --- a/src/responses/state/unforced-store-false.ts +++ b/src/responses/state/unforced-store-false.ts @@ -19,7 +19,9 @@ export function allowsUnforcedStoreFalseReplay( clientInput: readonly unknown[], carried: number, ): boolean { - const anchor = providerOutputStart ?? 0; + if (typeof providerOutputStart !== "number" || !Number.isSafeInteger(providerOutputStart) + || providerOutputStart < 0 || providerOutputStart > stored.length) return false; + const anchor = providerOutputStart; const pendingCallIds = new Map(); for (const item of stored.slice(anchor)) { if (item && typeof item === "object") { diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 2497ffbfa75..21049579e8c 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -19,7 +19,7 @@ For capture-only bridges, the init handshake is checked before buffering or emit The shared parser admits a valid-ID tool start before allocating its block, with a 16-call ceiling for CodeBuddy and Qoder and any tighter bridge ceiling applied there. IDs, names, and argument fragments charge the request's translator budget while buffered; closing, -replacement, and turn cleanup release reservations; Qoder complete snapshots retain budgeted ID/name/input until cleanup, suppress exact repeats, and reject conflicting ID reuse. A new start on an occupied +replacement, and turn cleanup release reservations; Qoder shares one budgeted emitted-ID/name/input ledger across complete and partial calls until cleanup, suppressing exact repeats and rejecting conflicting reuse in either order. A new start on an occupied index closes the previous block only when its arguments form a complete JSON object; an unindexed delta or stop cannot be attributed to an indexed block, and a nonempty argument delta that cannot be attributed fails immediately. Tool-bridge completion requires every opened block to close, preserving the downstream single-open-call contract. @@ -30,7 +30,7 @@ The capture-only bridge checks raw starts, complete tool-use frames and synthesi buffering; a later init cannot authorize a call that started earlier. The 8 MiB JSONL line ceiling is independent of the retained tool-block budget. -Qoder alone opts into restricted `store:false` function/custom-tool continuation through `adapterNeedsToolCallContinuation` in `src/server/responses/core-replay.ts`. RunTurn SSE/JSON and sidecar completion pass `retainForToolContinuation` using the serving adapter; `src/responses/state.ts` admits an unforced `store:false` response only with that opt-in and an actual `function_call` or `custom_tool_call`. Text-only Qoder responses and unforced non-opted-in providers, including CodeBuddy, are not retained. Saved entries keep the existing `unforcedStoreFalse` matching-call/output-type replay gate (any matching pending result admits a partial batch) through TTL, snapshot, spill, reload and memory-budget controls; the admission option is not persisted. Kiro/Cursor forced continuation and passthrough `force:true` remain unchanged. +Qoder alone opts into restricted `store:false` function/custom-tool continuation through `adapterNeedsToolCallContinuation` in `src/server/responses/core-replay.ts`. RunTurn SSE/JSON and sidecar completion pass `retainForToolContinuation` using the serving adapter; `src/responses/state.ts` admits an unforced `store:false` response only with that opt-in and an actual `function_call` or `custom_tool_call`. Text-only Qoder responses and unforced non-opted-in providers, including CodeBuddy, are not retained. Saved entries keep the existing `unforcedStoreFalse` matching-call/output-type replay gate (any matching pending result admits a partial batch; missing/invalid provider-output boundaries and client-history-only calls never admit replay, including hydrated snapshots/spills) through TTL, snapshot, spill, reload and memory-budget controls; the admission option is not persisted. Kiro/Cursor forced continuation and passthrough `force:true` remain unchanged. Direct MCP names emitted in a verified custom code-mode catalog follow the [Responses restoration boundary](transports/responses-wire-shapes.md#direct-mcp-calls-in-code-mode). diff --git a/tests/providers/qoder-adapter.test.ts b/tests/providers/qoder-adapter.test.ts index dcacf554670..e6a6049cb62 100644 --- a/tests/providers/qoder-adapter.test.ts +++ b/tests/providers/qoder-adapter.test.ts @@ -49,6 +49,47 @@ function fakeChild(frames: string[], options: { parked?: boolean } = {}): ChildP } describe("qoder adapter", () => { + for (const order of ["complete-first", "partial-first"] as const) { + for (const reuse of ["duplicate", "name", "input"] as const) { + test(`mixed ${order} call-id ${reuse} reuse is checked against one emitted identity`, async () => { + const first = { type: "tool_use", id: "call_mixed_identity", name: "mcp__opencodex__probe_echo", input: { value: "A" } }; + const second = structuredClone(first); + if (reuse === "name") second.name = "mcp__opencodex__probe_other"; + if (reuse === "input") second.input.value = "B"; + const complete = (call: typeof first, stop = false) => ({ type: "assistant", message: { stop_reason: stop ? "tool_use" : null, content: [call] } }); + const partial = (call: typeof first) => [ + { type: "stream_event", event: { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: call.id, name: call.name } } }, + // Whitespace differs from the complete serialization; the parsed input is identical. + { type: "stream_event", event: { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: `{ "value": "${call.input.value}" }` } } }, + { type: "stream_event", event: { type: "content_block_stop", index: 0 } }, + ]; + const frames = [ + { type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }, + ...(order === "complete-first" ? [complete(first), ...partial(second), complete(second, true)] : [...partial(first), complete(second, true)]), + ]; + const child = fakeChild(frames.map(frame => JSON.stringify(frame) + "\n"), { parked: true }); + const adapter = createQoderAdapter(provider(), { which: () => "/bin/qoder", spawn: () => child }); + const events: AdapterEvent[] = []; + const budget = createTestTranslatorBudget(); + await adapter.runTurn!(toolRequest(["probe_echo", "probe_other"]), { headers: new Headers(), translatorBudget: budget }, e => events.push(e)); + expect(events.filter(e => e.type === "tool_call_start")).toEqual([{ type: "tool_call_start", id: first.id, name: "probe_echo" }]); + expect(events.filter(e => e.type === "tool_call_end")).toHaveLength(1); + if (reuse === "duplicate") { + expect(events.at(-1)).toMatchObject({ type: "done", stopReason: "tool_use", endTurn: false }); + const output = buildResponseJSON(events, "fixture-model").output as Array>; + expect(output.filter(item => item.type === "function_call")).toHaveLength(1); + expect(output[0]).toMatchObject({ call_id: first.id, name: "probe_echo" }); + expect(JSON.parse(String(output[0]?.arguments))).toEqual({ value: "A" }); + } else { + expect(events.at(-1)).toMatchObject({ type: "error", code: "protocol_error", status: 502 }); + expect(events.some(e => e.type === "done")).toBe(false); + } + expect(child.killed).toBe(true); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + }); + } + } + test.each([false, true])("complete tool calls before init fail closed (late init=%s)", async lateInit => { const frames: unknown[] = [{ type: "assistant", message: { stop_reason: "tool_use", content: [ { type: "tool_use", id: "call_pre_init", name: "mcp__opencodex__probe_echo", input: {} }, diff --git a/tests/responses/responses-state-store-false.test.ts b/tests/responses/responses-state-store-false.test.ts index d945b4351e6..4e6328fe37c 100644 --- a/tests/responses/responses-state-store-false.test.ts +++ b/tests/responses/responses-state-store-false.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { mkdtempSync, readFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { setPlatformForTests } from "../../src/lib/windows-secret-acl"; @@ -23,6 +23,9 @@ import { setResponseStateByteCapForTests, } from "../../src/responses/state"; +import { readResponseSpill, writeResponseSpillDurably } from "../../src/responses/spill-store"; +import { allowsUnforcedStoreFalseReplay } from "../../src/responses/state/unforced-store-false"; + const qoderStateOptions = { clientThreadId: "qoder-task", retainForToolContinuation: adapterNeedsToolCallContinuation("qoder"), @@ -48,6 +51,64 @@ describe("Responses unforced store:false ephemeral function call state", () => { else process.env["OPENCODEX_HOME"] = priorHome; }); + test("restricted replay rejects missing and invalid boundaries at the gate", () => { + const call = { type: "custom_tool_call", call_id: "history_call", name: "exec", input: "echo marker" }; + const result = { type: "custom_tool_call_output", call_id: call.call_id, output: "OK" }; + for (const boundary of [undefined, -1, 0.5, NaN, Infinity, 2]) { + expect(allowsUnforcedStoreFalseReplay([call], boundary, [result], 0)).toBe(false); + } + expect(allowsUnforcedStoreFalseReplay([call], 0, [result], 0)).toBe(true); + }); + + for (const spilled of [false, true]) { + for (const boundary of [undefined, -1, 0.5, "1", null, 1000]) { + test(`restricted ${spilled ? "spill" : "resident"} snapshot rejects boundary ${String(boundary)}`, async () => { + const call = { type: "function_call", call_id: "pending_snapshot", name: "probe_echo", arguments: "{}" }; + rememberResponseState({ input: "run probe", store: false }, { id: "resp_provenance", output: [call] }, undefined, qoderStateOptions); + if (spilled) evictOldestResponseContinuationForBudget(); + await flushResponseState(); + const path = join(home, "responses-state.json"); + const snapshot = JSON.parse(readFileSync(path, "utf8")) as { states: Array<[string, Record]> }; + const entry = snapshot.states.find(([id]) => id === "resp_provenance")![1]; + expect(entry.unforcedStoreFalse).toBe(true); + if (boundary === undefined) delete entry.providerOutputStart; + else entry.providerOutputStart = boundary; + writeFileSync(path, JSON.stringify(snapshot)); + clearResponseStateMemoryForTests(); + const next = { previous_response_id: "resp_provenance", input: [{ type: "function_call_output", call_id: call.call_id, output: "OK" }] }; + expect(expandPreviousResponseInput(next, "qoder-task")).toEqual(next); + }); + } + + test(`a client-history-only call does not authorize restricted replay (${spilled ? "spill" : "resident"})`, async () => { + const historyCall = { type: "custom_tool_call", call_id: "client_history", name: "exec", input: "echo marker" }; + const providerCall = { type: "function_call", call_id: "provider_pending", name: "probe_echo", arguments: "{}" }; + rememberResponseState({ input: [historyCall], store: false }, { id: "resp_history_boundary", output: [providerCall] }, undefined, qoderStateOptions); + if (spilled) evictOldestResponseContinuationForBudget(); + await flushResponseState(); + clearResponseStateMemoryForTests(); + const next = { previous_response_id: "resp_history_boundary", input: [{ type: "custom_tool_call_output", call_id: historyCall.call_id, output: "OK" }] }; + expect(expandPreviousResponseInput(next, "qoder-task")).toEqual(next); + const valid = { ...next, input: [{ type: "function_call_output", call_id: providerCall.call_id, output: "OK" }] }; + expect(expandPreviousResponseInput(valid, "qoder-task")).not.toEqual(valid); + }); + } + + test("restricted spill payload without a boundary is corrupt even with a valid snapshot stub", () => { + const createdAt = Date.now(); + const call = { type: "function_call", call_id: "spill_history", name: "probe_echo", arguments: "{}" }; + const spill = writeResponseSpillDurably("resp_unanchored_spill", { + createdAt, clientThreadId: "qoder-task", items: [call], unforcedStoreFalse: true, + }); + expect(readResponseSpill("resp_unanchored_spill", spill)).toEqual({ ok: false, reason: "corrupt" }); + writeFileSync(join(home, "responses-state.json"), JSON.stringify({ version: 2, states: [["resp_unanchored_spill", { + kind: "spill", createdAt, clientThreadId: "qoder-task", spill, unforcedStoreFalse: true, providerOutputStart: 0, + }]] })); + clearResponseStateMemoryForTests(); + const next = { previous_response_id: "resp_unanchored_spill", input: [{ type: "function_call_output", call_id: call.call_id, output: "OK" }] }; + expect(expandPreviousResponseInput(next, "qoder-task")).toEqual(next); + }); + for (const stream of [false, true]) { for (const [name, toolTurn] of [["qoder", true], ["qoder", "custom"], ["qoder", false], ["codebuddy", true], ["codebuddy", "custom"]] as const) { test(`execution records only opted-in tool turns (${name}, tools=${toolTurn}, stream=${stream})`, async () => { From 5465e1dbc9252e367a704c7d37e478db6d2684b2 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 8 Oct 2026 10:21:02 +0900 Subject: [PATCH 5/7] chore(qoder): fit merged catalog and layout line budgets --- scripts/test-layout/layout.json | 3 +-- structure/providers-and-adapters.md | 12 ++++-------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f3f9eba359a..b62553e42be 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1444,8 +1444,7 @@ "proxy-env.test.ts": "server", "proxy-liveness-package-tree-fence.test.ts": "server", "proxy-liveness.test.ts": "server", - "qoder-adapter.test.ts": "providers", - "qoder-live-models.test.ts": "providers", "qoder-mcp-server.test.ts": "providers", + "qoder-adapter.test.ts": "providers", "qoder-live-models.test.ts": "providers", "qoder-mcp-server.test.ts": "providers", "qoder-scaffold-guard.test.ts": "providers", "quota-401-recovery-runtime.test.ts": "usage", "quota-401-recovery.test.ts": "usage", diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 92ad152f77e..943db043cbf 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -63,16 +63,12 @@ Failure prevents CLI spawn and settles the bridge's private directory; the CodeB also settles its prompt-file directory. Catalog and MCP-config write failures cover both owners. In a compiled executable, the bridge launches the private `__codebuddy-mcp` CLI entrypoint; source execution launches the MCP module with Bun. Both paths advertise only the request's -isolated catalog and leave tool execution to the external client. Qoder's selected catalog projection in -`src/adapters/coding-agent/tool-catalog.ts` rejects empty, control-bearing, comma-bearing, or unpaired-surrogate +isolated catalog and leave tool execution to the external client. Qoder's selected catalog projection in `src/adapters/coding-agent/tool-catalog.ts` rejects empty, control-bearing, comma-bearing, or unpaired-surrogate name/namespace components and wire names over 512 UTF-8 bytes before staging. It admits at most 128 selected tools, -256 KiB per serialized definition, and 2 MiB for the serialized array (including brackets and separators); the -comma-joined `--allowed-tools` argument is separately capped at 8 KiB of UTF-8, counting full -`mcp____` names and commas. Invalid catalogs return a fixed local 400 without spawning Qoder. Valid -wire identities, order and schemas are unchanged, apart from the Responses-only encrypted marker stripped during +256 KiB per serialized definition, and 2 MiB for the serialized array (including brackets and separators); the comma-joined `--allowed-tools` argument is separately capped at 8 KiB of UTF-8, counting full +`mcp____` names and commas. Invalid catalogs return a fixed local 400 without spawning Qoder. Valid wire identities, order and schemas are unchanged, apart from the Responses-only encrypted marker stripped during projection. Duplicate and ambiguous wire identities remain the Responses parser's responsibility. Qoder appends the -folded system prompt through its documented scoped `QODER_APPEND_SYSTEM_PROMPT` or `QODERCN_APPEND_SYSTEM_PROMPT` -child environment, never through command-line arguments or inherited vendor variables. +folded system prompt through its documented scoped `QODER_APPEND_SYSTEM_PROMPT` or `QODERCN_APPEND_SYSTEM_PROMPT` child environment, never through command-line arguments or inherited vendor variables. Coding-agent stdout is framed as bounded JSONL directly from decoded stream segments. The framer tracks the current line's UTF-8 byte count incrementally, searches each decoded segment once, and From 5e311003b831ffcc96331ff88a501f989aac97a0 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 8 Oct 2026 10:30:31 +0900 Subject: [PATCH 6/7] fix(qoder): count distinct calls independently of open repeats --- src/adapters/coding-agent/protocol.ts | 10 +++-- structure/providers-and-adapters.md | 2 +- tests/providers/qoder-adapter.test.ts | 63 +++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts index fc281a28975..a4925848867 100644 --- a/src/adapters/coding-agent/protocol.ts +++ b/src/adapters/coding-agent/protocol.ts @@ -254,7 +254,7 @@ export interface StreamParseState { completedToolCalls?: number; /** CodeBuddy's capture-only bridge requires complete JSON and matching block indices. */ strictToolBlockCapture?: boolean; - /** Tool IDs already captured through partial events, for complete-assistant deduplication. */ + /** Retained bridge IDs; Qoder counts distinct admissions here, independently of open blocks. */ partialToolCallIds?: Set; /** One budget lease per ID retained by complete-assistant deduplication, released at turn cleanup. */ partialToolCallBudgetIds?: string[]; @@ -310,8 +310,9 @@ export function mapStreamMessageToEvents(message: StreamMessage, state: StreamPa if (!id || !name || !input) throw new CodingAgentProtocolError("Coding-agent CLI returned a tool call without structured identity and input."); const argumentsJson = JSON.stringify(input); if (isEmittedToolCallRepeat(state, id, name, argumentsJson)) continue; + const distinctIds = state.partialToolCallIds ??= new Set(); const limit = Math.min(MAX_TOOL_BLOCK_STARTS, state.maxToolBlockStarts ?? MAX_TOOL_BLOCK_STARTS); - if ((state.toolBlockStarts ?? 0) >= limit) { + if (!distinctIds.has(id) && distinctIds.size >= limit) { state.toolCallLimitExceeded = true; return events; } @@ -333,7 +334,7 @@ export function mapStreamMessageToEvents(message: StreamMessage, state: StreamPa } // Retain snapshot bytes until turn cleanup: dedup still owns name/input after emit. (state.emittedToolCalls ??= new Map()).set(id, { name, argumentsJson, budgetCallIds }); - state.partialToolCallIds?.add(id); // The ledger's lease already owns this ID. + distinctIds.add(id); // The ledger's lease already owns this ID. state.toolBlockStarts = (state.toolBlockStarts ?? 0) + 1; events.push( { type: "tool_call_start", id, name }, @@ -576,8 +577,9 @@ function mapRawStreamEvent(event: StreamMessage, state: StreamParseState): Adapt if (previous && previous.name !== name) { throw new CodingAgentProtocolError("Coding-agent CLI reused a completed tool call ID with conflicting name or input."); } + const distinctIds = state.completeAssistantToolUse ? (state.partialToolCallIds ??= new Set()) : undefined; const limit = Math.min(MAX_TOOL_BLOCK_STARTS, state.maxToolBlockStarts ?? MAX_TOOL_BLOCK_STARTS); - if (!previous && (state.toolBlockStarts ?? 0) >= limit) { + if (distinctIds ? !distinctIds.has(id) && distinctIds.size >= limit : (state.toolBlockStarts ?? 0) >= limit) { state.toolCallLimitExceeded = true; return events; } diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 943db043cbf..ee8d3eca95f 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -21,7 +21,7 @@ For capture-only bridges, the init handshake is checked before buffering or emit The shared parser admits a valid-ID tool start before allocating its block, with a 16-call ceiling for CodeBuddy and Qoder and any tighter bridge ceiling applied there. IDs, names, and argument fragments charge the request's translator budget while buffered; closing, -replacement, and turn cleanup release reservations; Qoder shares one budgeted emitted-ID/name/input ledger across complete and partial calls until cleanup, suppressing exact repeats and rejecting conflicting reuse in either order. A new start on an occupied +replacement, and turn cleanup release reservations; Qoder shares one budgeted emitted-ID/name/input ledger across complete and partial calls until cleanup, suppressing exact repeats and rejecting conflicting reuse in either order. Its turn limit counts distinct admitted IDs separately from open/closed-block accounting; an open repeat cannot consume a slot for a new identity. A new start on an occupied index closes the previous block only when its arguments form a complete JSON object; an unindexed delta or stop cannot be attributed to an indexed block, and a nonempty argument delta that cannot be attributed fails immediately. Tool-bridge completion requires every opened block to close, preserving the downstream single-open-call contract. diff --git a/tests/providers/qoder-adapter.test.ts b/tests/providers/qoder-adapter.test.ts index e6a6049cb62..ad314ead4f5 100644 --- a/tests/providers/qoder-adapter.test.ts +++ b/tests/providers/qoder-adapter.test.ts @@ -49,6 +49,69 @@ function fakeChild(frames: string[], options: { parked?: boolean } = {}): ChildP } describe("qoder adapter", () => { + for (const repeatClosesFirst of [false, true]) { + for (const representation of ["complete", "partial"] as const) { + for (const distinctCalls of [16, 17]) { + test(`distinct-call limit ignores a partial repeat (${representation}, repeat-first=${repeatClosesFirst}, calls=${distinctCalls})`, async () => { + const call = (index: number) => ({ type: "tool_use", id: `call_limit_${index}`, name: "mcp__opencodex__probe_echo", input: { value: String(index) } }); + const complete = (index: number) => ({ type: "assistant", message: { content: [call(index)] } }); + const start = (index: number, blockIndex: number) => ({ type: "stream_event", event: { type: "content_block_start", index: blockIndex, content_block: call(index) } }); + const delta = (index: number, blockIndex: number) => ({ type: "stream_event", event: { type: "content_block_delta", index: blockIndex, delta: { type: "input_json_delta", partial_json: JSON.stringify(call(index).input) } } }); + const stop = (index: number) => ({ type: "stream_event", event: { type: "content_block_stop", index } }); + const distinct = (index: number) => representation === "complete" ? [complete(index)] : [start(index, index), delta(index, index), stop(index)]; + const frames = [ + { type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }, + ...Array.from({ length: 15 }, (_, index) => complete(index)), + start(0, 20), delta(0, 20), + ...(repeatClosesFirst ? [stop(20)] : []), + ...distinct(15), + ...(distinctCalls === 17 ? distinct(16) : []), + ...(!repeatClosesFirst ? [stop(20)] : []), + { type: "assistant", message: { stop_reason: "tool_use", content: [] } }, + ]; + const child = fakeChild(frames.map(frame => JSON.stringify(frame) + "\n"), { parked: true }); + const adapter = createQoderAdapter(provider(), { which: () => "/bin/qoder", spawn: () => child }); + const events: AdapterEvent[] = []; + const budget = createTestTranslatorBudget(); + await adapter.runTurn!(toolRequest(), { headers: new Headers(), translatorBudget: budget }, e => events.push(e)); + const starts = events.filter(e => e.type === "tool_call_start"); + expect(starts.map(e => e.id)).toEqual(Array.from({ length: 16 }, (_, index) => `call_limit_${index}`)); + expect(events.filter(e => e.type === "tool_call_end")).toHaveLength(16); + if (distinctCalls === 16) expect(events.at(-1)).toMatchObject({ type: "done", stopReason: "tool_use", endTurn: false }); + else { + expect(events.at(-1)).toMatchObject({ type: "error", code: "tool_call_limit", status: 502 }); + expect(events.some(e => e.type === "done")).toBe(false); + } + expect(child.killed).toBe(true); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + }); + } + } + } + + test.each(["incomplete", "conflicting"])("a %s repeat still fails closed after the sixteenth distinct call", async outcome => { + const frames: unknown[] = [ + { type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }, + ...Array.from({ length: 15 }, (_, index) => ({ type: "assistant", message: { content: [ + { type: "tool_use", id: `call_limit_${index}`, name: "mcp__opencodex__probe_echo", input: { value: String(index) } }, + ] } })), + { type: "stream_event", event: { type: "content_block_start", index: 20, content_block: { type: "tool_use", id: "call_limit_0", name: "mcp__opencodex__probe_echo" } } }, + { type: "stream_event", event: { type: "content_block_delta", index: 20, delta: { type: "input_json_delta", partial_json: outcome === "conflicting" ? '{"value":"different"}' : '{"value":' } } }, + { type: "assistant", message: { content: [{ type: "tool_use", id: "call_limit_15", name: "mcp__opencodex__probe_echo", input: { value: "15" } }] } }, + ...(outcome === "conflicting" ? [{ type: "stream_event", event: { type: "content_block_stop", index: 20 } }] : []), + { type: "assistant", message: { stop_reason: "tool_use", content: [] } }, + ]; + const child = fakeChild(frames.map(frame => JSON.stringify(frame) + "\n"), { parked: true }); + const adapter = createQoderAdapter(provider(), { which: () => "/bin/qoder", spawn: () => child }); + const events: AdapterEvent[] = []; + const budget = createTestTranslatorBudget(); + await adapter.runTurn!(toolRequest(), { headers: new Headers(), translatorBudget: budget }, e => events.push(e)); + expect(events.filter(e => e.type === "tool_call_start")).toHaveLength(16); + expect(events.at(-1)).toMatchObject({ type: "error", code: "protocol_error", status: 502 }); + expect(events.some(e => e.type === "done")).toBe(false); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + }); + for (const order of ["complete-first", "partial-first"] as const) { for (const reuse of ["duplicate", "name", "input"] as const) { test(`mixed ${order} call-id ${reuse} reuse is checked against one emitted identity`, async () => { From f962dfea85f62e0c56a1d7ac930de41e52f66118 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 8 Oct 2026 10:40:15 +0900 Subject: [PATCH 7/7] fix(qoder): bound retained blocks and validate names before allocation --- src/adapters/coding-agent/protocol.ts | 14 +++++- src/adapters/coding-agent/tool-catalog.ts | 4 +- src/adapters/coding-agent/turn.ts | 1 + structure/providers-and-adapters.md | 2 +- tests/providers/qoder-adapter.test.ts | 54 ++++++++++++++++++++++- 5 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts index a4925848867..7eb8e8b6f0d 100644 --- a/src/adapters/coding-agent/protocol.ts +++ b/src/adapters/coding-agent/protocol.ts @@ -1,5 +1,6 @@ import { namespacedToolName, type AdapterEvent, type OcxMessage, type OcxParsedRequest, type OcxUsage } from "../../types"; import type { TranslatorBudget } from "../../lib/translator-budget"; +import { isValidCodingAgentToolNamePart } from "./tool-catalog"; /** * Shared stream-json protocol for official coding-agent CLIs (CodeBuddy Code and Qoder CLI). @@ -242,8 +243,10 @@ export interface StreamParseState { openToolBlocks?: Map; /** Shared request budget for tool identity and buffered argument fragments. */ translatorBudget?: TranslatorBudget; - /** A capture bridge may impose a tighter ceiling than the shared parser limit. */ + /** A capture bridge may impose a tighter ceiling on distinct calls and retained blocks. */ maxToolBlockStarts?: number; + /** Qoder's isolated CLI names, validated at partial block start before retaining anything. */ + toolBridgeNames?: ReadonlyMap; /** Set before an over-limit block can be allocated or emitted. */ toolCallLimitExceeded?: boolean; /** Synthetic decreasing keys for tool_use start frames that omit the block index. */ @@ -573,6 +576,10 @@ function mapRawStreamEvent(event: StreamMessage, state: StreamParseState): Adapt const id = asString(block?.id) ?? ""; const name = asString(block?.name) ?? "tool"; if (id) { + if (state.completeAssistantToolUse && (!isValidCodingAgentToolNamePart(block?.name) + || (state.toolBridgeNames && !state.toolBridgeNames.has(name)))) { + throw new CodingAgentProtocolError("Coding-agent CLI started a tool block with an invalid or undeclared name."); + } const previous = state.emittedToolCalls?.get(id); if (previous && previous.name !== name) { throw new CodingAgentProtocolError("Coding-agent CLI reused a completed tool call ID with conflicting name or input."); @@ -592,6 +599,11 @@ function mapRawStreamEvent(event: StreamMessage, state: StreamParseState): Adapt // block only after the capture path verifies its arguments form a complete object. closeToolBlock(state, key, events, true); } + // Repeats reserve no distinct ID, but each unfinished block retains its own lease. + if ((state.openToolBlocks?.size ?? 0) >= limit) { + state.toolCallLimitExceeded = true; + return events; + } const budget = state.translatorBudget; const budgetCallId = budget ? `coding-agent:${++nextBudgetCallOrdinal}` : undefined; if (budget && budgetCallId) { diff --git a/src/adapters/coding-agent/tool-catalog.ts b/src/adapters/coding-agent/tool-catalog.ts index 9da511c4f5a..0c78ebdff1f 100644 --- a/src/adapters/coding-agent/tool-catalog.ts +++ b/src/adapters/coding-agent/tool-catalog.ts @@ -21,7 +21,7 @@ export class CodingAgentToolCatalogError extends Error { } } -function validNamePart(value: unknown): value is string { +export function isValidCodingAgentToolNamePart(value: unknown): value is string { if (typeof value !== "string" || !value || INVALID_NAME_PART.test(value)) return false; // TextEncoder replaces lone surrogates, which would change tool identity on the MCP wire. for (let i = 0; i < value.length; i++) { @@ -47,7 +47,7 @@ export function buildCodingAgentToolCatalog(parsed: OcxParsedRequest, serverName let catalogBytes = 2; let allowedToolsArgBytes = 0; const tools = selected.map((tool, index) => { - if (!tool || !validNamePart(tool.name) || (tool.namespace !== undefined && !validNamePart(tool.namespace))) { + if (!tool || !isValidCodingAgentToolNamePart(tool.name) || (tool.namespace !== undefined && !isValidCodingAgentToolNamePart(tool.namespace))) { throw new CodingAgentToolCatalogError("invalid tool name or namespace"); } const wireName = namespacedToolName(tool.namespace, tool.name); diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts index ad72eb98ce6..c46a53ad6ef 100644 --- a/src/adapters/coding-agent/turn.ts +++ b/src/adapters/coding-agent/turn.ts @@ -407,6 +407,7 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise() : undefined, completeAssistantToolUse: toolBridge?.completeAssistantToolUse, + toolBridgeNames: toolBridge?.completeAssistantToolUse ? toolBridge.emittedNameMap : undefined, }; const toolTurnCompletionSignal = toolBridge?.toolTurnCompletionSignal ?? "message_stop"; const sawSelectedToolTurnStop = (): boolean => toolTurnCompletionSignal === "assistant_tool_use_stop" diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index ee8d3eca95f..ec506d608bb 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -21,7 +21,7 @@ For capture-only bridges, the init handshake is checked before buffering or emit The shared parser admits a valid-ID tool start before allocating its block, with a 16-call ceiling for CodeBuddy and Qoder and any tighter bridge ceiling applied there. IDs, names, and argument fragments charge the request's translator budget while buffered; closing, -replacement, and turn cleanup release reservations; Qoder shares one budgeted emitted-ID/name/input ledger across complete and partial calls until cleanup, suppressing exact repeats and rejecting conflicting reuse in either order. Its turn limit counts distinct admitted IDs separately from open/closed-block accounting; an open repeat cannot consume a slot for a new identity. A new start on an occupied +replacement, and turn cleanup release reservations; Qoder shares one budgeted emitted-ID/name/input ledger across complete and partial calls until cleanup, suppressing exact repeats and rejecting conflicting reuse in either order. Its turn limit counts distinct admitted IDs separately from open/closed-block accounting; an open repeat cannot consume a slot for a new identity. Retained partial blocks independently obey the same ceiling regardless of ID, and Qoder refuses invalid/undeclared names before allocation; every retained block charges its nonempty name. A new start on an occupied index closes the previous block only when its arguments form a complete JSON object; an unindexed delta or stop cannot be attributed to an indexed block, and a nonempty argument delta that cannot be attributed fails immediately. Tool-bridge completion requires every opened block to close, preserving the downstream single-open-call contract. diff --git a/tests/providers/qoder-adapter.test.ts b/tests/providers/qoder-adapter.test.ts index ad314ead4f5..d8c26d636b7 100644 --- a/tests/providers/qoder-adapter.test.ts +++ b/tests/providers/qoder-adapter.test.ts @@ -49,6 +49,58 @@ function fakeChild(frames: string[], options: { parked?: boolean } = {}): ChildP } describe("qoder adapter", () => { + test.each([false, true])("unfinished repeated-ID blocks are bounded before a terminal frame (already emitted=%s)", async alreadyEmitted => { + const id = "same_call"; + const name = "mcp__opencodex__probe_echo"; + const frames = [ + { type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }, + ...(alreadyEmitted ? [{ type: "assistant", message: { content: [{ type: "tool_use", id, name, input: {} }] } }] : []), + ...Array.from({ length: 64 }, (_, index) => ({ type: "stream_event", event: { type: "content_block_start", index, + content_block: { type: "tool_use", id, name } } })), + // No stops or terminal frames: the seventeenth retained block itself must fail. + ]; + const child = fakeChild(frames.map(frame => JSON.stringify(frame) + "\n")); + const adapter = createQoderAdapter(provider(), { which: () => "/bin/qoder", spawn: () => child }); + const budget = createTestTranslatorBudget(); + const openCall = budget.openCall.bind(budget); + let openedLeases = 0; + let peakActive = 0; + budget.openCall = leaseId => { + openedLeases++; + openCall(leaseId); + peakActive = Math.max(peakActive, budget.snapshot().activeCalls); + }; + const events: AdapterEvent[] = []; + await adapter.runTurn!(toolRequest(), { headers: new Headers(), translatorBudget: budget }, e => events.push(e)); + expect(events.at(-1)).toMatchObject({ type: "error", code: "tool_call_limit", status: 502 }); + expect(events.some(e => e.type === "done")).toBe(false); + expect(events.filter(e => e.type === "tool_call_start")).toHaveLength(alreadyEmitted ? 1 : 0); + expect(openedLeases).toBe(alreadyEmitted ? 18 : 17); // 16 blocks plus the shared identity/snapshot leases. + expect(peakActive).toBe(openedLeases); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0, overflows: 0 }); + expect(child.killed).toBe(true); + }); + + test.each([undefined, null, 7, "", " ", "bad,name", "bad\nname", "bad\ud800", "mcp__opencodex__undeclared"])("invalid partial tool name %j is refused before any lease", async name => { + const child = fakeChild([ + JSON.stringify({ type: "system", subtype: "init", mcp_servers: [{ name: "opencodex", status: "connected" }] }) + "\n", + JSON.stringify({ type: "stream_event", event: { type: "content_block_start", index: 0, + content_block: { type: "tool_use", id: "same_call", name } } }) + "\n", + ]); + const adapter = createQoderAdapter(provider(), { which: () => "/bin/qoder", spawn: () => child }); + // Empty names formerly allocated a block and an ID lease under even an 8-byte budget. + const budget = createTestTranslatorBudget({ maxTurnBytes: 8 }); + const openCall = budget.openCall.bind(budget); + let openedLeases = 0; + budget.openCall = leaseId => { openedLeases++; openCall(leaseId); }; + const events: AdapterEvent[] = []; + await adapter.runTurn!(toolRequest(), { headers: new Headers(), translatorBudget: budget }, e => events.push(e)); + expect(events).toEqual([expect.objectContaining({ type: "error", code: "protocol_error", status: 502 })]); + expect(openedLeases).toBe(0); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0, highWaterBytes: 0, overflows: 0 }); + expect(child.killed).toBe(true); + }); + for (const repeatClosesFirst of [false, true]) { for (const representation of ["complete", "partial"] as const) { for (const distinctCalls of [16, 17]) { @@ -1081,7 +1133,7 @@ describe("qoder authoritative tool-turn completion", () => { parked.pushFrame({ type: "stream_event", - event: { type: "content_block_start", index: 1, content_block: { type: "tool_use", id: "call_inc_2", name: "exec" } }, + event: { type: "content_block_start", index: 1, content_block: { type: "tool_use", id: "call_inc_2", name: "mcp__opencodex__probe_echo" } }, }); await new Promise(r => setImmediate(r)); await new Promise(r => setImmediate(r));