diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 95433c224dd..24d344e4f2c 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1062,7 +1062,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 6c615332cb1..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-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", @@ -1612,7 +1611,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-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..7eb8e8b6f0d 100644 --- a/src/adapters/coding-agent/protocol.ts +++ b/src/adapters/coding-agent/protocol.ts @@ -1,5 +1,6 @@ -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"; +import { isValidCodingAgentToolNamePart } from "./tool-catalog"; /** * Shared stream-json protocol for official coding-agent CLIs (CodeBuddy Code and Qoder CLI). @@ -227,6 +228,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 @@ -240,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. */ @@ -252,12 +257,16 @@ 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[]; /** 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; + /** 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; } @@ -283,6 +292,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 +307,45 @@ 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) { + 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); + 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 (!distinctIds.has(id) && distinctIds.size >= 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.emittedToolCalls ??= new Map()).set(id, { name, argumentsJson, budgetCallIds }); + 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 }, + { type: "tool_call_delta", arguments: argumentsJson }, + { type: "tool_call_end" }, + ); + state.completedToolCalls = (state.completedToolCalls ?? 0) + 1; + } else if (!id || !state.partialToolCallIds?.has(id)) state.uncapturedToolUse = true; } } } @@ -399,6 +447,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. @@ -406,24 +464,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" }); @@ -438,6 +513,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.emittedToolCalls?.values() ?? []) { + for (const leaseId of call.budgetCallIds) state.translatorBudget?.closeCall(leaseId); + } + state.emittedToolCalls?.clear(); } /** Map a raw Anthropic SSE event (carried inside a `stream_event` frame) to AdapterEvents. */ @@ -497,8 +576,17 @@ 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."); + } + const distinctIds = state.completeAssistantToolUse ? (state.partialToolCallIds ??= new Set()) : undefined; const limit = Math.min(MAX_TOOL_BLOCK_STARTS, state.maxToolBlockStarts ?? MAX_TOOL_BLOCK_STARTS); - if ((state.toolBlockStarts ?? 0) >= limit) { + if (distinctIds ? !distinctIds.has(id) && distinctIds.size >= limit : (state.toolBlockStarts ?? 0) >= limit) { state.toolCallLimitExceeded = true; return events; } @@ -511,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) { @@ -625,7 +718,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 +733,11 @@ function formatMessageForHistory(message: OcxMessage): string { } else if (part.type === "thinking" && part.thinking.trim()) { parts.push(`[Thinking: ${part.thinking.trim()}]`); } else if (part.type === "toolCall") { + // 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: ${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 +759,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 +783,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 +817,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 +898,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..0c78ebdff1f --- /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"; + } +} + +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++) { + 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 || !isValidCodingAgentToolNamePart(tool.name) || (tool.namespace !== undefined && !isValidCodingAgentToolNamePart(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..c46a53ad6ef 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,35 @@ 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; + /** 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. */ + 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 +174,8 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise { + killTimer = setTimeoutFn(() => { try { child.kill("SIGKILL"); } catch { /* already gone */ } }, killGraceMs); }; @@ -352,7 +368,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 +380,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 +390,10 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise | undefined; @@ -389,7 +406,13 @@ 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" + ? state.sawAssistantToolUseStop === true + : state.sawMessageStop === true; try { // Write the replayed conversation, then close stdin so a single-shot turn can complete. @@ -403,14 +426,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) { @@ -432,13 +457,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", @@ -522,7 +553,7 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise 0 && (state.completedToolCalls ?? 0) !== (state.toolBlockStarts ?? 0) ) { @@ -596,9 +628,16 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise 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. + if (toolBridge && !terminalEmitted && sawToolTurnStop && (state.toolBlockStarts ?? 0) > 0 + && (state.completedToolCalls ?? 0) === (state.toolBlockStarts ?? 0)) { + 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. // Pre-result usage snapshots keep this terminated leg accountable: no result frame @@ -633,17 +672,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 +725,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,36 @@ 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, + projectHistoryToolNames: true, + 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 +169,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 a47268755e1..7f77a2cad21 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -210,11 +210,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..b7813649ab6 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,12 +551,14 @@ 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; + 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 892ba556e09..8594e2d1e40 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 { 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"; 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) { @@ -1030,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 } : {}), @@ -1038,6 +1062,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 +1100,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 +1121,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 +1141,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 +1187,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 +1197,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 +1237,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 +1247,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 && hasPendingClientToolCall(response.output))) return; if (response.status === "incomplete") { const details = response.incomplete_details; if (!details || typeof details !== "object" || Array.isArray(details) @@ -1300,6 +1283,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..d4575e9ed14 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 { @@ -51,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 = { @@ -61,6 +65,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 +93,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" && clientToolOutputType((item as { type?: unknown }).type) !== undefined, + ); +} + +export function allowsUnforcedStoreFalseReplay( + stored: readonly unknown[], + providerOutputStart: number | undefined, + clientInput: readonly unknown[], + carried: number, +): boolean { + 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") { + const outputType = clientToolOutputType((item as { type?: unknown }).type); + const callId = (item as { call_id?: unknown }).call_id; + 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") { + const callId = (item as { call_id?: unknown }).call_id; + 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") { + const callId = (item as { call_id?: unknown }).call_id; + 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/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 d19f8516726..61e5d0fb2ed 100644 --- a/src/server/responses/sidecar-execution.ts +++ b/src/server/responses/sidecar-execution.ts @@ -39,7 +39,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"; @@ -503,7 +503,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 156ada5a1b6..ec506d608bb 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -17,21 +17,23 @@ 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 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. 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 +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/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). Ordinary structured functions named `exec` do not opt into this compatibility path. @@ -61,10 +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 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 diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 3dda2d291dd..32abcc022c9 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -837,7 +837,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", @@ -923,7 +923,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/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 908b173f8e7..d8c26d636b7 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, 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"; 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,437 @@ 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.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]) { + 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 () => { + 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: {} }, + ] } }]; + 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, + 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 +608,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: "mcp__opencodex__probe_echo" } }, + }); + 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..4e6328fe37c --- /dev/null +++ b/tests/responses/responses-state-store-false.test.ts @@ -0,0 +1,463 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +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"; +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"; + +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"), +}; + +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; + }); + + 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 () => { + 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: 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, + 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: [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(); + 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"); + 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: 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, 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("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"}' }, + { 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); + }); +});