From f7b5ae037d2b6fc31216685a729ab72fbae6ef3a Mon Sep 17 00:00:00 2001 From: Andrew Wang Date: Fri, 23 Oct 2026 01:36:54 -0400 Subject: [PATCH 01/13] fix(zed): relay OpenAI tool calls instead of ending on the first sentence Zed's OpenAI delegate was parsed by the compaction-only Responses parser, which kept message text and dropped function and custom calls. Codex then stored that sentence as the final answer. Promote the current turn's additional_tools catalog into public tools so a follow-up is not rejected as an unknown input variant. --- src/adapters/zed-openai-stream.ts | 340 ++++++++++++++++++++ src/adapters/zed.ts | 13 +- tests/providers/zed-hosted-provider.test.ts | 114 +++++++ 3 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 src/adapters/zed-openai-stream.ts diff --git a/src/adapters/zed-openai-stream.ts b/src/adapters/zed-openai-stream.ts new file mode 100644 index 00000000000..02f98d0dc93 --- /dev/null +++ b/src/adapters/zed-openai-stream.ts @@ -0,0 +1,340 @@ +import type { AdapterEvent, OcxMessagePhase, OcxUsage } from "../types"; +import type { TranslatorBudget } from "../lib/translator-budget"; +import { decodeServerSentEvents } from "../lib/sse-decoder"; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function toolKey(tool: Record): string | undefined { + if (typeof tool.type !== "string" || !tool.type) return undefined; + const name = typeof tool.name === "string" ? tool.name : ""; + return tool.type + "\0" + name; +} + +/** + * Zed's OpenAI Responses schema accepts message and call items, not Codex's private + * additional_tools wrapper. Drop every wrapper. Promote only wrappers at or after the + * replayed prefix: earlier ones are history, not the current turn's tool catalog. + * A non-array tools field is left untouched so a malformed catalog is not replaced + * by a partial promotion. + */ +export function liftZedAdditionalTools(providerRequest: Record, replayPrefixLength: number): void { + if (!Array.isArray(providerRequest.input)) return; + if (providerRequest.tools !== undefined && !Array.isArray(providerRequest.tools)) return; + + const currentTurnStart = Number.isFinite(replayPrefixLength) + ? Math.min(providerRequest.input.length, Math.max(0, Math.trunc(replayPrefixLength))) + : 0; + const input: unknown[] = []; + const promoted: unknown[] = []; + let changed = false; + for (const [index, item] of providerRequest.input.entries()) { + if (isRecord(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { + changed = true; + if (index >= currentTurnStart) promoted.push(...item.tools); + continue; + } + input.push(item); + } + if (!changed) return; + + const seen = new Set(); + const tools: unknown[] = []; + const existing = Array.isArray(providerRequest.tools) ? providerRequest.tools : []; + for (const tool of [...existing, ...promoted]) { + if (!isRecord(tool)) { + tools.push(tool); + continue; + } + const key = toolKey(tool); + if (key && seen.has(key)) continue; + if (key) seen.add(key); + tools.push(tool); + } + providerRequest.input = input; + providerRequest.tools = tools; +} + +interface CallState { + key: string; + callId: string; + name: string; + args: string; + pendingFull?: string; + started: boolean; + ended: boolean; +} + +function phaseOf(item: Record): OcxMessagePhase | undefined { + return item.phase === "commentary" || item.phase === "final_answer" ? item.phase : undefined; +} + +function messageText(item: Record): string { + if (!Array.isArray(item.content)) return ""; + return item.content + .filter((part): part is Record => isRecord(part) && part.type === "output_text" && typeof part.text === "string") + .map(part => String(part.text)) + .join(""); +} + +function usageFromResponse(response: unknown): OcxUsage | undefined { + if (!isRecord(response) || !isRecord(response.usage)) return undefined; + const usage = response.usage; + if (typeof usage.input_tokens !== "number" || typeof usage.output_tokens !== "number") return undefined; + const inputDetails = isRecord(usage.input_tokens_details) ? usage.input_tokens_details : undefined; + const outputDetails = isRecord(usage.output_tokens_details) ? usage.output_tokens_details : undefined; + return { + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + ...(typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}), + ...(typeof inputDetails?.cached_tokens === "number" + ? { cachedInputTokens: inputDetails.cached_tokens, cacheReadInputTokens: inputDetails.cached_tokens } + : {}), + ...(typeof outputDetails?.reasoning_tokens === "number" + ? { reasoningOutputTokens: outputDetails.reasoning_tokens } + : {}), + }; +} + +function failureMessage(payload: Record): string { + const source = isRecord(payload.response) ? payload.response : payload; + const err = source.error ?? payload.error; + if (typeof err === "string" && err) return err; + if (isRecord(err) && typeof err.message === "string" && err.message) return err.message; + if (typeof source.message === "string" && source.message) return source.message; + return "Zed OpenAI request failed"; +} + +function incompleteReason(payload: Record): string { + const source = isRecord(payload.response) ? payload.response : payload; + const details = isRecord(source.incomplete_details) ? source.incomplete_details : undefined; + if (typeof details?.reason === "string" && details.reason) return details.reason; + return failureMessage(payload); +} + +/** + * Translate Zed's OpenAI Responses SSE into adapter events. + * + * The shared Responses passthrough parser only keeps message text, because its other caller + * is compaction. Using it here drops function and custom calls, and Codex then stores the + * first sentence as final_answer and ends the turn. A later synthetic response.completed + * with empty output (Zed's stream-ended frame) must not wipe usage taken from the real + * completion, and completed output must not repeat text or calls that already streamed. + */ +export async function* translateZedOpenAiStream( + body: ReadableStream | null, + budget: TranslatorBudget, +): AsyncGenerator { + if (!body) { + yield { type: "error", message: "Zed OpenAI response had no body" }; + return; + } + + const calls = new Map(); + const phases = new Map(); + let streamedText = ""; + let sawThinking = false; + let sawRawReasoning = false; + let usage: OcxUsage | undefined; + let terminal = false; + + function* closeOpenCallsExcept(keep: CallState): Generator { + for (const other of calls.values()) { + if (other !== keep && other.started && !other.ended) { + other.ended = true; + yield { type: "tool_call_end" }; + } + } + } + + function* startIfNeeded(state: CallState): Generator { + if (state.started || state.ended || !state.name) return; + yield* closeOpenCallsExcept(state); + state.started = true; + yield { type: "tool_call_start", id: state.callId, name: state.name }; + if (state.args) yield { type: "tool_call_delta", arguments: state.args }; + } + + function* appendArgs(state: CallState, fragment: string): Generator { + if (!fragment || state.ended) return; + if (!state.started) { + if (!state.name) { + state.args += fragment; + return; + } + yield* startIfNeeded(state); + } + state.args += fragment; + yield { type: "tool_call_delta", arguments: fragment }; + } + + function rememberFull(state: CallState, full: unknown): void { + if (typeof full === "string" && full.length > 0) state.pendingFull = full; + } + + function* finishCall(state: CallState): Generator { + if (state.ended) return; + const full = state.pendingFull; + if (typeof full === "string" && full.length > state.args.length && full.startsWith(state.args)) { + yield* appendArgs(state, full.slice(state.args.length)); + } + yield* startIfNeeded(state); + if (!state.started) return; + state.ended = true; + yield { type: "tool_call_end" }; + } + + function ensureCall(key: string, callId: string, name: string): CallState { + let state = calls.get(key); + if (!state) { + state = { key, callId, name, args: "", started: false, ended: false }; + calls.set(key, state); + return state; + } + if (callId) state.callId = callId; + if (name) state.name = name; + return state; + } + + function* emitNewText(text: string, phase?: OcxMessagePhase): Generator { + if (!text) return; + if (!streamedText) { + streamedText = text; + yield { type: "text_delta", text, ...(phase ? { phase } : {}) }; + return; + } + if (text === streamedText || streamedText.endsWith(text)) return; + if (text.startsWith(streamedText)) { + const tail = text.slice(streamedText.length); + streamedText = text; + if (tail) yield { type: "text_delta", text: tail }; + return; + } + streamedText += text; + yield { type: "text_delta", text, ...(phase ? { phase } : {}) }; + } + + function* observeToolItem(item: Record, done: boolean): Generator { + if (item.type !== "function_call" && item.type !== "custom_tool_call") return; + const key = typeof item.id === "string" ? item.id : typeof item.call_id === "string" ? item.call_id : undefined; + if (!key) return; + const callId = typeof item.call_id === "string" ? item.call_id : key; + const name = typeof item.name === "string" ? item.name : ""; + const state = ensureCall(key, callId, name); + rememberFull(state, item.type === "custom_tool_call" ? item.input : item.arguments); + if (done) yield* finishCall(state); + else yield* startIfNeeded(state); + } + + function* emitReasoningItem(item: Record): Generator { + if (!sawThinking && Array.isArray(item.summary)) { + const text = item.summary + .filter((part): part is Record => isRecord(part) && typeof part.text === "string") + .map(part => String(part.text)) + .join(""); + if (text) { + sawThinking = true; + yield { type: "thinking_delta", thinking: text }; + } + } + if (!sawRawReasoning && Array.isArray(item.content)) { + const text = item.content + .filter((part): part is Record => isRecord(part) && part.type === "reasoning_text" && typeof part.text === "string") + .map(part => String(part.text)) + .join(""); + if (text) { + sawRawReasoning = true; + yield { type: "reasoning_raw_delta", text }; + } + } + } + + function* consumeItem(item: Record, done: boolean): Generator { + if (item.type === "message") { + const phase = phaseOf(item); + if (typeof item.id === "string" && phase) phases.set(item.id, phase); + if (done) yield* emitNewText(messageText(item), phase); + return; + } + if (item.type === "function_call" || item.type === "custom_tool_call") { + yield* observeToolItem(item, done); + return; + } + if (done && item.type === "reasoning") yield* emitReasoningItem(item); + } + + function* onPayload(payload: Record): Generator { + if (payload.type === "error" || payload.type === "response.failed") { + terminal = true; + yield { type: "error", message: failureMessage(payload) }; + return; + } + if (payload.type === "response.incomplete") { + terminal = true; + for (const state of calls.values()) yield* finishCall(state); + yield { type: "incomplete", reason: incompleteReason(payload) }; + return; + } + if (payload.type === "response.output_item.added" || payload.type === "response.output_item.done") { + if (isRecord(payload.item)) yield* consumeItem(payload.item, payload.type === "response.output_item.done"); + return; + } + if (payload.type === "response.output_text.delta" && typeof payload.delta === "string" && payload.delta) { + const itemId = typeof payload.item_id === "string" ? payload.item_id : undefined; + const phase = itemId ? phases.get(itemId) : undefined; + streamedText += payload.delta; + yield { type: "text_delta", text: payload.delta, ...(phase ? { phase } : {}) }; + return; + } + if (payload.type === "response.reasoning_summary_text.delta" && typeof payload.delta === "string" && payload.delta) { + sawThinking = true; + yield { type: "thinking_delta", thinking: payload.delta }; + return; + } + if (payload.type === "response.reasoning_text.delta" && typeof payload.delta === "string" && payload.delta) { + sawRawReasoning = true; + yield { type: "reasoning_raw_delta", text: payload.delta }; + return; + } + if ( + (payload.type === "response.function_call_arguments.delta" || payload.type === "response.custom_tool_call_input.delta") + && typeof payload.delta === "string" + ) { + const key = typeof payload.item_id === "string" ? payload.item_id : undefined; + if (!key) return; + yield* appendArgs(ensureCall(key, key, ""), payload.delta); + return; + } + if (payload.type === "response.function_call_arguments.done" || payload.type === "response.custom_tool_call_input.done") { + const key = typeof payload.item_id === "string" ? payload.item_id : undefined; + if (!key) return; + const state = ensureCall(key, key, ""); + rememberFull(state, payload.type === "response.custom_tool_call_input.done" ? payload.input : payload.arguments); + yield* finishCall(state); + return; + } + if (payload.type === "response.completed") { + const response = isRecord(payload.response) ? payload.response : undefined; + if (response && Array.isArray(response.output)) { + for (const item of response.output) { + if (isRecord(item)) yield* consumeItem(item, true); + } + } + const nextUsage = usageFromResponse(response); + if (nextUsage) usage = nextUsage; + } + } + + for await (const event of decodeServerSentEvents(body, { translatorBudget: budget })) { + let payload: unknown; + try { payload = JSON.parse(event.data); } catch { continue; } + if (!isRecord(payload)) continue; + yield* onPayload(payload); + if (terminal) return; + } + + for (const state of calls.values()) yield* finishCall(state); + yield { type: "done", ...(usage ? { usage } : {}) }; +} + diff --git a/src/adapters/zed.ts b/src/adapters/zed.ts index 41a2f4857ee..439d1a928c2 100644 --- a/src/adapters/zed.ts +++ b/src/adapters/zed.ts @@ -4,6 +4,7 @@ import { createAnthropicAdapter } from "./anthropic"; import { createGoogleAdapter } from "./google"; import { createOpenAIChatAdapter } from "./openai-chat"; import { createResponsesPassthroughAdapter } from "./openai-responses"; +import { liftZedAdditionalTools, translateZedOpenAiStream } from "./zed-openai-stream"; import { createAdapterPhysicalSend } from "./physical-send"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../types"; import { createTranslatorBudget, releaseTranslatedEvent, retainTranslatedEvent, type TranslatorBudget } from "../lib/translator-budget"; @@ -311,6 +312,12 @@ export function createZedAdapter(provider: OcxProviderConfig): ProviderAdapter { : item, ); } + if (zedProvider === "open_ai") { + // Codex carries this turn's client tools in a private additional_tools input item. + // Zed rejects that variant, so promote the current turn and drop every wrapper + // before the built-in tool filter runs. + liftZedAdditionalTools(providerRequest, parsed._replayPrefixLen ?? 0); + } if (zedProvider === "open_ai" && Array.isArray(providerRequest.tools)) { // Zed's Responses decoder supports function/custom tools, not Codex's built-in web_search. const supported = (tool: unknown) => isRecord(tool) && (tool.type === "function" || tool.type === "custom"); @@ -407,7 +414,11 @@ export function createZedAdapter(provider: OcxProviderConfig): ProviderAdapter { headers: { "Content-Type": "text/event-stream" }, }); try { - yield* delegate.adapter.parseStream(translated, budget); + // The Responses passthrough parser only keeps compaction text. Zed is not a + // passthrough route, so function and custom calls have to be translated or + // Codex ends the turn on the first sentence. + if (delegate.provider === "open_ai") yield* translateZedOpenAiStream(translated.body, budget); + else yield* delegate.adapter.parseStream(translated, budget); } finally { // Some delegates only unlock their reader when a collector stops early. try { await translated.body?.cancel(); } catch { /* already closed/errored */ } diff --git a/tests/providers/zed-hosted-provider.test.ts b/tests/providers/zed-hosted-provider.test.ts index 1acb4c8d276..cdeb482840b 100644 --- a/tests/providers/zed-hosted-provider.test.ts +++ b/tests/providers/zed-hosted-provider.test.ts @@ -358,6 +358,120 @@ describe("Zed OpenAI tool selection after filtering", () => { }); }); +describe("Zed OpenAI tool relay", () => { + const provider: OcxProviderConfig = { + adapter: "zed", + baseUrl: "https://cloud.zed.dev", + authMode: "oauth", + apiKey: "access-token", + }; + + function catalogFetch(): void { + const responses = [ + jsonResponse({ default_organization_id: "org-1" }), + jsonResponse({ token: "llm-token-1" }), + jsonResponse({ models: [{ id: "gpt-6.1-sol", provider: "open_ai" }] }), + ]; + globalThis.fetch = (async () => { + const response = responses.shift(); + if (!response) throw new Error("unexpected Zed catalog fetch"); + return response; + }) as typeof globalThis.fetch; + } + + test("promotes the current turn's additional_tools and drops replayed wrappers", async () => { + catalogFetch(); + const already = { type: "function", name: "already", parameters: { type: "object", properties: {} } }; + const parsed = parseRequest({ + model: "gpt-6.1-sol", + input: [ + { type: "additional_tools", tools: [{ type: "function", name: "old_tool", parameters: { type: "object", properties: {} } }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "earlier" }] }, + { type: "additional_tools", tools: [ + { type: "function", name: "shell", parameters: { type: "object", properties: {} } }, + { type: "web_search" }, + already, + ] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "now" }] }, + ], + tools: [already], + stream: true, + }); + parsed._zedAuthContext = { userId: "user-1" }; + parsed._replayPrefixLen = 2; + const adapter = withTestTranslatorBudget(createZedAdapter(provider)); + const request = await adapter.buildRequest(parsed); + const body = JSON.parse(request.body).provider_request; + expect(body.input.map((item: { type: string }) => item.type)).toEqual(["message", "message"]); + expect(JSON.stringify(body.input)).not.toContain("old_tool"); + expect(JSON.stringify(body.input)).not.toContain("additional_tools"); + expect(body.tools).toEqual([ + already, + { type: "function", name: "shell", parameters: { type: "object", properties: {} } }, + ]); + }); + + async function relay(frames: unknown[]): Promise { + catalogFetch(); + const parsed = parseRequest({ model: "gpt-6.1-sol", input: "hello", stream: true }); + parsed._zedAuthContext = { userId: "user-1" }; + const adapter = withTestTranslatorBudget(createZedAdapter(provider)); + await adapter.buildRequest(parsed); + const ndjson = frames.map(frame => JSON.stringify(frame)).join("\n") + "\n"; + const events = []; + for await (const event of adapter.parseStream(new Response(ndjson))) events.push(event); + return events; + } + + test("relays commentary text, function-call deltas, and usage across the synthetic completion", async () => { + const events = await relay([ + { event: { type: "response.output_item.added", item: { id: "msg_1", type: "message", role: "assistant", phase: "commentary", content: [] } } }, + { event: { type: "response.output_text.delta", item_id: "msg_1", delta: "Looking." } }, + { event: { type: "response.output_item.added", item: { id: "fc_1", type: "function_call", call_id: "call_1", name: "shell", arguments: "" } } }, + { event: { type: "response.function_call_arguments.delta", item_id: "fc_1", delta: "{\"cmd\":" } }, + { event: { type: "response.function_call_arguments.delta", item_id: "fc_1", delta: "\"ls\"}" } }, + { event: { type: "response.function_call_arguments.done", item_id: "fc_1", arguments: "{\"cmd\":\"ls\"}" } }, + { event: { type: "response.completed", response: { + output: [ + { type: "message", id: "msg_1", phase: "commentary", content: [{ type: "output_text", text: "Looking." }] }, + { type: "function_call", id: "fc_1", call_id: "call_1", name: "shell", arguments: "{\"cmd\":\"ls\"}" }, + ], + usage: { input_tokens: 11, output_tokens: 6 }, + } } }, + { status: "stream_ended" }, + ]); + expect(events).toEqual([ + { type: "text_delta", text: "Looking.", phase: "commentary" }, + { type: "tool_call_start", id: "call_1", name: "shell" }, + { type: "tool_call_delta", arguments: "{\"cmd\":" }, + { type: "tool_call_delta", arguments: "\"ls\"}" }, + { type: "tool_call_end" }, + { type: "done", usage: { inputTokens: 11, outputTokens: 6 } }, + ]); + }); + + test("relays a custom tool that arrives only in the completed output without repeating text", async () => { + const events = await relay([ + { event: { type: "response.output_text.delta", delta: "hi" } }, + { event: { type: "response.completed", response: { + output: [ + { type: "message", id: "msg_1", phase: "commentary", content: [{ type: "output_text", text: "hi" }] }, + { type: "custom_tool_call", id: "ctc_1", call_id: "call_c", name: "apply_patch", input: "patch body" }, + ], + usage: { input_tokens: 3, output_tokens: 2 }, + } } }, + { status: "stream_ended" }, + ]); + expect(events).toEqual([ + { type: "text_delta", text: "hi" }, + { type: "tool_call_start", id: "call_c", name: "apply_patch" }, + { type: "tool_call_delta", arguments: "patch body" }, + { type: "tool_call_end" }, + { type: "done", usage: { inputTokens: 3, outputTokens: 2 } }, + ]); + }); +}); + describe("Zed inference send accounting", () => { async function fixture(limit: number, full = false) { const catalog = [jsonResponse({ default_organization_id: "org-1" }), From 9c5e22ace281ad55c7e6d15dfa63e1e317b7056e Mon Sep 17 00:00:00 2001 From: Andrew Wang Date: Fri, 23 Oct 2026 02:13:43 -0400 Subject: [PATCH 02/13] fix(zed): dedupe completed messages and keep incomplete usage A completed replay was compared with every message concatenated, so an earlier message was appended again. Incomplete responses also dropped usage that the adapter event already forwards. --- src/adapters/zed-openai-stream.ts | 53 ++++++++++++++++----- tests/providers/zed-hosted-provider.test.ts | 52 ++++++++++++++++++++ 2 files changed, 92 insertions(+), 13 deletions(-) diff --git a/src/adapters/zed-openai-stream.ts b/src/adapters/zed-openai-stream.ts index 02f98d0dc93..c84c6b4763c 100644 --- a/src/adapters/zed-openai-stream.ts +++ b/src/adapters/zed-openai-stream.ts @@ -133,7 +133,8 @@ export async function* translateZedOpenAiStream( const calls = new Map(); const phases = new Map(); - let streamedText = ""; + const textByItem = new Map(); + let unscopedText = ""; let sawThinking = false; let sawRawReasoning = false; let usage: OcxUsage | undefined; @@ -197,21 +198,43 @@ export async function* translateZedOpenAiStream( return state; } - function* emitNewText(text: string, phase?: OcxMessagePhase): Generator { + function noteText(itemId: string | undefined, text: string): void { + if (itemId) textByItem.set(itemId, text); + else unscopedText = text; + } + + function* emitNewText(text: string, phase?: OcxMessagePhase, itemId?: string): Generator { if (!text) return; - if (!streamedText) { - streamedText = text; + // A completed response repeats every message. Deltas from different messages must not + // be concatenated before that comparison, or the earlier message matches neither the + // whole string nor its prefix and is appended a second time. + let previous = itemId !== undefined && textByItem.has(itemId) ? textByItem.get(itemId) ?? "" : ""; + if (!previous && unscopedText) { + if (text === unscopedText || text.startsWith(unscopedText)) { + previous = unscopedText; + unscopedText = ""; + } else if (unscopedText.startsWith(text)) { + unscopedText = unscopedText.slice(text.length); + noteText(itemId, text); + return; + } + } + if (!previous) { + noteText(itemId, text); yield { type: "text_delta", text, ...(phase ? { phase } : {}) }; return; } - if (text === streamedText || streamedText.endsWith(text)) return; - if (text.startsWith(streamedText)) { - const tail = text.slice(streamedText.length); - streamedText = text; - if (tail) yield { type: "text_delta", text: tail }; + if (text === previous || previous.endsWith(text)) { + noteText(itemId, previous); return; } - streamedText += text; + if (text.startsWith(previous)) { + const tail = text.slice(previous.length); + noteText(itemId, text); + if (tail) yield { type: "text_delta", text: tail, ...(phase ? { phase } : {}) }; + return; + } + noteText(itemId, previous + text); yield { type: "text_delta", text, ...(phase ? { phase } : {}) }; } @@ -254,7 +277,7 @@ export async function* translateZedOpenAiStream( if (item.type === "message") { const phase = phaseOf(item); if (typeof item.id === "string" && phase) phases.set(item.id, phase); - if (done) yield* emitNewText(messageText(item), phase); + if (done) yield* emitNewText(messageText(item), phase, typeof item.id === "string" ? item.id : undefined); return; } if (item.type === "function_call" || item.type === "custom_tool_call") { @@ -273,7 +296,10 @@ export async function* translateZedOpenAiStream( if (payload.type === "response.incomplete") { terminal = true; for (const state of calls.values()) yield* finishCall(state); - yield { type: "incomplete", reason: incompleteReason(payload) }; + const response = isRecord(payload.response) ? payload.response : undefined; + const nextUsage = usageFromResponse(response) ?? usage; + if (nextUsage) usage = nextUsage; + yield { type: "incomplete", reason: incompleteReason(payload), ...(usage ? { usage } : {}) }; return; } if (payload.type === "response.output_item.added" || payload.type === "response.output_item.done") { @@ -283,7 +309,8 @@ export async function* translateZedOpenAiStream( if (payload.type === "response.output_text.delta" && typeof payload.delta === "string" && payload.delta) { const itemId = typeof payload.item_id === "string" ? payload.item_id : undefined; const phase = itemId ? phases.get(itemId) : undefined; - streamedText += payload.delta; + if (itemId) textByItem.set(itemId, (textByItem.get(itemId) ?? "") + payload.delta); + else unscopedText += payload.delta; yield { type: "text_delta", text: payload.delta, ...(phase ? { phase } : {}) }; return; } diff --git a/tests/providers/zed-hosted-provider.test.ts b/tests/providers/zed-hosted-provider.test.ts index cdeb482840b..7ada2061b0c 100644 --- a/tests/providers/zed-hosted-provider.test.ts +++ b/tests/providers/zed-hosted-provider.test.ts @@ -470,6 +470,58 @@ describe("Zed OpenAI tool relay", () => { { type: "done", usage: { inputTokens: 3, outputTokens: 2 } }, ]); }); + + test("does not repeat an earlier message when completed replays every output item", async () => { + const events = await relay([ + { event: { type: "response.output_text.delta", item_id: "msg_1", delta: "A" } }, + { event: { type: "response.output_text.delta", item_id: "msg_2", delta: "B" } }, + { event: { type: "response.completed", response: { + output: [ + { type: "message", id: "msg_1", content: [{ type: "output_text", text: "A" }] }, + { type: "message", id: "msg_2", content: [{ type: "output_text", text: "B" }] }, + ], + usage: { input_tokens: 1, output_tokens: 2 }, + } } }, + { status: "stream_ended" }, + ]); + expect(events).toEqual([ + { type: "text_delta", text: "A" }, + { type: "text_delta", text: "B" }, + { type: "done", usage: { inputTokens: 1, outputTokens: 2 } }, + ]); + }); + + test("still emits each completed message when nothing was streamed", async () => { + const events = await relay([ + { event: { type: "response.completed", response: { + output: [ + { type: "message", id: "msg_1", content: [{ type: "output_text", text: "A" }] }, + { type: "message", id: "msg_2", content: [{ type: "output_text", text: "B" }] }, + ], + } } }, + { status: "stream_ended" }, + ]); + expect(events).toEqual([ + { type: "text_delta", text: "A" }, + { type: "text_delta", text: "B" }, + { type: "done" }, + ]); + }); + + test("keeps usage on an incomplete Zed OpenAI response", async () => { + const events = await relay([ + { event: { type: "response.output_text.delta", item_id: "msg_1", delta: "partial" } }, + { event: { type: "response.incomplete", response: { + incomplete_details: { reason: "max_output_tokens" }, + usage: { input_tokens: 4, output_tokens: 9 }, + } } }, + { status: "stream_ended" }, + ]); + expect(events).toEqual([ + { type: "text_delta", text: "partial" }, + { type: "incomplete", reason: "max_output_tokens", usage: { inputTokens: 4, outputTokens: 9 } }, + ]); + }); }); describe("Zed inference send accounting", () => { From e2f87f6a2cbb8a1ec995cc0edf5679835c5ba6fc Mon Sep 17 00:00:00 2001 From: Andrew Wang Date: Sat, 24 Oct 2026 03:52:32 -0400 Subject: [PATCH 03/13] fix(zed): keep interleaved tool calls and promote current-turn tools Adapter tool deltas are unkeyed, so starting a second streamed call ended the first one and dropped every later delta plus its full arguments. That handed Codex truncated JSON. Only the oldest open call is emitted now; newer calls stay buffered and replay in order after the older call's own terminal event. EOF and response.incomplete flush arguments that arrived, without inventing a tail from a full field that no done item confirmed. Zed configurations with statelessResponses or paired tool results run Responses repair on the delegate body, which can drop a replayed reasoning item and slide the current-turn additional_tools wrapper under the original prefix length. Promote those wrappers on a detached copy of the raw body before the delegate builds, so the caller's request stays untouched and the post-build lift is a no-op. The [OI] Zed stream also lost the compaction ciphertext the old parser carried. Keep it on the done event with the same retained lease, and release it when the terminal event is never yielded. --- src/adapters/zed-openai-stream.ts | 146 ++++++++++++++------ src/adapters/zed.ts | 24 +++- structure/providers-and-adapters.md | 2 +- tests/providers/zed-hosted-provider.test.ts | 126 +++++++++++++++++ 4 files changed, 251 insertions(+), 47 deletions(-) diff --git a/src/adapters/zed-openai-stream.ts b/src/adapters/zed-openai-stream.ts index c84c6b4763c..67b30363791 100644 --- a/src/adapters/zed-openai-stream.ts +++ b/src/adapters/zed-openai-stream.ts @@ -1,6 +1,7 @@ import type { AdapterEvent, OcxMessagePhase, OcxUsage } from "../types"; import type { TranslatorBudget } from "../lib/translator-budget"; import { decodeServerSentEvents } from "../lib/sse-decoder"; +import { transferCompactionCiphertextLease } from "../responses/compaction"; function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); @@ -62,8 +63,12 @@ interface CallState { name: string; args: string; pendingFull?: string; + emitted: number; started: boolean; ended: boolean; + closed: boolean; + /** True only after this call's own done or completed item, not after EOF. */ + allowFull: boolean; } function phaseOf(item: Record): OcxMessagePhase | undefined { @@ -121,6 +126,11 @@ function incompleteReason(payload: Record): string { * first sentence as final_answer and ends the turn. A later synthetic response.completed * with empty output (Zed's stream-ended frame) must not wipe usage taken from the real * completion, and completed output must not repeat text or calls that already streamed. + * Adapter tool deltas are unkeyed, so only the oldest open call is emitted. A newer call + * keeps every later delta until the older call's own terminal event, then replays in order. + * EOF and response.incomplete flush arguments that already arrived; they do not copy a full + * arguments field that never came from that call's done or completed item. A compaction + * ciphertext is carried on done, with the same retained lease as the Responses parser. */ export async function* translateZedOpenAiStream( body: ReadableStream | null, @@ -139,57 +149,91 @@ export async function* translateZedOpenAiStream( let sawRawReasoning = false; let usage: OcxUsage | undefined; let terminal = false; + let compactionEncryptedContent: string | undefined; + let compactionEncryptedContentBytes = 0; + let ciphertextTransferred = false; - function* closeOpenCallsExcept(keep: CallState): Generator { - for (const other of calls.values()) { - if (other !== keep && other.started && !other.ended) { - other.ended = true; - yield { type: "tool_call_end" }; - } - } + function argumentText(state: CallState): string { + const full = state.pendingFull; + if (state.allowFull && typeof full === "string" && full.startsWith(state.args)) return full; + return state.args; } - function* startIfNeeded(state: CallState): Generator { - if (state.started || state.ended || !state.name) return; - yield* closeOpenCallsExcept(state); - state.started = true; - yield { type: "tool_call_start", id: state.callId, name: state.name }; - if (state.args) yield { type: "tool_call_delta", arguments: state.args }; + /** Emit the oldest open call. Newer calls stay buffered until that call closes. */ + function* pump(): Generator { + while (true) { + const next = [...calls.values()].find(state => !state.ended); + if (!next) return; + if (!next.name) { + if (!next.closed) return; + next.ended = true; + continue; + } + if (!next.started) { + next.started = true; + yield { type: "tool_call_start", id: next.callId, name: next.name }; + } + const text = argumentText(next); + if (text.length > next.emitted) { + const delta = text.slice(next.emitted); + next.emitted = text.length; + yield { type: "tool_call_delta", arguments: delta }; + } + if (!next.closed) return; + next.ended = true; + yield { type: "tool_call_end" }; + } } function* appendArgs(state: CallState, fragment: string): Generator { if (!fragment || state.ended) return; - if (!state.started) { - if (!state.name) { - state.args += fragment; - return; - } - yield* startIfNeeded(state); - } state.args += fragment; - yield { type: "tool_call_delta", arguments: fragment }; + yield* pump(); } function rememberFull(state: CallState, full: unknown): void { if (typeof full === "string" && full.length > 0) state.pendingFull = full; } - function* finishCall(state: CallState): Generator { + function* closeCall(state: CallState, allowFull: boolean): Generator { if (state.ended) return; - const full = state.pendingFull; - if (typeof full === "string" && full.length > state.args.length && full.startsWith(state.args)) { - yield* appendArgs(state, full.slice(state.args.length)); + state.closed = true; + if (allowFull) state.allowFull = true; + yield* pump(); + } + + function* flushUnclosed(allowFull: boolean): Generator { + for (const state of calls.values()) { + if (state.ended || state.closed) continue; + state.closed = true; + if (allowFull) state.allowFull = true; } - yield* startIfNeeded(state); - if (!state.started) return; - state.ended = true; - yield { type: "tool_call_end" }; + yield* pump(); + } + + function retainCompaction(response: unknown): void { + if (!isRecord(response) || !Array.isArray(response.output)) return; + const compaction = response.output.find(item => + isRecord(item) && item.type === "compaction" && typeof item.encrypted_content === "string"); + if (!isRecord(compaction) || typeof compaction.encrypted_content !== "string") return; + const next = compaction.encrypted_content; + if (next === compactionEncryptedContent) return; + const nextBytes = Buffer.byteLength(next, "utf8"); + const reservation = budget.reserveTransient(nextBytes, { kind: "retained_collectors" }); + compactionEncryptedContent = next; + reservation.commitRetained(); + budget.releaseRetained(compactionEncryptedContentBytes, { kind: "retained_collectors" }); + compactionEncryptedContentBytes = nextBytes; } function ensureCall(key: string, callId: string, name: string): CallState { let state = calls.get(key); if (!state) { - state = { key, callId, name, args: "", started: false, ended: false }; + state = { + // A delta names only the item. Keep the call id the added item already carried instead of + // replacing it with the item id, which would mislabel a deferred start. + key, callId: callId || key, name, args: "", emitted: 0, started: false, ended: false, closed: false, allowFull: false, + }; calls.set(key, state); return state; } @@ -246,8 +290,8 @@ export async function* translateZedOpenAiStream( const name = typeof item.name === "string" ? item.name : ""; const state = ensureCall(key, callId, name); rememberFull(state, item.type === "custom_tool_call" ? item.input : item.arguments); - if (done) yield* finishCall(state); - else yield* startIfNeeded(state); + if (done) yield* closeCall(state, true); + else yield* pump(); } function* emitReasoningItem(item: Record): Generator { @@ -295,7 +339,7 @@ export async function* translateZedOpenAiStream( } if (payload.type === "response.incomplete") { terminal = true; - for (const state of calls.values()) yield* finishCall(state); + yield* flushUnclosed(false); const response = isRecord(payload.response) ? payload.response : undefined; const nextUsage = usageFromResponse(response) ?? usage; if (nextUsage) usage = nextUsage; @@ -330,19 +374,20 @@ export async function* translateZedOpenAiStream( ) { const key = typeof payload.item_id === "string" ? payload.item_id : undefined; if (!key) return; - yield* appendArgs(ensureCall(key, key, ""), payload.delta); + yield* appendArgs(ensureCall(key, "", ""), payload.delta); return; } if (payload.type === "response.function_call_arguments.done" || payload.type === "response.custom_tool_call_input.done") { const key = typeof payload.item_id === "string" ? payload.item_id : undefined; if (!key) return; - const state = ensureCall(key, key, ""); + const state = ensureCall(key, "", ""); rememberFull(state, payload.type === "response.custom_tool_call_input.done" ? payload.input : payload.arguments); - yield* finishCall(state); + yield* closeCall(state, true); return; } if (payload.type === "response.completed") { const response = isRecord(payload.response) ? payload.response : undefined; + retainCompaction(response); if (response && Array.isArray(response.output)) { for (const item of response.output) { if (isRecord(item)) yield* consumeItem(item, true); @@ -353,15 +398,26 @@ export async function* translateZedOpenAiStream( } } - for await (const event of decodeServerSentEvents(body, { translatorBudget: budget })) { - let payload: unknown; - try { payload = JSON.parse(event.data); } catch { continue; } - if (!isRecord(payload)) continue; - yield* onPayload(payload); - if (terminal) return; - } + try { + for await (const event of decodeServerSentEvents(body, { translatorBudget: budget })) { + let payload: unknown; + try { payload = JSON.parse(event.data); } catch { continue; } + if (!isRecord(payload)) continue; + yield* onPayload(payload); + if (terminal) return; + } - for (const state of calls.values()) yield* finishCall(state); - yield { type: "done", ...(usage ? { usage } : {}) }; + yield* flushUnclosed(false); + const done: AdapterEvent = { + type: "done", + ...(usage ? { usage } : {}), + ...(compactionEncryptedContent ? { compactionEncryptedContent } : {}), + }; + transferCompactionCiphertextLease(done, budget, compactionEncryptedContentBytes); + ciphertextTransferred = true; + yield done; + } finally { + if (!ciphertextTransferred) budget.releaseRetained(compactionEncryptedContentBytes, { kind: "retained_collectors" }); + } } diff --git a/src/adapters/zed.ts b/src/adapters/zed.ts index 322e504f76a..5add17cc064 100644 --- a/src/adapters/zed.ts +++ b/src/adapters/zed.ts @@ -67,6 +67,27 @@ function forceStreaming(parsed: OcxParsedRequest): OcxParsedRequest { return { ...parsed, stream: true, _rawBody: rawBody }; } +/** + * Promote the current turn's additional_tools before the Responses delegate repairs input. + * Repair can drop a replayed reasoning item and slide the wrapper under the original prefix + * length, which then hides the current catalog. The caller's raw body stays untouched. + */ +function promoteOpenAiToolsBeforeRepair(parsed: OcxParsedRequest): OcxParsedRequest { + if (!isRecord(parsed._rawBody)) return forceStreaming(parsed); + const raw = parsed._rawBody; + const copy: Record = { ...raw, stream: true }; + if (Array.isArray(raw.input)) { + copy.input = raw.input.map(item => ( + isRecord(item) && item.type === "additional_tools" && Array.isArray(item.tools) + ? { ...item, tools: [...item.tools] } + : item + )); + } + if (Array.isArray(raw.tools)) copy.tools = [...raw.tools]; + liftZedAdditionalTools(copy, parsed._replayPrefixLen ?? 0); + return { ...parsed, stream: true, _rawBody: copy }; +} + function providerFromCatalog(catalog: Awaited> | undefined, model: string): ZedProvider { const raw = catalog?.rawById.get(model); return normalizeZedProvider(raw?.provider, model); @@ -257,7 +278,8 @@ export function createZedAdapter(provider: OcxProviderConfig, cacheRetention?: A ...(incoming?.providerFetch ? { providerFetch: incoming.providerFetch } : {}), ...(incoming?.abortSignal ? { abortSignal: incoming.abortSignal } : {}), }; - const built = await selected.buildRequest(forceStreaming(parsed), safeIncoming); + const delegateParsed = zedProvider === "open_ai" ? promoteOpenAiToolsBeforeRepair(parsed) : forceStreaming(parsed); + const built = await selected.buildRequest(delegateParsed, safeIncoming); let providerRequest: unknown; try { providerRequest = JSON.parse(built.body) as unknown; } catch { throw new Error("Zed delegate produced an invalid request body"); } if (zedProvider === "google" && isRecord(providerRequest)) delete providerRequest.safetySettings; diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index a81d0790042..c9898a7d0d8 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -208,7 +208,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge; optional `minMaxOutputTokens` raises only smaller explicit positive integer output budgets. Unset preserves caller caps. Serialization and pre-admission spend estimates share `effectiveAzureOutputBudget`; see the [spend contract](transports/responses-spend.md#durable-spend-reservations). | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | -| `src/adapters/zed.ts`, `src/providers/zed.ts`, `src/oauth/zed.ts` | Zed Hosted AI native-app RSA login, account-scoped short-lived LLM-token exchange, bounded live model discovery, and provider-envelope translation through the existing Anthropic, Google, Responses, and Chat builders. Buffered delivery retains events under the shared translator budget and cancels delegated streams on early exit. The Responses delegate applies destination compatibility, strips OpenAI-private fields and unsupported built-in tools, reconciles removed tool choices through the shared hosted-tool policy, and maps OpenAI `developer` messages to Zed-supported `system` messages before wrapping the request. Experimental and unofficial; the Zed terms risk is intentionally surfaced in user docs and release review. | +| `src/adapters/zed.ts`, `src/providers/zed.ts`, `src/oauth/zed.ts` | Zed Hosted AI native-app RSA login, account-scoped short-lived LLM-token exchange, bounded live model discovery, and provider-envelope translation through the existing Anthropic, Google, Responses, and Chat builders. Buffered delivery retains events under the shared translator budget and cancels delegated streams on early exit. The Responses delegate applies destination compatibility, strips OpenAI-private fields and unsupported built-in tools, reconciles removed tool choices through the shared hosted-tool policy, and maps OpenAI `developer` messages to Zed-supported `system` messages before wrapping the request. `src/adapters/zed-openai-stream.ts` translates the OpenAI stream: the current turn's `additional_tools` are promoted on a detached copy before replay repair, interleaved function and custom calls stay buffered until the oldest open call's own terminal event because adapter deltas are unkeyed, and cancellation or an incomplete response flushes only arguments that already arrived. A completed compaction item keeps its ciphertext on the done event and transfers that retained lease. Experimental and unofficial; the Zed terms risk is intentionally surfaced in user docs and release review. | | `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort anchors on the family's default member and selects the nearest enabled rung, rounding up first; an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays expressed as type-only `anyOf` branches in tool parameters. Shared constraints remain on the surrounding node, so nested schemas have one wire representation while outer enum/const, boolean constraints (including `not`, `oneOf`, and `allOf`) on null, and existing null-branch restrictions retain their conjunctive meaning. A null-only type keeps its null constraint even when an outer enum/const excludes null, preserving an unsatisfiable schema without producing an empty anyOf. Schema resource identifiers, anchors, definition maps, and existing anyOf branches remain on their original node. The type constraint is appended to existing allOf entries without shifting their indices, so JSON Pointer targets and reference scopes remain unchanged. A pre-output `invalid_argument` on a history whose word-piece estimate, using the sanitized and truncated tool descriptions actually sent on the wire, reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. The `COGNITION_BLOCKLIST_REWRITES` sanitizer rewrites exact phrases Cognition refuses with `permission_denied`; it runs only on instruction surfaces — tool descriptions and the #2 system prompt — while message text, replayed thinking (#11), and tool-call arguments stay byte-exact, because a rewrite there would silently change literal content such as patches or quoted file bytes; a trigger inside a data field surfaces as the upstream `permission_denied` instead (the Codex `` escalation boilerplate is the recorded #2 case). | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. Its registry entry is `deprecated`: existing saved providers keep the adapter and their stored configuration, while new-provider discovery (presets, the init menu, the featured list, and key logins) excludes it and `provider add` warns before creating one. | diff --git a/tests/providers/zed-hosted-provider.test.ts b/tests/providers/zed-hosted-provider.test.ts index d6d67a740c7..375488bd220 100644 --- a/tests/providers/zed-hosted-provider.test.ts +++ b/tests/providers/zed-hosted-provider.test.ts @@ -4,6 +4,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { constants, createHash, createPublicKey, publicEncrypt } from "node:crypto"; import { createZedAdapter, zedEventStream, type ZedProvider } from "../../src/adapters/zed"; +import { translateZedOpenAiStream } from "../../src/adapters/zed-openai-stream"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; import { setIcaclsRunnerForTests, resetHardenedStateForTests } from "../../src/lib/windows-secret-acl"; import { getValidAccessTokenSnapshot } from "../../src/oauth"; import { saveCredential } from "../../src/oauth/store"; @@ -415,6 +417,34 @@ describe("Zed OpenAI tool relay", () => { ]); }); + test("promotes the current turn when replay repair drops a reasoning item", async () => { + catalogFetch(); + const shell = { type: "function", name: "shell", parameters: { type: "object", properties: {} } }; + const parsed = parseRequest({ + model: "gpt-6.1-sol", + previous_response_id: "resp_replay", + input: [ + { type: "reasoning", id: "rs_1", summary: [] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "earlier" }] }, + { type: "additional_tools", tools: [shell] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "now" }] }, + ], + stream: true, + }); + parsed._zedAuthContext = { userId: "user-1" }; + parsed._replayPrefixLen = 2; + const rawBefore = JSON.stringify(parsed._rawBody); + const adapter = withTestTranslatorBudget(createZedAdapter({ ...provider, statelessResponses: true })); + const request = await adapter.buildRequest(parsed); + const body = JSON.parse(request.body).provider_request; + expect(body.tools).toEqual([shell]); + expect(JSON.stringify(body.input)).not.toContain("additional_tools"); + expect(JSON.stringify(body.input)).not.toContain("reasoning"); + expect(JSON.stringify(parsed._rawBody)).toBe(rawBefore); + expect(rawBefore).toContain("additional_tools"); + expect(rawBefore).toContain("reasoning"); + }); + async function relay(frames: unknown[]): Promise { catalogFetch(); const parsed = parseRequest({ model: "gpt-6.1-sol", input: "hello", stream: true }); @@ -526,6 +556,102 @@ describe("Zed OpenAI tool relay", () => { { type: "incomplete", reason: "max_output_tokens", usage: { inputTokens: 4, outputTokens: 9 } }, ]); }); + + test("keeps interleaved function-call deltas until each call closes", async () => { + const events = await relay([ + { event: { type: "response.output_item.added", item: { id: "call_a", type: "function_call", call_id: "call_a", name: "a", arguments: "" } } }, + { event: { type: "response.function_call_arguments.delta", item_id: "call_a", delta: "{\"x\":" } }, + { event: { type: "response.output_item.added", item: { id: "call_b", type: "function_call", call_id: "call_b", name: "b", arguments: "" } } }, + { event: { type: "response.function_call_arguments.delta", item_id: "call_b", delta: "{\"y\":2}" } }, + { event: { type: "response.function_call_arguments.done", item_id: "call_b", arguments: "{\"y\":2}" } }, + { event: { type: "response.function_call_arguments.delta", item_id: "call_a", delta: "1}" } }, + { event: { type: "response.function_call_arguments.done", item_id: "call_a", arguments: "{\"x\":1}" } }, + { event: { type: "response.completed", response: { output: [ + { type: "function_call", id: "call_a", call_id: "call_a", name: "a", arguments: "{\"x\":1}" }, + { type: "function_call", id: "call_b", call_id: "call_b", name: "b", arguments: "{\"y\":2}" }, + ] } } }, + ]); + expect(events).toEqual([ + { type: "tool_call_start", id: "call_a", name: "a" }, + { type: "tool_call_delta", arguments: "{\"x\":" }, + { type: "tool_call_delta", arguments: "1}" }, + { type: "tool_call_end" }, + { type: "tool_call_start", id: "call_b", name: "b" }, + { type: "tool_call_delta", arguments: "{\"y\":2}" }, + { type: "tool_call_end" }, + { type: "done" }, + ]); + }); + + test("keeps interleaved custom-tool input until each call closes", async () => { + const events = await relay([ + { event: { type: "response.output_item.added", item: { id: "ctc_a", type: "custom_tool_call", call_id: "call_a", name: "apply_patch", input: "" } } }, + { event: { type: "response.custom_tool_call_input.delta", item_id: "ctc_a", delta: "left" } }, + { event: { type: "response.output_item.added", item: { id: "ctc_b", type: "custom_tool_call", call_id: "call_b", name: "apply_patch", input: "" } } }, + { event: { type: "response.custom_tool_call_input.delta", item_id: "ctc_b", delta: "right" } }, + { event: { type: "response.custom_tool_call_input.done", item_id: "ctc_b", input: "right" } }, + { event: { type: "response.custom_tool_call_input.delta", item_id: "ctc_a", delta: " side" } }, + { event: { type: "response.custom_tool_call_input.done", item_id: "ctc_a", input: "left side" } }, + { event: { type: "response.completed", response: { output: [] } } }, + ]); + expect(events).toEqual([ + { type: "tool_call_start", id: "call_a", name: "apply_patch" }, + { type: "tool_call_delta", arguments: "left" }, + { type: "tool_call_delta", arguments: " side" }, + { type: "tool_call_end" }, + { type: "tool_call_start", id: "call_b", name: "apply_patch" }, + { type: "tool_call_delta", arguments: "right" }, + { type: "tool_call_end" }, + { type: "done" }, + ]); + }); + + test("an incomplete response does not invent tool arguments from a later full item", async () => { + const events = await relay([ + { event: { type: "response.output_item.added", item: { id: "call_a", type: "function_call", call_id: "call_a", name: "a", arguments: "{\"x\":1}" } } }, + { event: { type: "response.function_call_arguments.delta", item_id: "call_a", delta: "{\"x\":" } }, + { event: { type: "response.output_item.added", item: { id: "call_b", type: "function_call", call_id: "call_b", name: "b", arguments: "" } } }, + { event: { type: "response.function_call_arguments.delta", item_id: "call_b", delta: "{\"y\":" } }, + { event: { type: "response.function_call_arguments.done", item_id: "call_b", arguments: "{\"y\":2}" } }, + { event: { type: "response.incomplete", response: { + incomplete_details: { reason: "max_output_tokens" }, + output: [ + { type: "function_call", id: "call_a", call_id: "call_a", name: "a", arguments: "{\"x\":1}" }, + { type: "function_call", id: "call_b", call_id: "call_b", name: "b", arguments: "{\"y\":2}" }, + ], + } } }, + ]); + expect(events).toEqual([ + { type: "tool_call_start", id: "call_a", name: "a" }, + { type: "tool_call_delta", arguments: "{\"x\":" }, + { type: "tool_call_end" }, + { type: "tool_call_start", id: "call_b", name: "b" }, + { type: "tool_call_delta", arguments: "{\"y\":2}" }, + { type: "tool_call_end" }, + { type: "incomplete", reason: "max_output_tokens" }, + ]); + }); + + test("EOF flushes received arguments and ignores a full arguments field that never completed", async () => { + const budget = createTranslatorBudget(); + const frames = [ + { type: "response.output_item.added", item: { id: "fc_a", type: "function_call", call_id: "call_a", name: "a", arguments: "{\"x\":1}" } }, + { type: "response.function_call_arguments.delta", item_id: "fc_a", delta: "{\"x\":" }, + ]; + const sse = frames.map(event => "data: " + JSON.stringify(event) + "\n\n").join(""); + const events = []; + try { + for await (const event of translateZedOpenAiStream(new Response(sse).body, budget)) events.push(event); + } finally { + budget.dispose(); + } + expect(events).toEqual([ + { type: "tool_call_start", id: "call_a", name: "a" }, + { type: "tool_call_delta", arguments: "{\"x\":" }, + { type: "tool_call_end" }, + { type: "done" }, + ]); + }); }); describe("Zed inference send accounting", () => { From 585f5cde3859c0e53df9f73f38a3386a9bd47d1b Mon Sep 17 00:00:00 2001 From: Andrew Wang Date: Sat, 24 Oct 2026 04:27:26 -0400 Subject: [PATCH 04/13] fix(zed): charge retained stream buffers and correct the stream doc Tool arguments, the optional full arguments field, and the text kept to suppress a completed replay grew outside TranslatorBudget. A long or interleaved turn could hold far more than the per-turn ceiling. Each call now opens a budget call when it is first seen, every growth is charged, and closing the call releases exactly what it still holds. Text is charged per item and per unscoped stream, with superseded bytes released on every replacement. A finally block releases the remaining calls, text and ciphertext when the consumer cancels or a read throws. The structure row said cancellation flushes buffered arguments. The code flushes on end of stream and response.incomplete only, so the contract now says that. --- src/adapters/zed-openai-stream.ts | 109 +++++++++++++-- structure/providers-and-adapters.md | 2 +- tests/providers/zed-hosted-provider.test.ts | 143 ++++++++++++++++++++ 3 files changed, 245 insertions(+), 9 deletions(-) diff --git a/src/adapters/zed-openai-stream.ts b/src/adapters/zed-openai-stream.ts index 67b30363791..05171492299 100644 --- a/src/adapters/zed-openai-stream.ts +++ b/src/adapters/zed-openai-stream.ts @@ -63,6 +63,9 @@ interface CallState { name: string; args: string; pendingFull?: string; + argsBytes: number; + fullBytes: number; + lastCodeUnit: number; emitted: number; started: boolean; ended: boolean; @@ -71,6 +74,14 @@ interface CallState { allowFull: boolean; } +/** UTF-8 bytes a fragment adds to an existing string, without re-measuring the whole buffer. */ +function appendedUtf8Bytes(previousBytes: number, lastCodeUnit: number, fragment: string): number { + if (!fragment) return previousBytes; + const first = fragment.charCodeAt(0); + const joinsPair = lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff && first >= 0xdc00 && first <= 0xdfff; + return previousBytes + Buffer.byteLength(fragment, "utf8") - (joinsPair ? 2 : 0); +} + function phaseOf(item: Record): OcxMessagePhase | undefined { return item.phase === "commentary" || item.phase === "final_answer" ? item.phase : undefined; } @@ -144,7 +155,9 @@ export async function* translateZedOpenAiStream( const calls = new Map(); const phases = new Map(); const textByItem = new Map(); + const textBytesByItem = new Map(); let unscopedText = ""; + let unscopedTextBytes = 0; let sawThinking = false; let sawRawReasoning = false; let usage: OcxUsage | undefined; @@ -167,6 +180,7 @@ export async function* translateZedOpenAiStream( if (!next.name) { if (!next.closed) return; next.ended = true; + releaseCall(next); continue; } if (!next.started) { @@ -182,17 +196,62 @@ export async function* translateZedOpenAiStream( if (!next.closed) return; next.ended = true; yield { type: "tool_call_end" }; + releaseCall(next); } } function* appendArgs(state: CallState, fragment: string): Generator { if (!fragment || state.ended) return; + const nextBytes = appendedUtf8Bytes(state.argsBytes, state.lastCodeUnit, fragment); + budget.chargeRetained(nextBytes - state.argsBytes, { kind: "tool_args", callId: state.key }); state.args += fragment; + state.argsBytes = nextBytes; + state.lastCodeUnit = fragment.charCodeAt(fragment.length - 1); yield* pump(); } function rememberFull(state: CallState, full: unknown): void { - if (typeof full === "string" && full.length > 0) state.pendingFull = full; + // A closed call's buffers were already released; charging again would outlive their owner. + if (state.ended || typeof full !== "string" || full.length === 0 || full === state.pendingFull) return; + const nextBytes = Buffer.byteLength(full, "utf8"); + if (nextBytes > state.fullBytes) { + budget.chargeRetained(nextBytes - state.fullBytes, { kind: "tool_args", callId: state.key }); + } else if (nextBytes < state.fullBytes) { + budget.releaseRetained(state.fullBytes - nextBytes, { kind: "tool_args", callId: state.key }); + } + state.pendingFull = full; + state.fullBytes = nextBytes; + } + + /** + * A finished call's buffers are dead. Closing the budget call releases exactly the bytes this + * call still holds, including the part already emitted, so a turn cannot keep growing. + */ + function releaseCall(state: CallState): void { + budget.closeCall(state.key); + state.args = ""; + state.argsBytes = 0; + state.pendingFull = undefined; + state.fullBytes = 0; + state.emitted = 0; + state.lastCodeUnit = 0; + } + + /** + * Release everything this generator still holds. A cancelled consumer or a thrown read never + * reaches the flush path, so the buffers must not outlive the request. + */ + function releaseStreamBuffers(): void { + for (const state of calls.values()) budget.closeCall(state.key); + calls.clear(); + for (const bytes of textBytesByItem.values()) { + budget.releaseRetained(bytes, { kind: "retained_collectors" }); + } + textBytesByItem.clear(); + textByItem.clear(); + budget.releaseRetained(unscopedTextBytes, { kind: "retained_collectors" }); + unscopedTextBytes = 0; + unscopedText = ""; } function* closeCall(state: CallState, allowFull: boolean): Generator { @@ -232,9 +291,21 @@ export async function* translateZedOpenAiStream( state = { // A delta names only the item. Keep the call id the added item already carried instead of // replacing it with the item id, which would mislabel a deferred start. - key, callId: callId || key, name, args: "", emitted: 0, started: false, ended: false, closed: false, allowFull: false, + key, + callId: callId || key, + name, + args: "", + argsBytes: 0, + fullBytes: 0, + lastCodeUnit: 0, + emitted: 0, + started: false, + ended: false, + closed: false, + allowFull: false, }; calls.set(key, state); + budget.openCall(state.key); return state; } if (callId) state.callId = callId; @@ -242,9 +313,25 @@ export async function* translateZedOpenAiStream( return state; } + /** + * Text is kept only to suppress a completed replay. Every replacement is charged to the + * request budget and the superseded bytes released, so a long turn cannot accumulate text + * outside the per-turn ceiling. + */ function noteText(itemId: string | undefined, text: string): void { - if (itemId) textByItem.set(itemId, text); - else unscopedText = text; + const nextBytes = Buffer.byteLength(text, "utf8"); + if (itemId) { + const previousBytes = textBytesByItem.get(itemId) ?? 0; + if (nextBytes > previousBytes) budget.chargeRetained(nextBytes - previousBytes, { kind: "retained_collectors" }); + else if (nextBytes < previousBytes) budget.releaseRetained(previousBytes - nextBytes, { kind: "retained_collectors" }); + textByItem.set(itemId, text); + textBytesByItem.set(itemId, nextBytes); + return; + } + if (nextBytes > unscopedTextBytes) budget.chargeRetained(nextBytes - unscopedTextBytes, { kind: "retained_collectors" }); + else if (nextBytes < unscopedTextBytes) budget.releaseRetained(unscopedTextBytes - nextBytes, { kind: "retained_collectors" }); + unscopedText = text; + unscopedTextBytes = nextBytes; } function* emitNewText(text: string, phase?: OcxMessagePhase, itemId?: string): Generator { @@ -256,9 +343,9 @@ export async function* translateZedOpenAiStream( if (!previous && unscopedText) { if (text === unscopedText || text.startsWith(unscopedText)) { previous = unscopedText; - unscopedText = ""; + noteText(undefined, ""); } else if (unscopedText.startsWith(text)) { - unscopedText = unscopedText.slice(text.length); + noteText(undefined, unscopedText.slice(text.length)); noteText(itemId, text); return; } @@ -282,6 +369,12 @@ export async function* translateZedOpenAiStream( yield { type: "text_delta", text, ...(phase ? { phase } : {}) }; } + /** Streamed deltas accumulate per item; the charge follows the accumulated size. */ + function appendText(itemId: string | undefined, delta: string): void { + if (itemId) noteText(itemId, (textByItem.get(itemId) ?? "") + delta); + else noteText(undefined, unscopedText + delta); + } + function* observeToolItem(item: Record, done: boolean): Generator { if (item.type !== "function_call" && item.type !== "custom_tool_call") return; const key = typeof item.id === "string" ? item.id : typeof item.call_id === "string" ? item.call_id : undefined; @@ -353,8 +446,7 @@ export async function* translateZedOpenAiStream( if (payload.type === "response.output_text.delta" && typeof payload.delta === "string" && payload.delta) { const itemId = typeof payload.item_id === "string" ? payload.item_id : undefined; const phase = itemId ? phases.get(itemId) : undefined; - if (itemId) textByItem.set(itemId, (textByItem.get(itemId) ?? "") + payload.delta); - else unscopedText += payload.delta; + appendText(itemId, payload.delta); yield { type: "text_delta", text: payload.delta, ...(phase ? { phase } : {}) }; return; } @@ -418,6 +510,7 @@ export async function* translateZedOpenAiStream( yield done; } finally { if (!ciphertextTransferred) budget.releaseRetained(compactionEncryptedContentBytes, { kind: "retained_collectors" }); + releaseStreamBuffers(); } } diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index c9898a7d0d8..81334cc8c9e 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -208,7 +208,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge; optional `minMaxOutputTokens` raises only smaller explicit positive integer output budgets. Unset preserves caller caps. Serialization and pre-admission spend estimates share `effectiveAzureOutputBudget`; see the [spend contract](transports/responses-spend.md#durable-spend-reservations). | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | -| `src/adapters/zed.ts`, `src/providers/zed.ts`, `src/oauth/zed.ts` | Zed Hosted AI native-app RSA login, account-scoped short-lived LLM-token exchange, bounded live model discovery, and provider-envelope translation through the existing Anthropic, Google, Responses, and Chat builders. Buffered delivery retains events under the shared translator budget and cancels delegated streams on early exit. The Responses delegate applies destination compatibility, strips OpenAI-private fields and unsupported built-in tools, reconciles removed tool choices through the shared hosted-tool policy, and maps OpenAI `developer` messages to Zed-supported `system` messages before wrapping the request. `src/adapters/zed-openai-stream.ts` translates the OpenAI stream: the current turn's `additional_tools` are promoted on a detached copy before replay repair, interleaved function and custom calls stay buffered until the oldest open call's own terminal event because adapter deltas are unkeyed, and cancellation or an incomplete response flushes only arguments that already arrived. A completed compaction item keeps its ciphertext on the done event and transfers that retained lease. Experimental and unofficial; the Zed terms risk is intentionally surfaced in user docs and release review. | +| `src/adapters/zed.ts`, `src/providers/zed.ts`, `src/oauth/zed.ts` | Zed Hosted AI native-app RSA login, account-scoped short-lived LLM-token exchange, bounded live model discovery, and provider-envelope translation through the existing Anthropic, Google, Responses, and Chat builders. Buffered delivery retains events under the shared translator budget and cancels delegated streams on early exit. The Responses delegate applies destination compatibility, strips OpenAI-private fields and unsupported built-in tools, reconciles removed tool choices through the shared hosted-tool policy, and maps OpenAI `developer` messages to Zed-supported `system` messages before wrapping the request. `src/adapters/zed-openai-stream.ts` translates the OpenAI stream: the current turn's `additional_tools` are promoted on a detached copy before replay repair, interleaved function and custom calls stay buffered until the oldest open call's own terminal event because adapter deltas are unkeyed, and end of stream or an incomplete response flushes only arguments that already arrived. A completed compaction item keeps its ciphertext on the done event and transfers that retained lease. Experimental and unofficial; the Zed terms risk is intentionally surfaced in user docs and release review. | | `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort anchors on the family's default member and selects the nearest enabled rung, rounding up first; an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays expressed as type-only `anyOf` branches in tool parameters. Shared constraints remain on the surrounding node, so nested schemas have one wire representation while outer enum/const, boolean constraints (including `not`, `oneOf`, and `allOf`) on null, and existing null-branch restrictions retain their conjunctive meaning. A null-only type keeps its null constraint even when an outer enum/const excludes null, preserving an unsatisfiable schema without producing an empty anyOf. Schema resource identifiers, anchors, definition maps, and existing anyOf branches remain on their original node. The type constraint is appended to existing allOf entries without shifting their indices, so JSON Pointer targets and reference scopes remain unchanged. A pre-output `invalid_argument` on a history whose word-piece estimate, using the sanitized and truncated tool descriptions actually sent on the wire, reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. The `COGNITION_BLOCKLIST_REWRITES` sanitizer rewrites exact phrases Cognition refuses with `permission_denied`; it runs only on instruction surfaces — tool descriptions and the #2 system prompt — while message text, replayed thinking (#11), and tool-call arguments stay byte-exact, because a rewrite there would silently change literal content such as patches or quoted file bytes; a trigger inside a data field surfaces as the upstream `permission_denied` instead (the Codex `` escalation boilerplate is the recorded #2 case). | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. Its registry entry is `deprecated`: existing saved providers keep the adapter and their stored configuration, while new-provider discovery (presets, the init menu, the featured list, and key logins) excludes it and `provider add` warns before creating one. | diff --git a/tests/providers/zed-hosted-provider.test.ts b/tests/providers/zed-hosted-provider.test.ts index 375488bd220..f2a1bd5574f 100644 --- a/tests/providers/zed-hosted-provider.test.ts +++ b/tests/providers/zed-hosted-provider.test.ts @@ -6,6 +6,7 @@ import { constants, createHash, createPublicKey, publicEncrypt } from "node:cryp import { createZedAdapter, zedEventStream, type ZedProvider } from "../../src/adapters/zed"; import { translateZedOpenAiStream } from "../../src/adapters/zed-openai-stream"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import { releaseCompactionCiphertextLease } from "../../src/responses/compaction"; import { setIcaclsRunnerForTests, resetHardenedStateForTests } from "../../src/lib/windows-secret-acl"; import { getValidAccessTokenSnapshot } from "../../src/oauth"; import { saveCredential } from "../../src/oauth/store"; @@ -652,6 +653,148 @@ describe("Zed OpenAI tool relay", () => { { type: "done" }, ]); }); + + function sse(frames: unknown[]): string { + return frames.map(frame => "data: " + JSON.stringify(frame) + "\n\n").join(""); + } + + const compactionFrame = { type: "response.completed", response: { + output: [{ type: "compaction", encrypted_content: "fixture-ciphertext" }], + } }; + + test("carries a completed compaction ciphertext on done and transfers its lease", async () => { + const budget = createTranslatorBudget(); + const events: { type: string; compactionEncryptedContent?: string }[] = []; + try { + for await (const event of translateZedOpenAiStream(new Response(sse([compactionFrame])).body, budget)) { + events.push(event as { type: string; compactionEncryptedContent?: string }); + } + const done = events.at(-1)!; + expect(done.type).toBe("done"); + expect(done.compactionEncryptedContent).toBe("fixture-ciphertext"); + expect(budget.snapshot().currentBytes).toBe(Buffer.byteLength("fixture-ciphertext")); + releaseCompactionCiphertextLease(done, budget); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { + budget.dispose(); + } + }); + + test("a failure after retention releases the ciphertext without a done owner", async () => { + const budget = createTranslatorBudget(); + try { + const events = []; + for await (const event of translateZedOpenAiStream( + new Response(sse([compactionFrame, { type: "response.failed", response: { error: { message: "boom" } } }])).body, + budget, + )) events.push(event); + expect(events).toEqual([{ type: "error", message: "boom" }]); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { + budget.dispose(); + } + }); + + test("an incomplete response after retention releases the ciphertext", async () => { + const budget = createTranslatorBudget(); + try { + const events = []; + for await (const event of translateZedOpenAiStream( + new Response(sse([compactionFrame, { type: "response.incomplete", response: { incomplete_details: { reason: "max_output_tokens" } } }])).body, + budget, + )) events.push(event); + expect(events).toEqual([{ type: "incomplete", reason: "max_output_tokens" }]); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { + budget.dispose(); + } + }); + + test("a consumer that stops before done releases the retained ciphertext", async () => { + const budget = createTranslatorBudget(); + try { + const iterator = translateZedOpenAiStream( + new Response(sse([compactionFrame, { type: "response.output_text.delta", delta: "x" }])).body, + budget, + ); + expect(await iterator.next()).toEqual({ done: false, value: { type: "text_delta", text: "x" } }); + // The decoder also holds the chunk it is suspended in, so only the lower bound is stable. + expect(budget.snapshot().currentBytes).toBeGreaterThanOrEqual(Buffer.byteLength("fixture-ciphertext")); + await iterator.return(undefined); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { + budget.dispose(); + } + }); + + test("charged stream buffers are released when the turn ends", async () => { + const budget = createTranslatorBudget(); + try { + const events = []; + for await (const event of translateZedOpenAiStream(new Response(sse([ + { type: "response.output_text.delta", item_id: "msg_1", delta: "hello" }, + { type: "response.output_item.added", item: { id: "fc_a", type: "function_call", call_id: "call_a", name: "a", arguments: "" } }, + { type: "response.function_call_arguments.delta", item_id: "fc_a", delta: "{\"x\":1}" }, + { type: "response.function_call_arguments.done", item_id: "fc_a", arguments: "{\"x\":1}" }, + ])).body, budget)) events.push(event); + expect(events).toHaveLength(5); + expect(budget.snapshot().currentBytes).toBe(0); + expect(budget.snapshot().activeCalls).toBe(0); + } finally { + budget.dispose(); + } + }); + + test("accumulated streamed text counts against the turn ceiling", async () => { + const budget = createTranslatorBudget({ maxTurnBytes: 600 }); + const frames = Array.from({ length: 14 }, () => ({ type: "response.output_text.delta", item_id: "msg_1", delta: "x".repeat(50) })); + const encoder = new TextEncoder(); + let index = 0; + // One frame per chunk keeps each transient chunk charge small, so only the accumulated + // text can exceed the ceiling. + const body = new ReadableStream({ + pull(controller) { + if (index >= frames.length) { controller.close(); return; } + controller.enqueue(encoder.encode("data: " + JSON.stringify(frames[index++]) + "\n\n")); + }, + }); + try { + const iterator = translateZedOpenAiStream(body, budget); + const seen = []; + await expect((async () => { + for await (const event of iterator) seen.push(event); + })()).rejects.toMatchObject({ code: "translation_buffer_limit" }); + expect(seen.length).toBeGreaterThan(0); + expect(seen.length).toBeLessThan(frames.length); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { + budget.dispose(); + } + }); + + test("a completed item replay does not re-charge a closed call's arguments", async () => { + const budget = createTranslatorBudget(); + try { + const events = []; + for await (const event of translateZedOpenAiStream(new Response(sse([ + { type: "response.output_item.added", item: { id: "fc_a", type: "function_call", call_id: "call_a", name: "a", arguments: "" } }, + { type: "response.function_call_arguments.delta", item_id: "fc_a", delta: "{\"x\":1}" }, + { type: "response.function_call_arguments.done", item_id: "fc_a", arguments: "{\"x\":1}" }, + { type: "response.completed", response: { output: [ + { type: "function_call", id: "fc_a", call_id: "call_a", name: "a", arguments: "{\"x\":1}" }, + ] } }, + ])).body, budget)) events.push(event); + expect(events).toEqual([ + { type: "tool_call_start", id: "call_a", name: "a" }, + { type: "tool_call_delta", arguments: "{\"x\":1}" }, + { type: "tool_call_end" }, + { type: "done" }, + ]); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + } finally { + budget.dispose(); + } + }); }); describe("Zed inference send accounting", () => { From 5ba497ff03fd7f26970702547164f2f6f43e31b1 Mon Sep 17 00:00:00 2001 From: Andrew Wang Date: Sat, 24 Oct 2026 04:52:01 -0400 Subject: [PATCH 05/13] test(zed): cover cancellation with open tool calls A consumer that stops mid-turn now has a regression: two calls are open, the newer one's arguments are still buffered, and cancelling the iterator must release both calls and every retained byte. --- tests/providers/zed-hosted-provider.test.ts | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/providers/zed-hosted-provider.test.ts b/tests/providers/zed-hosted-provider.test.ts index f2a1bd5574f..3f1f5acf5e1 100644 --- a/tests/providers/zed-hosted-provider.test.ts +++ b/tests/providers/zed-hosted-provider.test.ts @@ -795,6 +795,28 @@ describe("Zed OpenAI tool relay", () => { budget.dispose(); } }); + + test("a consumer that stops while calls are open releases their buffered arguments", async () => { + const budget = createTranslatorBudget(); + try { + const iterator = translateZedOpenAiStream(new Response(sse([ + { type: "response.output_item.added", item: { id: "fc_a", type: "function_call", call_id: "call_a", name: "a", arguments: "" } }, + { type: "response.output_item.added", item: { id: "fc_b", type: "function_call", call_id: "call_b", name: "b", arguments: "" } }, + { type: "response.function_call_arguments.delta", item_id: "fc_b", delta: "{\"y\":2}" }, + { type: "response.function_call_arguments.delta", item_id: "fc_a", delta: "{\"x\":1}" }, + { type: "response.function_call_arguments.done", item_id: "fc_a", arguments: "{\"x\":1}" }, + ])).body, budget); + let step = await iterator.next(); + while (!step.done && step.value.type !== "tool_call_delta") step = await iterator.next(); + expect(step.done).toBe(false); + // Both calls are still open: a is mid-stream and b's arguments are buffered behind it. + expect(budget.snapshot().activeCalls).toBeGreaterThan(0); + await iterator.return(undefined); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + } finally { + budget.dispose(); + } + }); }); describe("Zed inference send accounting", () => { From a9a664a7f6feab1ba44245bb0c2eedc3c5b531dd Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:56:51 +0900 Subject: [PATCH 06/13] fix(zed): preserve relay identity, truncation and bounded stream ownership Keep semantic output behind active tools, reconcile call aliases, and stop on terminal errors. Preserve partial tool status through all client encoders, restore done-only text and shared usage projection, and scrub native diagnostics. Budget argument snapshots, metadata and queued output with cancellation cleanup. Co-authored-by: Andrew Wang <59988150+andrew05060414@users.noreply.github.com> --- .../src/content/docs/guides/providers.md | 6 + scripts/test-layout/layout-adapters.json | 1 + src/adapters/openai-responses/passthrough.ts | 33 +- src/adapters/openai-responses/usage.ts | 34 ++ src/adapters/zed-additional-tools.ts | 51 +++ src/adapters/zed-openai-stream.ts | 330 ++++++++++++------ src/adapters/zed.ts | 18 +- src/bridge/response-json.ts | 1 + src/bridge/sse.ts | 1 + src/protocols/encoders/adapter-events.ts | 1 + src/types/request.ts | 2 +- structure/data-planes/protocol-paths.md | 2 +- structure/providers-and-adapters.md | 2 +- structure/transports/byte-accounting.md | 7 + structure/transports/responses-wire-shapes.md | 2 +- structure/transports/responses.md | 2 +- tests/fixtures/test-layout-expected.json | 1 + tests/providers/zed-hosted-provider.test.ts | 9 +- tests/providers/zed-openai-contract.test.ts | 321 +++++++++++++++++ 19 files changed, 672 insertions(+), 152 deletions(-) create mode 100644 src/adapters/openai-responses/usage.ts create mode 100644 src/adapters/zed-additional-tools.ts create mode 100644 tests/providers/zed-openai-contract.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index c365b6c573b..482d243fc0f 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -377,6 +377,12 @@ revokes it, run `ocx login zed` again. For Zed-hosted OpenAI models, OpenCodex translates `developer` instructions to `system` messages and removes OpenAI-private fields and unsupported built-in tools such as `web_search`. Function and custom tools remain available through the delegate; a tool choice with no supported tools left becomes `none`. +Current-turn tool declarations carried in Codex's `additional_tools` input are promoted into the +supported tool catalog. Function calls and custom-tool input are relayed in order, including +interleaved calls, so a tool-using turn can continue after its introductory text. Incomplete calls +keep only received arguments and remain incomplete; cancellation releases buffered data. The +relay is covered by synthetic stream fixtures; live hosted-stream compatibility remains unverified. + For the canonical Kimi Coding Plan presets (`kimi` account login and `kimi-code` API key), opencodex forwards only a caller-supplied stable `prompt_cache_key` to the Chat Completions request (the Responses wire accepts the same field, but opencodex does not send it there today); diff --git a/scripts/test-layout/layout-adapters.json b/scripts/test-layout/layout-adapters.json index 08f0ab47160..cc7117ed973 100644 --- a/scripts/test-layout/layout-adapters.json +++ b/scripts/test-layout/layout-adapters.json @@ -1,4 +1,5 @@ { + "zed-openai-contract.test.ts": "providers", "cursor-native-grep-globs.test.ts": "providers/cursor", "tool-identity-hash.test.ts": "adapters", "eventstream-fragmentation.test.ts": "responses", diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index e8abc6aab58..5a99fbe0ffa 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -44,6 +44,7 @@ import { repairUrlCitationAnnotations, scrubOcxCompactionItems, stripCanonicalOn import { notePlaintextReasoningDropped } from "./plaintext-reasoning-notice"; import { stripCanonicalForwardPromptCacheOptions, stripDeprecatedPromptCacheRetention } from "./prompt-cache"; import { isPlainObject } from "./internal"; +import { usageFromResponsesPayload } from "./usage"; import { normalizeToolSchemas, promoteClientLoadedTools, stripUnsupportedHostedTools } from "./tool-schema"; import { annotateEmptyResponsesToolOutputs, backfillWebSearchQueries, normalizeResponsesToolResultAdjacency, repairOrphanedInputItems, repairOversizedReplayCallIds, repairUnidentifiedToolOutputItems, restoreBridgedWebSearchCalls } from "./tool-output-recovery"; import { bridgeSearchReplayScope } from "../../responses/bridge-search-replay-cache"; @@ -124,38 +125,6 @@ function buildRoutedCompactionBody(body: unknown, responsesLite: boolean): unkno }; } -/** Read the Responses `usage` block, if the gateway sent one. */ -function usageFromResponsesPayload(payload: unknown): OcxUsage | undefined { - if (!isPlainObject(payload) || !isPlainObject(payload.usage)) return undefined; - const usage = payload.usage; - const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0; - const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0; - // openai/codex#41980: the raw usage object is wire data a rebuilt response.completed must keep — - // unknown keys (subscription metadata, future counters) ride along even when the token counts - // themselves are zero or absent (metadata-only usage). - const knownKeys = new Set(["input_tokens", "output_tokens", "total_tokens", "input_tokens_details", "output_tokens_details"]); - const hasExtras = Object.keys(usage).some(key => !knownKeys.has(key)) - || (isPlainObject(usage.input_tokens_details) - && Object.keys(usage.input_tokens_details).some(key => key !== "cached_tokens" && key !== "cache_write_tokens" - && key !== "cache_write_1h_tokens")) - || (isPlainObject(usage.output_tokens_details) - && Object.keys(usage.output_tokens_details).some(key => key !== "reasoning_tokens")); - if (inputTokens === 0 && outputTokens === 0 && !hasExtras) return undefined; - const inputDetails = isPlainObject(usage.input_tokens_details) ? usage.input_tokens_details : undefined; - const outputDetails = isPlainObject(usage.output_tokens_details) ? usage.output_tokens_details : undefined; - return { - inputTokens, - outputTokens, - ...(typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}), - ...(typeof inputDetails?.cached_tokens === "number" ? { cachedInputTokens: inputDetails.cached_tokens } : {}), - ...(typeof inputDetails?.cache_write_tokens === "number" ? { cacheCreationInputTokens: inputDetails.cache_write_tokens } : {}), - ...(typeof inputDetails?.cache_write_1h_tokens === "number" - ? { cacheCreation1hInputTokens: inputDetails.cache_write_1h_tokens } : {}), - ...(typeof outputDetails?.reasoning_tokens === "number" ? { reasoningOutputTokens: outputDetails.reasoning_tokens } : {}), - ...(hasExtras ? { rawUsage: { ...usage } } : {}), - }; -} - function responsesPayloadText(response: unknown): string { if (!isPlainObject(response) || !Array.isArray(response.output)) return ""; return response.output diff --git a/src/adapters/openai-responses/usage.ts b/src/adapters/openai-responses/usage.ts new file mode 100644 index 00000000000..674f268df4b --- /dev/null +++ b/src/adapters/openai-responses/usage.ts @@ -0,0 +1,34 @@ +import type { OcxUsage } from "../../types"; +import { isPlainObject } from "./internal"; + +/** Read the Responses `usage` block, if the gateway sent one. */ +export function usageFromResponsesPayload(payload: unknown): OcxUsage | undefined { + if (!isPlainObject(payload) || !isPlainObject(payload.usage)) return undefined; + const usage = payload.usage; + const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0; + const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0; + // openai/codex#41980: the raw usage object is wire data a rebuilt response.completed must keep — + // unknown keys (subscription metadata, future counters) ride along even when the token counts + // themselves are zero or absent (metadata-only usage). + const knownKeys = new Set(["input_tokens", "output_tokens", "total_tokens", "input_tokens_details", "output_tokens_details"]); + const hasExtras = Object.keys(usage).some(key => !knownKeys.has(key)) + || (isPlainObject(usage.input_tokens_details) + && Object.keys(usage.input_tokens_details).some(key => key !== "cached_tokens" && key !== "cache_write_tokens" + && key !== "cache_write_1h_tokens")) + || (isPlainObject(usage.output_tokens_details) + && Object.keys(usage.output_tokens_details).some(key => key !== "reasoning_tokens")); + if (inputTokens === 0 && outputTokens === 0 && !hasExtras) return undefined; + const inputDetails = isPlainObject(usage.input_tokens_details) ? usage.input_tokens_details : undefined; + const outputDetails = isPlainObject(usage.output_tokens_details) ? usage.output_tokens_details : undefined; + return { + inputTokens, + outputTokens, + ...(typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}), + ...(typeof inputDetails?.cached_tokens === "number" ? { cachedInputTokens: inputDetails.cached_tokens } : {}), + ...(typeof inputDetails?.cache_write_tokens === "number" ? { cacheCreationInputTokens: inputDetails.cache_write_tokens } : {}), + ...(typeof inputDetails?.cache_write_1h_tokens === "number" + ? { cacheCreation1hInputTokens: inputDetails.cache_write_1h_tokens } : {}), + ...(typeof outputDetails?.reasoning_tokens === "number" ? { reasoningOutputTokens: outputDetails.reasoning_tokens } : {}), + ...(hasExtras ? { rawUsage: { ...usage } } : {}), + }; +} diff --git a/src/adapters/zed-additional-tools.ts b/src/adapters/zed-additional-tools.ts new file mode 100644 index 00000000000..73a18ca4533 --- /dev/null +++ b/src/adapters/zed-additional-tools.ts @@ -0,0 +1,51 @@ +import { isPlainObject as isRecord } from "./openai-responses/internal"; + +function toolKey(tool: Record): string | undefined { + if (typeof tool.type !== "string" || !tool.type) return undefined; + const name = typeof tool.name === "string" ? tool.name : ""; + return tool.type + "\0" + name; +} + +/** + * Zed's OpenAI Responses schema accepts message and call items, not Codex's private + * additional_tools wrapper. Drop every wrapper. Promote only wrappers at or after the + * replayed prefix: earlier ones are history, not the current turn's tool catalog. + * A non-array tools field is left untouched so a malformed catalog is not replaced + * by a partial promotion. + */ +export function liftZedAdditionalTools(providerRequest: Record, replayPrefixLength: number): void { + if (!Array.isArray(providerRequest.input)) return; + if (providerRequest.tools !== undefined && !Array.isArray(providerRequest.tools)) return; + + const currentTurnStart = Number.isFinite(replayPrefixLength) + ? Math.min(providerRequest.input.length, Math.max(0, Math.trunc(replayPrefixLength))) + : 0; + const input: unknown[] = []; + const promoted: unknown[] = []; + let changed = false; + for (const [index, item] of providerRequest.input.entries()) { + if (isRecord(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { + changed = true; + if (index >= currentTurnStart) promoted.push(...item.tools); + continue; + } + input.push(item); + } + if (!changed) return; + + const seen = new Set(); + const tools: unknown[] = []; + const existing = Array.isArray(providerRequest.tools) ? providerRequest.tools : []; + for (const tool of [...existing, ...promoted]) { + if (!isRecord(tool)) { + tools.push(tool); + continue; + } + const key = toolKey(tool); + if (key && seen.has(key)) continue; + if (key) seen.add(key); + tools.push(tool); + } + providerRequest.input = input; + providerRequest.tools = tools; +} diff --git a/src/adapters/zed-openai-stream.ts b/src/adapters/zed-openai-stream.ts index 05171492299..08d87d79d05 100644 --- a/src/adapters/zed-openai-stream.ts +++ b/src/adapters/zed-openai-stream.ts @@ -2,64 +2,24 @@ import type { AdapterEvent, OcxMessagePhase, OcxUsage } from "../types"; import type { TranslatorBudget } from "../lib/translator-budget"; import { decodeServerSentEvents } from "../lib/sse-decoder"; import { transferCompactionCiphertextLease } from "../responses/compaction"; +import { truncationReasonFor } from "../responses/truncated-stop-reason"; +import { unnamedToolCallEvent } from "./openai-chat/response-events"; +import { usageFromResponsesPayload } from "./openai-responses/usage"; +import { createHash, randomUUID } from "node:crypto"; function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } -function toolKey(tool: Record): string | undefined { - if (typeof tool.type !== "string" || !tool.type) return undefined; - const name = typeof tool.name === "string" ? tool.name : ""; - return tool.type + "\0" + name; -} - -/** - * Zed's OpenAI Responses schema accepts message and call items, not Codex's private - * additional_tools wrapper. Drop every wrapper. Promote only wrappers at or after the - * replayed prefix: earlier ones are history, not the current turn's tool catalog. - * A non-array tools field is left untouched so a malformed catalog is not replaced - * by a partial promotion. - */ -export function liftZedAdditionalTools(providerRequest: Record, replayPrefixLength: number): void { - if (!Array.isArray(providerRequest.input)) return; - if (providerRequest.tools !== undefined && !Array.isArray(providerRequest.tools)) return; - - const currentTurnStart = Number.isFinite(replayPrefixLength) - ? Math.min(providerRequest.input.length, Math.max(0, Math.trunc(replayPrefixLength))) - : 0; - const input: unknown[] = []; - const promoted: unknown[] = []; - let changed = false; - for (const [index, item] of providerRequest.input.entries()) { - if (isRecord(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { - changed = true; - if (index >= currentTurnStart) promoted.push(...item.tools); - continue; - } - input.push(item); - } - if (!changed) return; - - const seen = new Set(); - const tools: unknown[] = []; - const existing = Array.isArray(providerRequest.tools) ? providerRequest.tools : []; - for (const tool of [...existing, ...promoted]) { - if (!isRecord(tool)) { - tools.push(tool); - continue; - } - const key = toolKey(tool); - if (key && seen.has(key)) continue; - if (key) seen.add(key); - tools.push(tool); - } - providerRequest.input = input; - providerRequest.tools = tools; -} +export { liftZedAdditionalTools } from "./zed-additional-tools"; interface CallState { key: string; + budgetKey: string; callId: string; + kind?: string; + finalHash?: string; + after: { event: AdapterEvent; bytes: number }[]; name: string; args: string; pendingFull?: string; @@ -74,6 +34,10 @@ interface CallState { allowFull: boolean; } +class ZedToolIdentityError extends Error { + constructor() { super("Zed stream reused a tool call identity with conflicting data"); } +} + /** UTF-8 bytes a fragment adds to an existing string, without re-measuring the whole buffer. */ function appendedUtf8Bytes(previousBytes: number, lastCodeUnit: number, fragment: string): number { if (!fragment) return previousBytes; @@ -94,25 +58,6 @@ function messageText(item: Record): string { .join(""); } -function usageFromResponse(response: unknown): OcxUsage | undefined { - if (!isRecord(response) || !isRecord(response.usage)) return undefined; - const usage = response.usage; - if (typeof usage.input_tokens !== "number" || typeof usage.output_tokens !== "number") return undefined; - const inputDetails = isRecord(usage.input_tokens_details) ? usage.input_tokens_details : undefined; - const outputDetails = isRecord(usage.output_tokens_details) ? usage.output_tokens_details : undefined; - return { - inputTokens: usage.input_tokens, - outputTokens: usage.output_tokens, - ...(typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}), - ...(typeof inputDetails?.cached_tokens === "number" - ? { cachedInputTokens: inputDetails.cached_tokens, cacheReadInputTokens: inputDetails.cached_tokens } - : {}), - ...(typeof outputDetails?.reasoning_tokens === "number" - ? { reasoningOutputTokens: outputDetails.reasoning_tokens } - : {}), - }; -} - function failureMessage(payload: Record): string { const source = isRecord(payload.response) ? payload.response : payload; const err = source.error ?? payload.error; @@ -125,7 +70,7 @@ function failureMessage(payload: Record): string { function incompleteReason(payload: Record): string { const source = isRecord(payload.response) ? payload.response : payload; const details = isRecord(source.incomplete_details) ? source.incomplete_details : undefined; - if (typeof details?.reason === "string" && details.reason) return details.reason; + if (typeof details?.reason === "string" && details.reason) return truncationReasonFor(details.reason) ?? details.reason; return failureMessage(payload); } @@ -153,19 +98,41 @@ export async function* translateZedOpenAiStream( } const calls = new Map(); + const aliases = new Map(); + const callIds = new Map(); const phases = new Map(); const textByItem = new Map(); const textBytesByItem = new Map(); + const textLastByItem = new Map(); + const textParts = new Map(); + let textPartBytes = 0; + const identities = new Set(); + let identityBytes = 0; + const thinkingItems = new Set(); + const rawReasoningItems = new Set(); let unscopedText = ""; let unscopedTextBytes = 0; + let unscopedLast = 0; let sawThinking = false; let sawRawReasoning = false; let usage: OcxUsage | undefined; + let usageRawBytes = 0; + let semanticBytes = 0; let terminal = false; + let terminalError = false; let compactionEncryptedContent: string | undefined; let compactionEncryptedContentBytes = 0; let ciphertextTransferred = false; + // Include a fixed entry allowance so arbitrarily many empty items also spend the turn cap. + function retainIdentity(key: string): void { + if (identities.has(key)) return; + const bytes = 64 + Buffer.byteLength(key, "utf8"); + budget.chargeRetained(bytes, { kind: "item_ids" }); + identityBytes += bytes; + identities.add(key); + } + function argumentText(state: CallState): string { const full = state.pendingFull; if (state.allowFull && typeof full === "string" && full.startsWith(state.args)) return full; @@ -175,13 +142,17 @@ export async function* translateZedOpenAiStream( /** Emit the oldest open call. Newer calls stay buffered until that call closes. */ function* pump(): Generator { while (true) { + if (terminalError) return; const next = [...calls.values()].find(state => !state.ended); if (!next) return; if (!next.name) { if (!next.closed) return; next.ended = true; releaseCall(next); - continue; + terminal = true; + terminalError = true; + yield unnamedToolCallEvent(usage); + return; } if (!next.started) { next.started = true; @@ -195,16 +166,26 @@ export async function* translateZedOpenAiStream( } if (!next.closed) return; next.ended = true; - yield { type: "tool_call_end" }; + next.finalHash = createHash("sha256").update(text).digest("hex"); + yield { type: "tool_call_end", ...(!next.allowFull ? { incomplete: true } : {}) }; releaseCall(next); + for (const entry of next.after) { + budget.releaseRetained(entry.bytes, { kind: "retained_collectors" }); + semanticBytes -= entry.bytes; + yield entry.event; + } + next.after = []; } } function* appendArgs(state: CallState, fragment: string): Generator { if (!fragment || state.ended) return; const nextBytes = appendedUtf8Bytes(state.argsBytes, state.lastCodeUnit, fragment); - budget.chargeRetained(nextBytes - state.argsBytes, { kind: "tool_args", callId: state.key }); + const scope = { kind: "tool_args" as const, callId: state.budgetKey }; + const reservation = budget.reserveTransient(nextBytes, scope); state.args += fragment; + reservation.commitRetained(); + budget.releaseRetained(state.argsBytes, scope); state.argsBytes = nextBytes; state.lastCodeUnit = fragment.charCodeAt(fragment.length - 1); yield* pump(); @@ -212,14 +193,24 @@ export async function* translateZedOpenAiStream( function rememberFull(state: CallState, full: unknown): void { // A closed call's buffers were already released; charging again would outlive their owner. - if (state.ended || typeof full !== "string" || full.length === 0 || full === state.pendingFull) return; - const nextBytes = Buffer.byteLength(full, "utf8"); - if (nextBytes > state.fullBytes) { - budget.chargeRetained(nextBytes - state.fullBytes, { kind: "tool_args", callId: state.key }); - } else if (nextBytes < state.fullBytes) { - budget.releaseRetained(state.fullBytes - nextBytes, { kind: "tool_args", callId: state.key }); + if (typeof full !== "string" || full.length === 0) return; + if (state.ended) { + if (state.finalHash && state.finalHash !== createHash("sha256").update(full).digest("hex")) throw new ZedToolIdentityError(); + return; + } + if (full === state.pendingFull) return; + if (full === state.args) { + budget.releaseRetained(state.fullBytes, { kind: "tool_args", callId: state.budgetKey }); + state.pendingFull = undefined; + state.fullBytes = 0; + return; } + const nextBytes = Buffer.byteLength(full, "utf8"); + const scope = { kind: "tool_args" as const, callId: state.budgetKey }; + const reservation = budget.reserveTransient(nextBytes, scope); state.pendingFull = full; + reservation.commitRetained(); + budget.releaseRetained(state.fullBytes, scope); state.fullBytes = nextBytes; } @@ -228,7 +219,7 @@ export async function* translateZedOpenAiStream( * call still holds, including the part already emitted, so a turn cannot keep growing. */ function releaseCall(state: CallState): void { - budget.closeCall(state.key); + budget.closeCall(state.budgetKey); state.args = ""; state.argsBytes = 0; state.pendingFull = undefined; @@ -242,13 +233,27 @@ export async function* translateZedOpenAiStream( * reaches the flush path, so the buffers must not outlive the request. */ function releaseStreamBuffers(): void { - for (const state of calls.values()) budget.closeCall(state.key); + for (const state of calls.values()) budget.closeCall(state.budgetKey); calls.clear(); + aliases.clear(); + callIds.clear(); + budget.releaseRetained(semanticBytes + usageRawBytes, { kind: "retained_collectors" }); + semanticBytes = usageRawBytes = 0; + budget.releaseRetained(identityBytes, { kind: "item_ids" }); + identityBytes = 0; + identities.clear(); + phases.clear(); + thinkingItems.clear(); + rawReasoningItems.clear(); for (const bytes of textBytesByItem.values()) { budget.releaseRetained(bytes, { kind: "retained_collectors" }); } textBytesByItem.clear(); + textLastByItem.clear(); textByItem.clear(); + textParts.clear(); + budget.releaseRetained(textPartBytes, { kind: "retained_collectors" }); + textPartBytes = 0; budget.releaseRetained(unscopedTextBytes, { kind: "retained_collectors" }); unscopedTextBytes = 0; unscopedText = ""; @@ -285,14 +290,21 @@ export async function* translateZedOpenAiStream( compactionEncryptedContentBytes = nextBytes; } - function ensureCall(key: string, callId: string, name: string): CallState { - let state = calls.get(key); + function ensureCall(key: string, callId: string, name: string, kind?: string): CallState { + const byItem = aliases.get(key); + const byCall = callId ? callIds.get(callId) : undefined; + if (byItem && byCall && byItem !== byCall) throw new ZedToolIdentityError(); + let state = byItem ?? byCall; if (!state) { + retainIdentity(`call:${key}`); state = { // A delta names only the item. Keep the call id the added item already carried instead of // replacing it with the item id, which would mislabel a deferred start. key, + budgetKey: `zed:${randomUUID()}`, callId: callId || key, + kind, + after: [], name, args: "", argsBytes: 0, @@ -305,33 +317,65 @@ export async function* translateZedOpenAiStream( allowFull: false, }; calls.set(key, state); - budget.openCall(state.key); - return state; + budget.openCall(state.budgetKey); } - if (callId) state.callId = callId; + if ((name && state.name && name !== state.name) || (kind && state.kind && kind !== state.kind) + || (callId && state.started && callId !== state.callId)) throw new ZedToolIdentityError(); + retainIdentity(`call-alias:${key}`); + aliases.set(key, state); + // Metadata stays alive for replay deduplication even after argument buffers close. + if (callId) retainIdentity(`call-id:${callId}`); + if (name) retainIdentity(`call-name:${name}`); + if (callId) { state.callId = callId; callIds.set(callId, state); } if (name) state.name = name; + if (kind) state.kind = kind; return state; } + function* semantic(event: AdapterEvent): Generator { + const active = [...calls.values()].find(state => state.started && !state.ended); + if (!active) { yield event; return; } + const bytes = 64 + Buffer.byteLength(JSON.stringify(event), "utf8"); + budget.chargeRetained(bytes, { kind: "retained_collectors" }); + semanticBytes += bytes; + active.after.push({ event, bytes }); + yield { type: "heartbeat" }; + } + + function retainUsage(response: unknown): void { + const next = usageFromResponsesPayload(response); + if (!next) return; + const bytes = next.rawUsage ? Buffer.byteLength(JSON.stringify(next.rawUsage), "utf8") : 0; + const reservation = budget.reserveTransient(bytes, { kind: "retained_collectors" }); + usage = next; + reservation.commitRetained(); + budget.releaseRetained(usageRawBytes, { kind: "retained_collectors" }); + usageRawBytes = bytes; + } + /** * Text is kept only to suppress a completed replay. Every replacement is charged to the * request budget and the superseded bytes released, so a long turn cannot accumulate text * outside the per-turn ceiling. */ - function noteText(itemId: string | undefined, text: string): void { - const nextBytes = Buffer.byteLength(text, "utf8"); + function noteText(itemId: string | undefined, text: string, nextBytes = Buffer.byteLength(text, "utf8"), last = text.charCodeAt(text.length - 1)): void { if (itemId) { + retainIdentity(`text:${itemId}`); const previousBytes = textBytesByItem.get(itemId) ?? 0; - if (nextBytes > previousBytes) budget.chargeRetained(nextBytes - previousBytes, { kind: "retained_collectors" }); - else if (nextBytes < previousBytes) budget.releaseRetained(previousBytes - nextBytes, { kind: "retained_collectors" }); + const reservation = budget.reserveTransient(nextBytes, { kind: "retained_collectors" }); textByItem.set(itemId, text); textBytesByItem.set(itemId, nextBytes); + textLastByItem.set(itemId, last); + reservation.commitRetained(); + budget.releaseRetained(previousBytes, { kind: "retained_collectors" }); return; } - if (nextBytes > unscopedTextBytes) budget.chargeRetained(nextBytes - unscopedTextBytes, { kind: "retained_collectors" }); - else if (nextBytes < unscopedTextBytes) budget.releaseRetained(unscopedTextBytes - nextBytes, { kind: "retained_collectors" }); + const reservation = budget.reserveTransient(nextBytes, { kind: "retained_collectors" }); unscopedText = text; + reservation.commitRetained(); + budget.releaseRetained(unscopedTextBytes, { kind: "retained_collectors" }); unscopedTextBytes = nextBytes; + unscopedLast = last; } function* emitNewText(text: string, phase?: OcxMessagePhase, itemId?: string): Generator { @@ -352,7 +396,7 @@ export async function* translateZedOpenAiStream( } if (!previous) { noteText(itemId, text); - yield { type: "text_delta", text, ...(phase ? { phase } : {}) }; + yield* semantic({ type: "text_delta", text, ...(phase ? { phase } : {}) }); return; } if (text === previous || previous.endsWith(text)) { @@ -362,17 +406,34 @@ export async function* translateZedOpenAiStream( if (text.startsWith(previous)) { const tail = text.slice(previous.length); noteText(itemId, text); - if (tail) yield { type: "text_delta", text: tail, ...(phase ? { phase } : {}) }; + if (tail) yield* semantic({ type: "text_delta", text: tail, ...(phase ? { phase } : {}) }); return; } noteText(itemId, previous + text); - yield { type: "text_delta", text, ...(phase ? { phase } : {}) }; + yield* semantic({ type: "text_delta", text, ...(phase ? { phase } : {}) }); } /** Streamed deltas accumulate per item; the charge follows the accumulated size. */ function appendText(itemId: string | undefined, delta: string): void { - if (itemId) noteText(itemId, (textByItem.get(itemId) ?? "") + delta); - else noteText(undefined, unscopedText + delta); + const previous = itemId ? textByItem.get(itemId) ?? "" : unscopedText; + const bytes = itemId ? textBytesByItem.get(itemId) ?? 0 : unscopedTextBytes; + const last = itemId ? textLastByItem.get(itemId) ?? 0 : unscopedLast; + noteText(itemId, previous + delta, appendedUtf8Bytes(bytes, last, delta), delta.charCodeAt(delta.length - 1)); + } + + function textPartKey(payload: Record): string | undefined { + return typeof payload.item_id === "string" + ? JSON.stringify([payload.item_id, payload.content_index ?? 0]) : undefined; + } + + function rememberTextPart(key: string, text: string, bytes: number, last: number): void { + retainIdentity(`text-part:${key}`); + const oldBytes = textParts.get(key)?.bytes ?? 0; + const reservation = budget.reserveTransient(bytes, { kind: "retained_collectors" }); + textParts.set(key, { text, bytes, last }); + reservation.commitRetained(); + budget.releaseRetained(oldBytes, { kind: "retained_collectors" }); + textPartBytes += bytes - oldBytes; } function* observeToolItem(item: Record, done: boolean): Generator { @@ -381,31 +442,35 @@ export async function* translateZedOpenAiStream( if (!key) return; const callId = typeof item.call_id === "string" ? item.call_id : key; const name = typeof item.name === "string" ? item.name : ""; - const state = ensureCall(key, callId, name); + const state = ensureCall(key, callId, name, item.type); rememberFull(state, item.type === "custom_tool_call" ? item.input : item.arguments); if (done) yield* closeCall(state, true); else yield* pump(); } function* emitReasoningItem(item: Record): Generator { - if (!sawThinking && Array.isArray(item.summary)) { + const key = typeof item.id === "string" ? item.id : ""; + retainIdentity(`reasoning:${key}`); + if (!(key ? thinkingItems.has(key) : sawThinking) && Array.isArray(item.summary)) { const text = item.summary .filter((part): part is Record => isRecord(part) && typeof part.text === "string") .map(part => String(part.text)) .join(""); if (text) { sawThinking = true; - yield { type: "thinking_delta", thinking: text }; + thinkingItems.add(key); + yield* semantic({ type: "thinking_delta", thinking: text }); } } - if (!sawRawReasoning && Array.isArray(item.content)) { + if (!(key ? rawReasoningItems.has(key) : sawRawReasoning) && Array.isArray(item.content)) { const text = item.content .filter((part): part is Record => isRecord(part) && part.type === "reasoning_text" && typeof part.text === "string") .map(part => String(part.text)) .join(""); if (text) { sawRawReasoning = true; - yield { type: "reasoning_raw_delta", text }; + rawReasoningItems.add(key); + yield* semantic({ type: "reasoning_raw_delta", text }); } } } @@ -413,6 +478,7 @@ export async function* translateZedOpenAiStream( function* consumeItem(item: Record, done: boolean): Generator { if (item.type === "message") { const phase = phaseOf(item); + if (typeof item.id === "string") retainIdentity(`message:${item.id}`); if (typeof item.id === "string" && phase) phases.set(item.id, phase); if (done) yield* emitNewText(messageText(item), phase, typeof item.id === "string" ? item.id : undefined); return; @@ -427,15 +493,15 @@ export async function* translateZedOpenAiStream( function* onPayload(payload: Record): Generator { if (payload.type === "error" || payload.type === "response.failed") { terminal = true; - yield { type: "error", message: failureMessage(payload) }; + retainUsage(payload.response); + yield { type: "error", message: failureMessage(payload), ...(usage ? { usage } : {}) }; return; } if (payload.type === "response.incomplete") { terminal = true; + retainUsage(payload.response); yield* flushUnclosed(false); - const response = isRecord(payload.response) ? payload.response : undefined; - const nextUsage = usageFromResponse(response) ?? usage; - if (nextUsage) usage = nextUsage; + if (terminalError) return; yield { type: "incomplete", reason: incompleteReason(payload), ...(usage ? { usage } : {}) }; return; } @@ -446,18 +512,49 @@ export async function* translateZedOpenAiStream( if (payload.type === "response.output_text.delta" && typeof payload.delta === "string" && payload.delta) { const itemId = typeof payload.item_id === "string" ? payload.item_id : undefined; const phase = itemId ? phases.get(itemId) : undefined; + const partKey = textPartKey(payload); + if (partKey) { + const previous = textParts.get(partKey); + rememberTextPart(partKey, (previous?.text ?? "") + payload.delta, + appendedUtf8Bytes(previous?.bytes ?? 0, previous?.last ?? 0, payload.delta), payload.delta.charCodeAt(payload.delta.length - 1)); + } appendText(itemId, payload.delta); - yield { type: "text_delta", text: payload.delta, ...(phase ? { phase } : {}) }; + yield* semantic({ type: "text_delta", text: payload.delta, ...(phase ? { phase } : {}) }); + return; + } + if (payload.type === "response.output_text.done" && typeof payload.text === "string") { + const itemId = typeof payload.item_id === "string" ? payload.item_id : undefined; + const key = textPartKey(payload); + if (key) { + const previous = textParts.get(key)?.text ?? ""; + const delta = payload.text.startsWith(previous) ? payload.text.slice(previous.length) : ""; + rememberTextPart(key, payload.text, Buffer.byteLength(payload.text, "utf8"), payload.text.charCodeAt(payload.text.length - 1)); + if (delta) { + appendText(itemId, delta); + const phase = itemId ? phases.get(itemId) : undefined; + yield* semantic({ type: "text_delta", text: delta, ...(phase ? { phase } : {}) }); + } + return; + } + yield* emitNewText(payload.text, itemId ? phases.get(itemId) : undefined, itemId); return; } if (payload.type === "response.reasoning_summary_text.delta" && typeof payload.delta === "string" && payload.delta) { sawThinking = true; - yield { type: "thinking_delta", thinking: payload.delta }; + if (typeof payload.item_id === "string") { + retainIdentity(`reasoning:${payload.item_id}`); + thinkingItems.add(payload.item_id); + } + yield* semantic({ type: "thinking_delta", thinking: payload.delta }); return; } if (payload.type === "response.reasoning_text.delta" && typeof payload.delta === "string" && payload.delta) { sawRawReasoning = true; - yield { type: "reasoning_raw_delta", text: payload.delta }; + if (typeof payload.item_id === "string") { + retainIdentity(`reasoning:${payload.item_id}`); + rawReasoningItems.add(payload.item_id); + } + yield* semantic({ type: "reasoning_raw_delta", text: payload.delta }); return; } if ( @@ -466,7 +563,9 @@ export async function* translateZedOpenAiStream( ) { const key = typeof payload.item_id === "string" ? payload.item_id : undefined; if (!key) return; - yield* appendArgs(ensureCall(key, "", ""), payload.delta); + const state = ensureCall(key, "", ""); + yield* appendArgs(state, payload.delta); + if (!state.started && !state.ended && payload.delta) yield { type: "heartbeat" }; return; } if (payload.type === "response.function_call_arguments.done" || payload.type === "response.custom_tool_call_input.done") { @@ -479,14 +578,14 @@ export async function* translateZedOpenAiStream( } if (payload.type === "response.completed") { const response = isRecord(payload.response) ? payload.response : undefined; + retainUsage(response); retainCompaction(response); if (response && Array.isArray(response.output)) { for (const item of response.output) { if (isRecord(item)) yield* consumeItem(item, true); + if (terminalError) return; } } - const nextUsage = usageFromResponse(response); - if (nextUsage) usage = nextUsage; } } @@ -499,7 +598,13 @@ export async function* translateZedOpenAiStream( if (terminal) return; } + const unfinished = [...calls.values()].some(state => !state.closed); yield* flushUnclosed(false); + if (terminal) return; + if (unfinished) { + yield { type: "incomplete", reason: "adapter_eof", ...(usage ? { usage } : {}) }; + return; + } const done: AdapterEvent = { type: "done", ...(usage ? { usage } : {}), @@ -508,6 +613,9 @@ export async function* translateZedOpenAiStream( transferCompactionCiphertextLease(done, budget, compactionEncryptedContentBytes); ciphertextTransferred = true; yield done; + } catch (error) { + if (!(error instanceof ZedToolIdentityError)) throw error; + yield { type: "error", message: error.message, ...(usage ? { usage } : {}) }; } finally { if (!ciphertextTransferred) budget.releaseRetained(compactionEncryptedContentBytes, { kind: "retained_collectors" }); releaseStreamBuffers(); diff --git a/src/adapters/zed.ts b/src/adapters/zed.ts index 5add17cc064..b906a01302f 100644 --- a/src/adapters/zed.ts +++ b/src/adapters/zed.ts @@ -235,7 +235,23 @@ export function zedEventStream( return; } if (isNativeTerminalEvent(event)) sawNativeTerminal = true; - emit(controller, event); + // Native diagnostics can echo the same account credential as status failures. + const diagnostic = event.type === "error" || event.type === "response.failed" || event.type === "response.incomplete"; + const scrubFields = (record: Record): Record => ({ + ...record, + ...(typeof record.message === "string" ? { message: scrub(record.message) } : {}), + ...(typeof record.reason === "string" ? { reason: scrub(record.reason) } : {}), + }); + const scrubRecord = (record: Record): Record => ({ + ...scrubFields(record), + ...(typeof record.error === "string" ? { error: scrub(record.error) } + : isRecord(record.error) ? { error: scrubFields(record.error) } : {}), + ...(isRecord(record.incomplete_details) ? { incomplete_details: scrubFields(record.incomplete_details) } : {}), + }); + emit(controller, diagnostic ? { + ...scrubRecord(event), + ...(isRecord(event.response) ? { response: scrubRecord(event.response) } : {}), + } : event); } return body.pipeThrough(output); diff --git a/src/bridge/response-json.ts b/src/bridge/response-json.ts index a169e72256a..897dc853ad1 100644 --- a/src/bridge/response-json.ts +++ b/src/bridge/response-json.ts @@ -514,6 +514,7 @@ function buildResponseJSONWithBudget( } break; case "tool_call_end": + if (e.incomplete) { flushToolCall("incomplete"); break; } if (!toolCallArgumentsUsable(currentToolCallArgs) && currentToolCallId) { // Mirror the streaming path: refuse to complete unusable arguments. const mapped = options?.toolNsMap?.get(currentToolCallName); diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index 3b8116e0a55..00b9ccc9a3e 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -1178,6 +1178,7 @@ export function bridgeToResponsesSSE( break; } case "tool_call_end": { + if (event.incomplete) { failCurrentToolCall(); break; } // Fragments already streamed cannot be repaired. Refuse to complete a function call // whose assembled arguments do not parse — cancel the item and fail the turn so the // client never sees status:"completed" for unusable args (#765 stream remainder). diff --git a/src/protocols/encoders/adapter-events.ts b/src/protocols/encoders/adapter-events.ts index 4fc36ce90e3..265e816a4cc 100644 --- a/src/protocols/encoders/adapter-events.ts +++ b/src/protocols/encoders/adapter-events.ts @@ -682,6 +682,7 @@ export function encodeAdapterEventStream( break; } case "tool_call_end": { + if (event.incomplete) { failCurrentToolCall(); break; } // Streamed fragments cannot be repaired: unusable arguments fail the turn. if (currentToolCall && currentToolCall.kind === "function" && !toolCallArgumentsUsable(currentToolCall.args)) { failCurrentToolCall(); diff --git a/src/types/request.ts b/src/types/request.ts index b184d4ca450..ced5099d717 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -474,7 +474,7 @@ export type AdapterEvent = | { type: "reasoning_raw_delta"; text: string } | { type: "tool_call_start"; id: string; name: string; providerMetadata?: OcxProviderOpaqueToolCallMetadata } | { type: "tool_call_delta"; arguments: string } - | { type: "tool_call_end" } + | { type: "tool_call_end"; incomplete?: true } /** Internal boundary between a guarded first pass and its one-shot continuation. */ | { type: "assistant_boundary" } // Native web-search activity surfaced by the web-search sidecar so Codex renders a "Searched the diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 16e940dd4ed..836c04d358d 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -217,7 +217,7 @@ fixed at the bridge entry mark, before an effort override rewrites `thinking`. `src/protocols/encoders/` turns `AdapterEvent` streams into the Chat Completions and Anthropic Messages wires without the internal Responses SSE. It is server side and not a leaf. -`adapter-events.ts` is the driver: it ports the item state machine of `bridgeToResponsesSSE` +`adapter-events.ts` is the driver: it ports the item state machine of `bridgeToResponsesSSE`, including `tool_call_end.incomplete`, which closes an unfinished item without claiming argument completion (item boundaries, signature grouping, hidden and redacted reasoning envelopes, tool naming and argument gating, the integral-float repair, every terminal with its usage and durability rule, the wire-silence heartbeat and stall watchdog, pull-based stepping, cancellation) and calls one diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 0f164fe4362..efb63d0a122 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -208,7 +208,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge; optional `minMaxOutputTokens` raises only smaller explicit positive integer output budgets. Unset preserves caller caps. Serialization and pre-admission spend estimates share `effectiveAzureOutputBudget`; see the [spend contract](transports/responses-spend.md#durable-spend-reservations). | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | -| `src/adapters/zed.ts`, `src/providers/zed.ts`, `src/oauth/zed.ts` | Zed Hosted AI native-app RSA login, account-scoped short-lived LLM-token exchange, bounded live model discovery, and provider-envelope translation through the existing Anthropic, Google, Responses, and Chat builders. Buffered delivery retains events under the shared translator budget and cancels delegated streams on early exit. The Responses delegate applies destination compatibility, strips OpenAI-private fields and unsupported built-in tools, reconciles removed tool choices through the shared hosted-tool policy, and maps OpenAI `developer` messages to Zed-supported `system` messages before wrapping the request. `src/adapters/zed-openai-stream.ts` translates the OpenAI stream: the current turn's `additional_tools` are promoted on a detached copy before replay repair, interleaved function and custom calls stay buffered until the oldest open call's own terminal event because adapter deltas are unkeyed, and end of stream or an incomplete response flushes only arguments that already arrived. A completed compaction item keeps its ciphertext on the done event and transfers that retained lease. Experimental and unofficial; the Zed terms risk is intentionally surfaced in user docs and release review. | +| `src/adapters/zed.ts`, `src/providers/zed.ts`, `src/oauth/zed.ts` | Zed Hosted AI native-app RSA login, account-scoped short-lived LLM-token exchange, bounded live model discovery, and provider-envelope translation through the existing Anthropic, Google, Responses, and Chat builders. Buffered delivery retains events under the shared translator budget and cancels delegated streams on early exit. The Responses delegate applies destination compatibility, strips OpenAI-private fields and unsupported built-in tools, reconciles removed tool choices through the shared hosted-tool policy, and maps OpenAI `developer` messages to Zed-supported `system` messages before wrapping the request. `src/adapters/zed-openai-stream.ts` translates the OpenAI stream: the current turn's `additional_tools` are promoted on a detached copy before replay repair, interleaved function and custom calls stay buffered until the oldest open call's own terminal event because adapter deltas are unkeyed, and end of stream or an incomplete response flushes only arguments that already arrived, marking unfinished calls incomplete. Text and reasoning wait behind an open call; invisible heartbeats report buffered progress. Item aliases share one execution identity and conflicting reuse fails closed. Missing tool names fail the turn, and no output follows a terminal error. Per-item reasoning and done-only text fallback suppress completed replays. A completed compaction item keeps its ciphertext on the done event and transfers that retained lease. Arguments, full snapshots, dedupe identities, queued events, text and opaque usage are budgeted and released on failure or cancellation; native diagnostics use the captured credential scrubber. `src/adapters/zed-additional-tools.ts` owns tool promotion, while `src/adapters/openai-responses/usage.ts` shares usage projection with Responses compaction. `tests/providers/zed-openai-contract.test.ts` covers the relay and client-boundary regressions. Experimental and unofficial; the Zed terms risk is intentionally surfaced in user docs and release review. | | `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort anchors on the family's default member and selects the nearest enabled rung, rounding up first; an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays expressed as type-only `anyOf` branches in tool parameters. Shared constraints remain on the surrounding node, so nested schemas have one wire representation while outer enum/const, boolean constraints (including `not`, `oneOf`, and `allOf`) on null, and existing null-branch restrictions retain their conjunctive meaning. A null-only type keeps its null constraint even when an outer enum/const excludes null, preserving an unsatisfiable schema without producing an empty anyOf. Schema resource identifiers, anchors, definition maps, and existing anyOf branches remain on their original node. The type constraint is appended to existing allOf entries without shifting their indices, so JSON Pointer targets and reference scopes remain unchanged. A pre-output `invalid_argument` on a history whose word-piece estimate, using the sanitized and truncated tool descriptions actually sent on the wire, reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. The `COGNITION_BLOCKLIST_REWRITES` sanitizer rewrites exact phrases Cognition refuses with `permission_denied`; it runs only on instruction surfaces — tool descriptions and the #2 system prompt — while message text, replayed thinking (#11), and tool-call arguments stay byte-exact, because a rewrite there would silently change literal content such as patches or quoted file bytes; a trigger inside a data field surfaces as the upstream `permission_denied` instead (the Codex `` escalation boilerplate is the recorded #2 case). | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. Its registry entry is `deprecated`: existing saved providers keep the adapter and their stored configuration, while new-provider discovery (presets, the init menu, the featured list, and key logins) excludes it and `provider add` warns before creating one. | diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 13ae6cd51e5..2cce24bcdb6 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -371,3 +371,10 @@ charges each replacement event while its source batch remains retained; the brid the signed envelope and visible commentary pair through its existing output accounting. Explicitly selected canonical Codex Lite ordinary unreferenced history notes reuse `src/adapters/openai-responses/compaction-search-history.ts` and its 65,536-byte projection cap, included in final passthrough serialization accounting; original raw history and all non-hosted input remain untouched. The budget counts only actually projected cells; same-input referenced hosted cells do not consume the ordinary notes budget. Top-level and action sources share the existing per-field/list allowlists and total bound. + +The Zed OpenAI relay uses private per-call budget keys so client encoders cannot close its leases. +Each argument or full snapshot replacement reserves its complete new logical size before releasing +the old physical bytes; two retained copies spend the turn cap without doubling a call's logical limit. +Dedupe identities include an entry allowance, so even empty items remain bounded. Queued semantic +events and opaque usage are charged until emitted or replaced; cancellation, read failure and +identity refusal release every relay-owned buffer. Ciphertext alone transfers with its done lease. diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 476f2267128..584a29bff76 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -6,7 +6,7 @@ With a valid delay, `src/bridge/internal.ts` maps typed HTTP 429 `rate_limit_err ## Adapter incomplete reasons -`src/bridge/sse.ts`, `src/bridge/response-json.ts`, and `src/protocols/encoders/adapter-events.ts` normalize explicit incomplete reasons with `truncationReasonFor(reason) ?? reason`: known output-limit aliases become `max_output_tokens`, and refusal/filter aliases become `content_filter`. `src/responses/incomplete-reason.ts` applies the same classifier to native Responses JSON and SSE delivery in `src/server/responses/passthrough-delivery.ts` and to WebSocket frames in `src/server/responses/codex-ws-wire.ts`, including native control chains. It copies only changed `incomplete_details.reason` metadata, leaving output content and all other response fields intact. The compaction parser in `src/adapters/openai-responses/passthrough.ts` also normalizes its internal incomplete event. `tests/responses/incomplete-reason-normalization.test.ts` covers native HTTP delivery, WS terminal normalization and both adapter parser formats. +`src/bridge/sse.ts`, `src/bridge/response-json.ts`, and `src/protocols/encoders/adapter-events.ts` normalize explicit incomplete reasons with `truncationReasonFor(reason) ?? reason`: known output-limit aliases become `max_output_tokens`, and refusal/filter aliases become `content_filter`. `src/responses/incomplete-reason.ts` applies the same classifier to native Responses JSON and SSE delivery in `src/server/responses/passthrough-delivery.ts` and to WebSocket frames in `src/server/responses/codex-ws-wire.ts`, including native control chains. It copies only changed `incomplete_details.reason` metadata, leaving output content and all other response fields intact. The compaction parser in `src/adapters/openai-responses/passthrough.ts` also normalizes its internal incomplete event. `tests/responses/incomplete-reason-normalization.test.ts` covers native HTTP delivery, WS terminal normalization and both adapter parser formats. `tool_call_end.incomplete` preserves unfinished function/custom items without argument-done frames in SSE, buffered JSON and direct encoders; it does not itself terminate the turn or complete those arguments. Unknown reasons remain verbatim, including `adapter_eof`, `max_messages`, and `steered`; message, retryability, usage, and incomplete status are preserved. `tests/adapters/bridge-nonstreaming-terminal.test.ts` covers both Responses projections and real passthrough parsing; the direct Chat and Messages parity suites cover the client-wire outcomes. The Chat writer does not depend on that upstream pass alone: `chatCompletionsIncompleteOutcome` in `src/chat/outbound.ts` classifies through the same `truncationReasonFor` table for the SSE converter (and the collector that folds it), the direct encoder in `src/protocols/encoders/chat.ts` and `responsesJsonToChatCompletion`, so a raw alias such as Volcengine Ark's `length` still ends with `finish_reason: "length"` (or `content_filter`). Reasons outside the table, including `adapter_eof`, `upstream_stall_timeout`, `missing_terminal_event`, `max_messages` and `steered`, remain Chat errors. `tests/responses/chat-incomplete-length-reason.test.ts` covers the converter outcomes and the handler's streamed and buffered wire. ## Compaction image input diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 00a688001bc..c66fa65ac25 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -586,7 +586,7 @@ machine for those encoders, so a change to item boundaries, tool naming or termi keeps allowlisted transport verdict codes, the buffered combo [required-tool verdict and retry policy](responses-combo-boundaries.md#buffered-required-tool-contract), and the explicit local `spend_ledger_storage_unavailable` identity ([storage refusal contract](responses-spend.md#durable-spend-reservations)). Adapter error events take a different path: `src/bridge/internal.ts` preserves explicit verdicts except cyber-policy and known rate-limit mappings ([client retry advice](responses-wire-shapes.md#client-rate-limit-retry-advice)). Explicit incomplete reasons follow the [reason contract](responses-wire-shapes.md#adapter-incomplete-reasons). -The shared usage shaping's `input_tokens_details` and `output_tokens_details` are always emitted, +An explicit `tool_call_end.incomplete` closes only that item as incomplete without argument-completion frames or malformed-argument failure; the following turn terminal owns reason and usage. The shared usage shaping's `input_tokens_details` and `output_tokens_details` are always emitted, with zero defaults, because strict Responses clients deserialize them as required fields. Active-turn admission owns workflow admission, so both remain held until a streaming body finishes or is cancelled. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c7858ea3657..50035cc20b1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,6 @@ { "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "tool-identity-hash.test.ts": "adapters", + "zed-openai-contract.test.ts": "providers", "server-combo-required-tool-contract.test.ts": "server", "responses-anthropic-compaction-prefix.test.ts": "responses", "service-proxy-env.test.ts": "service", diff --git a/tests/providers/zed-hosted-provider.test.ts b/tests/providers/zed-hosted-provider.test.ts index 3f1f5acf5e1..4132c6880ec 100644 --- a/tests/providers/zed-hosted-provider.test.ts +++ b/tests/providers/zed-hosted-provider.test.ts @@ -575,6 +575,7 @@ describe("Zed OpenAI tool relay", () => { expect(events).toEqual([ { type: "tool_call_start", id: "call_a", name: "a" }, { type: "tool_call_delta", arguments: "{\"x\":" }, + { type: "heartbeat" }, { type: "tool_call_delta", arguments: "1}" }, { type: "tool_call_end" }, { type: "tool_call_start", id: "call_b", name: "b" }, @@ -598,6 +599,7 @@ describe("Zed OpenAI tool relay", () => { expect(events).toEqual([ { type: "tool_call_start", id: "call_a", name: "apply_patch" }, { type: "tool_call_delta", arguments: "left" }, + { type: "heartbeat" }, { type: "tool_call_delta", arguments: " side" }, { type: "tool_call_end" }, { type: "tool_call_start", id: "call_b", name: "apply_patch" }, @@ -625,7 +627,8 @@ describe("Zed OpenAI tool relay", () => { expect(events).toEqual([ { type: "tool_call_start", id: "call_a", name: "a" }, { type: "tool_call_delta", arguments: "{\"x\":" }, - { type: "tool_call_end" }, + { type: "heartbeat" }, + { type: "tool_call_end", incomplete: true }, { type: "tool_call_start", id: "call_b", name: "b" }, { type: "tool_call_delta", arguments: "{\"y\":2}" }, { type: "tool_call_end" }, @@ -649,8 +652,8 @@ describe("Zed OpenAI tool relay", () => { expect(events).toEqual([ { type: "tool_call_start", id: "call_a", name: "a" }, { type: "tool_call_delta", arguments: "{\"x\":" }, - { type: "tool_call_end" }, - { type: "done" }, + { type: "tool_call_end", incomplete: true }, + { type: "incomplete", reason: "adapter_eof" }, ]); }); diff --git a/tests/providers/zed-openai-contract.test.ts b/tests/providers/zed-openai-contract.test.ts new file mode 100644 index 00000000000..d19482d0363 --- /dev/null +++ b/tests/providers/zed-openai-contract.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, test } from "bun:test"; +import { translateZedOpenAiStream } from "../../src/adapters/zed-openai-stream"; +import { createTranslatorBudget, type TranslatorBudgetOptions } from "../../src/lib/translator-budget"; +import type { AdapterEvent } from "../../src/types"; +import { bridgeToResponsesSSE, buildResponseJSON } from "../../src/bridge"; +import { zedEventStream } from "../../src/adapters/zed"; +import { scrubZedCredentials } from "../../src/providers/zed"; +import { foldChatCompletion } from "../../src/protocols/encoders/chat"; + +// Each source chunk contains one frame, so the tests measure retained state separately. +function stream(frames: unknown[]): ReadableStream { + let index = 0; + return new ReadableStream({ + pull(controller) { + if (index === frames.length) controller.close(); + else controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(frames[index++])}\n\n`)); + }, + }); +} + +async function collect(frames: unknown[], options?: TranslatorBudgetOptions): Promise { + const budget = createTranslatorBudget(options); + try { + return await Array.fromAsync(translateZedOpenAiStream(stream(frames), budget)); + } finally { + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + budget.dispose(); + } +} + +describe("Zed OpenAI terminal contract", () => { + test.each(["text", "summary", "raw"])("intervening %s cannot close a tool before its final delta", async channel => { + const semantic = channel === "text" + ? { type: "response.output_text.delta", item_id: "msg", delta: "later" } + : { type: channel === "summary" ? "response.reasoning_summary_text.delta" : "response.reasoning_text.delta", item_id: "r", delta: "later" }; + const events = await collect([ + { type: "response.output_item.added", item: { type: "function_call", id: "fc", call_id: "call", name: "shell", arguments: "" } }, + { type: "response.function_call_arguments.delta", item_id: "fc", delta: '{"x":' }, semantic, + { type: "response.function_call_arguments.delta", item_id: "fc", delta: "1}" }, + { type: "response.function_call_arguments.done", item_id: "fc", arguments: '{"x":1}' }, + ]); + const semanticIndex = events.findIndex(event => event.type === (channel === "text" ? "text_delta" : channel === "summary" ? "thinking_delta" : "reasoning_raw_delta")); + expect(semanticIndex).toBeGreaterThan(events.findIndex(event => event.type === "tool_call_end")); + const result = buildResponseJSON(events, "fixture"); + expect(result.output).toContainEqual(expect.objectContaining({ type: "function_call", arguments: '{"x":1}', status: "completed" })); + }); + + test("a different item id replaying the same call cannot execute twice", async () => { + const call = { type: "function_call", call_id: "same", name: "shell", arguments: "{}" }; + const events = await collect([ + { type: "response.output_item.done", item: { ...call, id: "first" } }, + { type: "response.completed", response: { output: [{ ...call, id: "second" }] } }, + ]); + expect(events.filter(event => event.type === "tool_call_start")).toHaveLength(1); + }); + + test.each(["name", "type"])("conflicting call %s reuse fails closed", async field => { + const call = { type: "function_call", call_id: "same", name: "shell", arguments: "{}" }; + const events = await collect([ + { type: "response.output_item.done", item: { ...call, id: "first" } }, + { type: "response.completed", response: { output: [{ ...call, id: "second", [field]: field === "name" ? "other" : "custom_tool_call" }] } }, + ]); + expect(events.at(-1)?.type).toBe("error"); + expect(events.filter(event => event.type === "tool_call_start")).toHaveLength(1); + }); + + test("an unnamed completed call terminates before later items", async () => { + const events = await collect([{ type: "response.completed", response: { output: [ + { type: "function_call", id: "bad", arguments: "{}" }, + { type: "function_call", id: "good", call_id: "call", name: "shell", arguments: "{}" }, + ] } }]); + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("error"); + }); + + test("done-only text survives and is deduplicated on completion", async () => { + const events = await collect([ + { type: "response.output_text.done", item_id: "m", content_index: 0, text: "answer" }, + { type: "response.completed", response: { output: [{ type: "message", id: "m", content: [{ type: "output_text", text: "answer" }] }] } }, + ]); + expect(events).toEqual([{ type: "text_delta", text: "answer" }, { type: "done" }]); + }); + + test("done-only fallback keeps repeated text in distinct content parts", async () => { + const events = await collect([ + { type: "response.output_text.delta", item_id: "m", content_index: 0, delta: "same" }, + { type: "response.output_text.done", item_id: "m", content_index: 0, text: "same" }, + { type: "response.output_text.done", item_id: "m", content_index: 1, text: "same" }, + { type: "response.completed", response: { output: [] } }, + ]); + expect(events.filter(event => event.type === "text_delta").map(event => event.text).join("")) + .toBe("samesame"); + }); + + test.each(["error", "response.failed", "response.incomplete"])("native %s diagnostics scrub the captured credential", async type => { + const credentials = { userId: "fixture-user-42", accessToken: "fixture-token-xyz" }; + const message = "account fixture-user-42 token fixture-token-xyz"; + const payload = type === "error" ? { type, message } + : { type, response: { error: { message }, incomplete_details: { reason: message } } }; + const body = new Response(JSON.stringify({ event: payload }) + "\n" + JSON.stringify({ status: "stream_ended" }) + "\n").body!; + const budget = createTranslatorBudget(); + try { + const events = await Array.fromAsync(translateZedOpenAiStream(zedEventStream(body, "open_ai", text => scrubZedCredentials(text, credentials)), budget)); + expect(JSON.stringify(events)).not.toContain(credentials.accessToken); + expect(JSON.stringify(events)).not.toContain(credentials.userId); + } finally { budget.dispose(); } + }); + + test.each([false, true])("truncated tools preserve terminal reason and usage through the %s streaming bridge", async streaming => { + const budget = createTranslatorBudget(); + const events = translateZedOpenAiStream(stream([ + { type: "response.output_item.added", item: { id: "fc", type: "function_call", call_id: "call", name: "shell", arguments: "" } }, + { type: "response.function_call_arguments.delta", item_id: "fc", delta: '{"x":' }, + { type: "response.incomplete", response: { incomplete_details: { reason: "max_output_tokens" }, usage: { input_tokens: 7, output_tokens: 3 } } }, + ]), budget); + try { + const result = streaming + ? (await new Response(bridgeToResponsesSSE(events, "fixture")).text()).split("\n") + .filter(line => line.startsWith("data: {")) + .map(line => JSON.parse(line.slice(6))).findLast(event => event.type === "response.incomplete" || event.type === "response.failed")?.response + : buildResponseJSON(await Array.fromAsync(events), "fixture"); + expect(result).toMatchObject({ status: "incomplete", incomplete_details: { reason: "max_output_tokens" }, usage: { input_tokens: 7, output_tokens: 3 } }); + expect(result.output).toContainEqual(expect.objectContaining({ type: "function_call", arguments: '{"x":', status: "incomplete" })); + } finally { budget.dispose(); } + }); + + test("unfinished parallel calls keep their statuses and received input through both bridges", async () => { + const events = await collect([ + { type: "response.output_item.added", item: { id: "a", type: "function_call", call_id: "a", name: "shell", arguments: "" } }, + { type: "response.function_call_arguments.delta", item_id: "a", delta: "{}" }, + { type: "response.output_item.added", item: { id: "b", type: "function_call", call_id: "b", name: "shell", arguments: "" } }, + { type: "response.function_call_arguments.delta", item_id: "b", delta: '{"x":' }, + { type: "response.incomplete", response: { incomplete_details: { reason: "max_output_tokens" } } }, + ]); + for (const streaming of [false, true]) { + const result = streaming + ? (await new Response(bridgeToResponsesSSE((async function* () { yield* events; })(), "fixture")).text()).split("\n") + .filter(line => line.startsWith("data: {")) + .map(line => JSON.parse(line.slice(6))).findLast(event => event.type === "response.incomplete")?.response + : buildResponseJSON(events, "fixture"); + expect(result.output.filter((item: Record) => item.type === "function_call")) + .toEqual([expect.objectContaining({ call_id: "a", status: "incomplete", arguments: "{}" }), + expect.objectContaining({ call_id: "b", status: "incomplete", arguments: '{"x":' })]); + } + }); + + test("direct Chat encoding retains truncation and usage for an unfinished function", async () => { + const budget = createTranslatorBudget(); + try { + const response = await foldChatCompletion(translateZedOpenAiStream(stream([ + { type: "response.output_item.added", item: { id: "fc", type: "function_call", call_id: "call", name: "shell", arguments: "" } }, + { type: "response.function_call_arguments.delta", item_id: "fc", delta: '{"x":' }, + { type: "response.incomplete", response: { incomplete_details: { reason: "max_output_tokens" }, usage: { input_tokens: 7, output_tokens: 3 } } }, + ]), budget), { model: "fixture", translatorBudget: budget }); + expect(await response.json()).toMatchObject({ choices: [{ finish_reason: "length" }], usage: { prompt_tokens: 7, completion_tokens: 3 } }); + } finally { budget.dispose(); } + }); + + test("EOF with an unfinished call cannot claim a completed turn", async () => { + const events = await collect([ + { type: "response.output_item.added", item: { id: "fc", type: "function_call", call_id: "call", name: "shell", arguments: "" } }, + { type: "response.function_call_arguments.delta", item_id: "fc", delta: "{}" }, + ]); + expect(events.at(-1)).toEqual({ type: "incomplete", reason: "adapter_eof" }); + expect(buildResponseJSON(events, "fixture").output).toContainEqual(expect.objectContaining({ status: "incomplete" })); + }); + + test("buffered parallel progress emits an invisible heartbeat", async () => { + const events = await collect([ + { type: "response.output_item.added", item: { id: "a", type: "function_call", call_id: "call_a", name: "shell", arguments: "" } }, + { type: "response.output_item.added", item: { id: "b", type: "function_call", call_id: "call_b", name: "shell", arguments: "" } }, + { type: "response.function_call_arguments.delta", item_id: "b", delta: "{}" }, + { type: "response.function_call_arguments.done", item_id: "a", arguments: "{}" }, + { type: "response.function_call_arguments.done", item_id: "b", arguments: "{}" }, + ]); + expect(events[1]).toMatchObject({ type: "heartbeat" }); + expect(events.filter(event => event.type === "tool_call_start").map(event => event.id)).toEqual(["call_a", "call_b"]); + }); + + test.each([ + { input_tokens: 2, output_tokens: 1, input_tokens_details: { cache_write_tokens: 4, cache_write_1h_tokens: 3 }, vendor: { fixture: true } }, + { vendor: { fixture: true } }, + ])("usage retains cache writes and opaque metadata: %j", async usage => { + const events = await collect([{ type: "response.completed", response: { output: [], usage } }]); + expect(events.at(-1)).toMatchObject({ type: "done", usage: { rawUsage: usage } }); + if ("input_tokens" in usage) expect(events.at(-1)).toMatchObject({ usage: { cacheCreationInputTokens: 4, cacheCreation1hInputTokens: 3 } }); + }); + + test("a failed response keeps usage without completing an open tool", async () => { + expect(await collect([ + { type: "response.output_item.added", item: { id: "fc", type: "function_call", call_id: "call", name: "shell", arguments: "" } }, + { type: "response.function_call_arguments.delta", item_id: "fc", delta: "{" }, + { type: "response.failed", response: { error: { message: "failure" }, usage: { input_tokens: 7, output_tokens: 3 } } }, + ])).toEqual([ + { type: "tool_call_start", id: "call", name: "shell" }, + { type: "tool_call_delta", arguments: "{" }, + { type: "error", message: "failure", usage: { inputTokens: 7, outputTokens: 3 } }, + ]); + }); + + test("incomplete aliases use the shared max_output_tokens contract", async () => { + expect(await collect([{ type: "response.incomplete", response: { incomplete_details: { reason: "length" } } }])) + .toEqual([{ type: "incomplete", reason: "max_output_tokens" }]); + }); + + test("a tool with no name fails instead of disappearing into a successful turn", async () => { + const events = await collect([ + { type: "response.function_call_arguments.delta", item_id: "fc", delta: "{}" }, + { type: "response.function_call_arguments.done", item_id: "fc", arguments: "{}" }, + ]); + expect(events).toEqual([ + { type: "heartbeat" }, + expect.objectContaining({ type: "error", message: expect.stringContaining("without a function name") }), + ]); + }); + + test("reasoning fallback is scoped to each output item", async () => { + expect(await collect([ + { type: "response.reasoning_summary_text.delta", item_id: "r1", delta: "first" }, + { type: "response.reasoning_text.delta", item_id: "r1", delta: "raw first" }, + { type: "response.output_item.done", item: { type: "reasoning", id: "r1", summary: [{ text: "first" }], content: [{ type: "reasoning_text", text: "raw first" }] } }, + { type: "response.output_item.done", item: { type: "reasoning", id: "r2", summary: [{ text: "second" }], content: [{ type: "reasoning_text", text: "raw second" }] } }, + { type: "response.completed", response: { output: [ + { type: "reasoning", id: "r2", summary: [{ text: "second" }], content: [{ type: "reasoning_text", text: "raw second" }] }, + ] } }, + ])).toEqual([ + { type: "thinking_delta", thinking: "first" }, + { type: "reasoning_raw_delta", text: "raw first" }, + { type: "thinking_delta", thinking: "second" }, + { type: "reasoning_raw_delta", text: "raw second" }, + { type: "done" }, + ]); + }); +}); + +describe("Zed OpenAI retention bounds", () => { + test("replacing a full snapshot with a shorter one restores per-call headroom", async () => { + const events = await collect([ + { type: "response.output_item.added", item: { type: "function_call", id: "fc", call_id: "call", name: "shell", arguments: "x".repeat(20) } }, + { type: "response.output_item.added", item: { type: "function_call", id: "fc", call_id: "call", name: "shell", arguments: "x" } }, + { type: "response.function_call_arguments.delta", item_id: "fc", delta: "x".repeat(15) }, + { type: "response.function_call_arguments.done", item_id: "fc" }, + ], { maxCallArgumentBytes: 30 }); + expect(events.at(-1)).toEqual({ type: "done" }); + }); + + test.each(["function_call", "message"])("empty %s identities cannot grow outside the turn budget", async type => { + const frames = Array.from({ length: 30 }, (_, index) => ({ + type: "response.output_item.done", + item: type === "function_call" + ? { type, id: `fc_${index}_${"x".repeat(100)}`, call_id: `call_${index}`, name: "shell", arguments: "" } + : { type, id: `msg_${index}_${"x".repeat(100)}`, phase: "commentary", content: [] }, + })); + await expect(collect(frames, { maxTurnBytes: 2048 })).rejects.toMatchObject({ code: "translation_buffer_limit" }); + }); + + test("a full snapshot beyond the per-call limit releases on overflow", async () => { + await expect(collect([ + { type: "response.output_item.added", item: { type: "function_call", id: "fc", call_id: "call", name: "shell", arguments: "x".repeat(31) } }, + ], { maxCallArgumentBytes: 30 })).rejects.toMatchObject({ code: "translation_buffer_limit" }); + }); + + test("an identical done snapshot does not double the logical per-call size", async () => { + expect((await collect([ + { type: "response.output_item.added", item: { type: "custom_tool_call", id: "fc", call_id: "call", name: "apply_patch", input: "" } }, + { type: "response.custom_tool_call_input.delta", item_id: "fc", delta: "x".repeat(30) }, + { type: "response.custom_tool_call_input.done", item_id: "fc", input: "x".repeat(30) }, + ], { maxCallArgumentBytes: 30 })).at(-1)).toEqual({ type: "done" }); + }); + + test("a done snapshot equal to streamed input replaces an older speculative full value", async () => { + const events = await collect([ + { type: "response.output_item.added", item: { type: "custom_tool_call", id: "fc", call_id: "call", name: "apply_patch", input: "prefix-stale" } }, + { type: "response.custom_tool_call_input.delta", item_id: "fc", delta: "prefix" }, + { type: "response.custom_tool_call_input.done", item_id: "fc", input: "prefix" }, + ]); + expect(events.filter(event => event.type === "tool_call_delta").map(event => event.arguments).join("")) + .toBe("prefix"); + }); + + test("queued semantic events and open calls release on cancellation", async () => { + const budget = createTranslatorBudget(); + const iterator = translateZedOpenAiStream(stream([ + { type: "response.output_item.added", item: { type: "function_call", id: "fc", call_id: "call", name: "shell", arguments: "" } }, + { type: "response.output_text.delta", item_id: "m", delta: "held" }, + ]), budget); + try { + expect((await iterator.next()).value?.type).toBe("tool_call_start"); + expect((await iterator.next()).value?.type).toBe("heartbeat"); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + await iterator.return(undefined); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + } finally { budget.dispose(); } + }); + + test("metadata-only usage remains charged until its terminal event is consumed", async () => { + const usage = { vendor: { large: "x".repeat(500) } }; + const budget = createTranslatorBudget(); + const iterator = translateZedOpenAiStream(stream([ + { type: "response.completed", response: { output: [], usage } }, + ]), budget); + try { + expect((await iterator.next()).value).toMatchObject({ type: "done", usage: { rawUsage: usage } }); + expect(budget.snapshot().currentBytes).toBe(Buffer.byteLength(JSON.stringify(usage))); + await iterator.return(undefined); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { budget.dispose(); } + }); + + test("split surrogate fragments retain their exact UTF-8 argument size", async () => { + const events = await collect([ + { type: "response.output_item.added", item: { type: "custom_tool_call", id: "fc", call_id: "call", name: "apply_patch", input: "" } }, + { type: "response.custom_tool_call_input.delta", item_id: "fc", delta: "\ud83d" }, + { type: "response.custom_tool_call_input.delta", item_id: "fc", delta: "\ude00" }, + { type: "response.custom_tool_call_input.done", item_id: "fc" }, + ], { maxCallArgumentBytes: 4 }); + expect(events.filter(event => event.type === "tool_call_delta").map(event => event.arguments).join("")) + .toBe("😀"); + expect(events.at(-1)).toEqual({ type: "done" }); + }); +}); From ea538e10e0ddecb2e4f5beb8c17391ef19cb7da4 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:59:22 +0900 Subject: [PATCH 07/13] style(zed): normalize relay file ending --- src/adapters/zed-openai-stream.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/adapters/zed-openai-stream.ts b/src/adapters/zed-openai-stream.ts index 08d87d79d05..ebeb4484a8e 100644 --- a/src/adapters/zed-openai-stream.ts +++ b/src/adapters/zed-openai-stream.ts @@ -621,4 +621,3 @@ export async function* translateZedOpenAiStream( releaseStreamBuffers(); } } - From 194a33b3166ccda4319c78077c7eb5ed2f0de418 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:02:49 +0900 Subject: [PATCH 08/13] fix(zed): retain explicitly incomplete tool item status --- src/adapters/zed-openai-stream.ts | 7 +++++-- tests/providers/zed-openai-contract.test.ts | 9 +++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/adapters/zed-openai-stream.ts b/src/adapters/zed-openai-stream.ts index ebeb4484a8e..7d86e0eadc2 100644 --- a/src/adapters/zed-openai-stream.ts +++ b/src/adapters/zed-openai-stream.ts @@ -30,6 +30,7 @@ interface CallState { started: boolean; ended: boolean; closed: boolean; + incomplete: boolean; /** True only after this call's own done or completed item, not after EOF. */ allowFull: boolean; } @@ -167,7 +168,7 @@ export async function* translateZedOpenAiStream( if (!next.closed) return; next.ended = true; next.finalHash = createHash("sha256").update(text).digest("hex"); - yield { type: "tool_call_end", ...(!next.allowFull ? { incomplete: true } : {}) }; + yield { type: "tool_call_end", ...(!next.allowFull || next.incomplete ? { incomplete: true } : {}) }; releaseCall(next); for (const entry of next.after) { budget.releaseRetained(entry.bytes, { kind: "retained_collectors" }); @@ -314,6 +315,7 @@ export async function* translateZedOpenAiStream( started: false, ended: false, closed: false, + incomplete: false, allowFull: false, }; calls.set(key, state); @@ -443,6 +445,7 @@ export async function* translateZedOpenAiStream( const callId = typeof item.call_id === "string" ? item.call_id : key; const name = typeof item.name === "string" ? item.name : ""; const state = ensureCall(key, callId, name, item.type); + if (item.status === "incomplete") state.incomplete = true; rememberFull(state, item.type === "custom_tool_call" ? item.input : item.arguments); if (done) yield* closeCall(state, true); else yield* pump(); @@ -598,7 +601,7 @@ export async function* translateZedOpenAiStream( if (terminal) return; } - const unfinished = [...calls.values()].some(state => !state.closed); + const unfinished = [...calls.values()].some(state => !state.closed || state.incomplete); yield* flushUnclosed(false); if (terminal) return; if (unfinished) { diff --git a/tests/providers/zed-openai-contract.test.ts b/tests/providers/zed-openai-contract.test.ts index d19482d0363..183813e88e9 100644 --- a/tests/providers/zed-openai-contract.test.ts +++ b/tests/providers/zed-openai-contract.test.ts @@ -165,6 +165,15 @@ describe("Zed OpenAI terminal contract", () => { expect(buildResponseJSON(events, "fixture").output).toContainEqual(expect.objectContaining({ status: "incomplete" })); }); + test("an explicitly incomplete custom item keeps its confirmed input and incomplete status", async () => { + const events = await collect([ + { type: "response.output_item.done", item: { type: "custom_tool_call", id: "fc", call_id: "call", name: "apply_patch", input: "partial", status: "incomplete" } }, + { type: "response.incomplete", response: { incomplete_details: { reason: "max_output_tokens" } } }, + ]); + const result = buildResponseJSON(events, "fixture", { freeformToolNames: new Set(["apply_patch"]) }); + expect(result.output).toContainEqual(expect.objectContaining({ type: "custom_tool_call", input: "partial", status: "incomplete" })); + }); + test("buffered parallel progress emits an invisible heartbeat", async () => { const events = await collect([ { type: "response.output_item.added", item: { id: "a", type: "function_call", call_id: "call_a", name: "shell", arguments: "" } }, From 45b77da1ec0ad4b14e88f9a37ef5864d34e4e0c7 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:13:56 +0900 Subject: [PATCH 09/13] fix(zed): scrub native diagnostics and fence unfinished sidecar calls --- .../openai-chat/required-tool-result.ts | 2 +- src/adapters/zed.ts | 17 ++++++------- src/images/loop.ts | 16 ++++++++---- src/server/responses/advisor-slot.ts | 6 +++-- src/web-search/anthropic-history.ts | 1 + src/web-search/loop.ts | 11 +++++--- structure/advisor.md | 2 +- structure/providers-and-adapters.md | 4 +-- structure/providers/anthropic-progress.md | 2 +- .../providers/chat-required-tool-results.md | 2 +- structure/transports/inventory.md | 2 +- .../openai/openai-chat-required-tool.test.ts | 16 ++++++++++++ tests/advisor/advisor-retention.test.ts | 23 +++++++++++++++++ tests/images/loop-terminal-refusal.test.ts | 12 +++++---- tests/providers/zed-openai-contract.test.ts | 25 +++++++++++++++++++ .../web-search-terminal-refusal.test.ts | 12 +++++---- 16 files changed, 116 insertions(+), 37 deletions(-) diff --git a/src/adapters/openai-chat/required-tool-result.ts b/src/adapters/openai-chat/required-tool-result.ts index 180af96e7c4..c83ef66e65d 100644 --- a/src/adapters/openai-chat/required-tool-result.ts +++ b/src/adapters/openai-chat/required-tool-result.ts @@ -18,7 +18,7 @@ function terminalGuard(required: boolean): (event: AdapterEvent) => AdapterEvent let failed = false; return event => { if (event.type === "tool_call_start") pending = true; - if (event.type === "tool_call_end") { fulfilled ||= pending; pending = false; } + if (event.type === "tool_call_end") { fulfilled ||= pending && !event.incomplete; pending = false; } if (event.type === "error" || event.type === "incomplete") failed = true; if (event.type !== "done" || !required || fulfilled || failed || isTruncatedStopReason(event.stopReason)) return event; // Preserve delivered output and consumption. A mandatory call is a contract, not a hint diff --git a/src/adapters/zed.ts b/src/adapters/zed.ts index b906a01302f..9129a81770c 100644 --- a/src/adapters/zed.ts +++ b/src/adapters/zed.ts @@ -236,20 +236,19 @@ export function zedEventStream( } if (isNativeTerminalEvent(event)) sawNativeTerminal = true; // Native diagnostics can echo the same account credential as status failures. - const diagnostic = event.type === "error" || event.type === "response.failed" || event.type === "response.incomplete"; - const scrubFields = (record: Record): Record => ({ - ...record, - ...(typeof record.message === "string" ? { message: scrub(record.message) } : {}), - ...(typeof record.reason === "string" ? { reason: scrub(record.reason) } : {}), - }); - const scrubRecord = (record: Record): Record => ({ - ...scrubFields(record), + const diagnostic = Object.hasOwn(event, "error") || event.type === "response.failed" || event.type === "response.incomplete" + || event.type === "error" || (isRecord(event.response) && Object.hasOwn(event.response, "error")); + const scrubFields = (record: Record, preserveType = false): Record => Object.fromEntries( + Object.entries(record).map(([key, value]) => [key, typeof value === "string" && !(preserveType && key === "type") ? scrub(value) : value]), + ); + const scrubRecord = (record: Record, preserveType = false): Record => ({ + ...scrubFields(record, preserveType), ...(typeof record.error === "string" ? { error: scrub(record.error) } : isRecord(record.error) ? { error: scrubFields(record.error) } : {}), ...(isRecord(record.incomplete_details) ? { incomplete_details: scrubFields(record.incomplete_details) } : {}), }); emit(controller, diagnostic ? { - ...scrubRecord(event), + ...scrubRecord(event, true), ...(isRecord(event.response) ? { response: scrubRecord(event.response) } : {}), } : event); } diff --git a/src/images/loop.ts b/src/images/loop.ts index 5dd189f8398..6ef60ca7738 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -24,7 +24,7 @@ import { isAnthropicInstanceId } from "../providers/anthropic-instance-id"; import { cloneProviderOpaqueToolCallMetadata } from "../responses/provider-opaque-metadata"; import type { AttemptRecoveryKind } from "../usage/log"; import { bridgeToResponsesSSE } from "../bridge"; -import { isTerminalRefusalIncomplete } from "../bridge/terminal-refusal"; +import { isTruncatedStopReason } from "../responses/truncated-stop-reason"; import { clearableDeadline, idleDeadline } from "../lib/abort"; import { readBoundedResponseBody } from "../lib/bounded-body"; import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait, isNonReplayableResponse, isSameTargetRetryHold, SendBudgetExhaustedError } from "../lib/upstream-retry"; @@ -172,10 +172,12 @@ function scanEventsForImageCall(events: AdapterEvent[], toolNames: Set): calls: ImageCall[]; passthrough: AdapterEvent[]; hasRealToolCall: boolean; + hasIncompleteToolCall: boolean; } { const calls: ImageCall[] = []; const passthrough: AdapterEvent[] = []; let hasRealToolCall = false; + let hasIncompleteToolCall = false; let pending: { name: string; id: string; argsBuf: string; events: AdapterEvent[]; providerMetadata?: OcxProviderOpaqueToolCallMetadata } | null = null; const flushPending = (): void => { if (!pending) return; @@ -198,7 +200,10 @@ function scanEventsForImageCall(events: AdapterEvent[], toolNames: Set): pending.events.push(e); } else if (e.type === "tool_call_end" && pending) { pending.events.push(e); - if (toolNames.has(pending.name)) { + if (e.incomplete) { + hasIncompleteToolCall = true; + passthrough.push(...pending.events); + } else if (toolNames.has(pending.name)) { calls.push({ id: pending.id, name: pending.name, args: pending.argsBuf, providerMetadata: pending.providerMetadata }); } else { passthrough.push(...pending.events); @@ -211,7 +216,7 @@ function scanEventsForImageCall(events: AdapterEvent[], toolNames: Set): } } flushPending(); - return { calls, passthrough, hasRealToolCall }; + return { calls, passthrough, hasRealToolCall, hasIncompleteToolCall }; } async function* replay(events: AdapterEvent[]): AsyncGenerator { @@ -974,8 +979,9 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise 0 && !split.hasRealToolCall && !forceFinal - && !split.passthrough.some(isTerminalRefusalIncomplete); + const shouldLoop = split.calls.length > 0 && !split.hasRealToolCall && !split.hasIncompleteToolCall && !forceFinal + && !split.passthrough.some(event => event.type === "incomplete" || event.type === "error" + || (event.type === "done" && isTruncatedStopReason(event.stopReason))); if (!shouldLoop) { if (hiddenUsage || rotatedUsage) { for (let i = split.passthrough.length - 1; i >= 0; i--) { diff --git a/src/server/responses/advisor-slot.ts b/src/server/responses/advisor-slot.ts index 89f8bd28895..8ae9a31d825 100644 --- a/src/server/responses/advisor-slot.ts +++ b/src/server/responses/advisor-slot.ts @@ -183,6 +183,7 @@ export function createAdvisorGuard(plan: AdvisorPlan): NonNullable | undefined; @@ -214,9 +215,10 @@ export function createAdvisorGuard(plan: AdvisorPlan): NonNullable 0 - && !hasRealToolCall; + && !hasRealToolCall && !hasIncompleteToolCall; if (!shouldIntercept) { // Plain leg: surface the terminal with merged usage from any earlier intercepted legs. diff --git a/src/web-search/anthropic-history.ts b/src/web-search/anthropic-history.ts index 49abfd2f8bd..87ad37a80d0 100644 --- a/src/web-search/anthropic-history.ts +++ b/src/web-search/anthropic-history.ts @@ -46,6 +46,7 @@ export function anthropicSearchAssistantContent(events: readonly AdapterEvent[]) argumentsText += event.arguments; break; case "tool_call_end": { + if (event.incomplete) throw new Error("unfinished tool call cannot enter search continuation history"); if (!call) throw new Error("search history has an unpaired tool call"); const args: unknown = JSON.parse(argumentsText || "{}"); if (!args || typeof args !== "object" || Array.isArray(args)) throw new Error("search history tool input is not an object"); diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 83bcadecb31..00e36531e78 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -11,7 +11,6 @@ import { cloneProviderOpaqueToolCallMetadata } from "../responses/provider-opaqu import type { AttemptRecoveryKind } from "../usage/log"; import { isTruncatedStopReason } from "../responses/truncated-stop-reason"; import { bridgeToResponsesSSE } from "../bridge"; -import { isTerminalRefusalIncomplete } from "../bridge/terminal-refusal"; import { guardEmptyToolCatalogStream, type ClientToolCatalogRestriction } from "../lib/empty-tool-catalog"; import { mergeUsage } from "../server/responses/empty-completion-guard"; import { markSidecarContextOverflow, sidecarLoopTerminalEvent, upstreamErrorCode, type SidecarLoopFailure } from "../server/responses/context-overflow"; @@ -123,7 +122,10 @@ export function scanEventsForWebSearch(events: AdapterEvent[]): { } else { pending.events.push(e); pending.closed = true; - if (pending.name === WEB_SEARCH_TOOL_NAME) { + if (e.incomplete) { + hasMalformedToolCall = true; + passthrough.push(...pending.events); + } else if (pending.name === WEB_SEARCH_TOOL_NAME) { calls.push({ id: pending.id, queries: parseQueries(pending.argsBuf), providerMetadata: pending.providerMetadata }); } else { passthrough.push(...pending.events); @@ -1070,8 +1072,9 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise 0 && !split.hasRealToolCall - && !split.passthrough.some(isTerminalRefusalIncomplete); + const shouldLoop = split.calls.length > 0 && !split.hasRealToolCall && !split.hasMalformedToolCall + && !split.passthrough.some(event => event.type === "incomplete" || event.type === "error" + || (event.type === "done" && isTruncatedStopReason(event.stopReason))); if (!shouldLoop) { // #1001: a forced-answer pass that ends `done` must have produced // usable output — never a malformed tool call, and never silence. diff --git a/structure/advisor.md b/structure/advisor.md index 6c070725acf..b6d07b21f26 100644 --- a/structure/advisor.md +++ b/structure/advisor.md @@ -45,7 +45,7 @@ multi-call pairing, overlap and lifecycle release. - Translated (non-passthrough, non-run-turn) fetch path: full support — synthetic tool injection, guard interception of `advisor` tool calls, advice reinjection as a paired assistant-toolCall/toolResult message pair, and worker re-dispatch through the same - continuation machinery the terminal guard uses (`adapter-continuation.ts`). Consultations are + continuation machinery the terminal guard uses (`adapter-continuation.ts`). Explicitly unfinished tool ends cannot trigger consultation or continuation. Consultations are bounded at three per request including automatic calls, with at most four Advisor-owned worker continuations. The tool is removed when consultations are exhausted. A repeated call gets one final paired limit result; a further call ends with typed 502 `advisor_continuation_limit`, without another worker send. diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index efb63d0a122..53376cfc7b4 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -208,7 +208,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge; optional `minMaxOutputTokens` raises only smaller explicit positive integer output budgets. Unset preserves caller caps. Serialization and pre-admission spend estimates share `effectiveAzureOutputBudget`; see the [spend contract](transports/responses-spend.md#durable-spend-reservations). | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | -| `src/adapters/zed.ts`, `src/providers/zed.ts`, `src/oauth/zed.ts` | Zed Hosted AI native-app RSA login, account-scoped short-lived LLM-token exchange, bounded live model discovery, and provider-envelope translation through the existing Anthropic, Google, Responses, and Chat builders. Buffered delivery retains events under the shared translator budget and cancels delegated streams on early exit. The Responses delegate applies destination compatibility, strips OpenAI-private fields and unsupported built-in tools, reconciles removed tool choices through the shared hosted-tool policy, and maps OpenAI `developer` messages to Zed-supported `system` messages before wrapping the request. `src/adapters/zed-openai-stream.ts` translates the OpenAI stream: the current turn's `additional_tools` are promoted on a detached copy before replay repair, interleaved function and custom calls stay buffered until the oldest open call's own terminal event because adapter deltas are unkeyed, and end of stream or an incomplete response flushes only arguments that already arrived, marking unfinished calls incomplete. Text and reasoning wait behind an open call; invisible heartbeats report buffered progress. Item aliases share one execution identity and conflicting reuse fails closed. Missing tool names fail the turn, and no output follows a terminal error. Per-item reasoning and done-only text fallback suppress completed replays. A completed compaction item keeps its ciphertext on the done event and transfers that retained lease. Arguments, full snapshots, dedupe identities, queued events, text and opaque usage are budgeted and released on failure or cancellation; native diagnostics use the captured credential scrubber. `src/adapters/zed-additional-tools.ts` owns tool promotion, while `src/adapters/openai-responses/usage.ts` shares usage projection with Responses compaction. `tests/providers/zed-openai-contract.test.ts` covers the relay and client-boundary regressions. Experimental and unofficial; the Zed terms risk is intentionally surfaced in user docs and release review. | +| `src/adapters/zed.ts`, `src/providers/zed.ts`, `src/oauth/zed.ts` | Zed Hosted AI native-app RSA login, account-scoped short-lived LLM-token exchange, bounded live model discovery, and provider-envelope translation through the existing Anthropic, Google, Responses, and Chat builders. Buffered delivery retains events under the shared translator budget and cancels delegated streams on early exit. The Responses delegate applies destination compatibility, strips OpenAI-private fields and unsupported built-in tools, reconciles removed tool choices through the shared hosted-tool policy, and maps OpenAI `developer` messages to Zed-supported `system` messages before wrapping the request. `src/adapters/zed-openai-stream.ts` translates the OpenAI stream: the current turn's `additional_tools` are promoted on a detached copy before replay repair, interleaved function and custom calls stay buffered until the oldest open call's own terminal event because adapter deltas are unkeyed, and end of stream or an incomplete response flushes only arguments that already arrived, marking unfinished calls incomplete. Text and reasoning wait behind an open call; invisible heartbeats report buffered progress. Item aliases share one execution identity and conflicting reuse fails closed. Missing tool names fail the turn, and no output follows a terminal error. Per-item reasoning and done-only text fallback suppress completed replays. A completed compaction item keeps its ciphertext on the done event and transfers that retained lease. Arguments, full snapshots, dedupe identities, queued events, text and opaque usage are budgeted and released on failure or cancellation; typed and untyped native error envelopes scrub every diagnostic string, including code/type, with the captured credential scrubber. `src/adapters/zed-additional-tools.ts` owns tool promotion, while `src/adapters/openai-responses/usage.ts` shares usage projection with Responses compaction. `tests/providers/zed-openai-contract.test.ts` covers the relay and client-boundary regressions. Experimental and unofficial; the Zed terms risk is intentionally surfaced in user docs and release review. | | `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort anchors on the family's default member and selects the nearest enabled rung, rounding up first; an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays expressed as type-only `anyOf` branches in tool parameters. Shared constraints remain on the surrounding node, so nested schemas have one wire representation while outer enum/const, boolean constraints (including `not`, `oneOf`, and `allOf`) on null, and existing null-branch restrictions retain their conjunctive meaning. A null-only type keeps its null constraint even when an outer enum/const excludes null, preserving an unsatisfiable schema without producing an empty anyOf. Schema resource identifiers, anchors, definition maps, and existing anyOf branches remain on their original node. The type constraint is appended to existing allOf entries without shifting their indices, so JSON Pointer targets and reference scopes remain unchanged. A pre-output `invalid_argument` on a history whose word-piece estimate, using the sanitized and truncated tool descriptions actually sent on the wire, reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. The `COGNITION_BLOCKLIST_REWRITES` sanitizer rewrites exact phrases Cognition refuses with `permission_denied`; it runs only on instruction surfaces — tool descriptions and the #2 system prompt — while message text, replayed thinking (#11), and tool-call arguments stay byte-exact, because a rewrite there would silently change literal content such as patches or quoted file bytes; a trigger inside a data field surfaces as the upstream `permission_denied` instead (the Codex `` escalation boilerplate is the recorded #2 case). | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. Its registry entry is `deprecated`: existing saved providers keep the adapter and their stored configuration, while new-provider discovery (presets, the init menu, the featured list, and key logins) excludes it and `provider add` warns before creating one. | @@ -496,7 +496,7 @@ recovery probe lease no search consumed is always returned. A forward OpenAI search sidecar retries a 429 only when the requested delay fits both its retry ceiling and the remaining overall sidecar deadline. A delay that cannot fit returns and records the original 429 so pool routing retains quota evidence. One search makes at most three physical sends in total: connection-reset recovery and 429 replays draw from the same budget, and a budget spent with a 429 in hand ends with that 429 as the recorded outcome. OpenAI helpers recheck the resolved [stored-account credit policy](codex-account-controls.md#stored-account-authentication-policy) immediately before every physical send, including those retries. An unchanged caller-owned Direct credential is not governed by stored-pool consent. -A leg whose +An explicitly unfinished tool end is passed through and never executes. A leg whose upstream terminal is `response.failed` or `response.incomplete` runs no search at all and closes any cell it opened rather than leaving it in progress. Assistant text is not treated as a search instruction. diff --git a/structure/providers/anthropic-progress.md b/structure/providers/anthropic-progress.md index 5c49a7acb86..952068c4caf 100644 --- a/structure/providers/anthropic-progress.md +++ b/structure/providers/anthropic-progress.md @@ -32,4 +32,4 @@ The hosted tool loops in `src/web-search/loop.ts`, `src/web-search/run-turn-loop `src/images/loop.ts` reconstruct progress events as signed thinking parts. Search's early commentary path retains these events for the next iteration. Intercepted search and image iterations emit their validated signed progress before fulfilling -the hosted calls, retaining the same blocks for replay and avoiding duplicate live updates. +the hosted calls, retaining the same blocks for replay and avoiding duplicate live updates. Unfinished calls never become executable search/media/advisor continuations; search history rejects an explicitly incomplete tool end. diff --git a/structure/providers/chat-required-tool-results.md b/structure/providers/chat-required-tool-results.md index 03ea9b10f3c..92a6433c6b2 100644 --- a/structure/providers/chat-required-tool-results.md +++ b/structure/providers/chat-required-tool-results.md @@ -1,5 +1,5 @@ # Mandatory Translated Chat Tool Results -`src/adapters/openai-chat/required-tool-result.ts` requires a completed client tool call before a clean terminal for required, named or required allowed-tool choices. Text and reasoning do not satisfy it. The existing response bridge owns selected-name authorization and alias restoration, so valid namespace/code-mode echoes keep their existing interpretation. Missing required calls become a non-retryable `required_tool_not_called` error with upstream usage preserved. Earlier output stays delivered and the guard neither buffers a stream nor sends another request. Existing errors and truncated outcomes retain their classification; auto/none remain unchanged. Sidecar-bearing catalogs bypass this client-call guard because their execution owner governs completion. Native passthrough and other adapters retain their existing contracts. When the failure ends a stream that produced no output, combo stream preflight (`src/server/responses/combo-stream-preflight.ts`) treats a `response.failed` carrying `required_tool_not_called` as final, so the combo does not send the same turn to another target. +`src/adapters/openai-chat/required-tool-result.ts` requires a completed client tool call before a clean terminal for required, named or required allowed-tool choices. Text, reasoning and explicitly incomplete tool ends do not satisfy it. The existing response bridge owns selected-name authorization and alias restoration, so valid namespace/code-mode echoes keep their existing interpretation. Missing required calls become a non-retryable `required_tool_not_called` error with upstream usage preserved. Earlier output stays delivered and the guard neither buffers a stream nor sends another request. Existing errors and truncated outcomes retain their classification; auto/none remain unchanged. Sidecar-bearing catalogs bypass this client-call guard because their execution owner governs completion. Native passthrough and other adapters retain their existing contracts. When the failure ends a stream that produced no output, combo stream preflight (`src/server/responses/combo-stream-preflight.ts`) treats a `response.failed` carrying `required_tool_not_called` as final, so the combo does not send the same turn to another target. > Decision record: [ADR-7456](../decisions/ADR-7456-mandatory-chat-tool-results.md) diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 6a6cc614f6c..f2299513437 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -90,7 +90,7 @@ not cap process RSS or the conversation messages accumulated across completed me opaque metadata, normal tool passthrough, coalesced-tail accounting, and consumer cancellation on both execution paths. Final client tool output also follows the [explicit empty client-catalog boundary](responses-wire-shapes.md#declared-tool-membership-by-inbound-wire); internal media tool -execution keeps its existing behavior. +execution rejects explicitly unfinished tool ends and never fulfills media or re-asks after an incomplete, failed or truncated terminal. ## Bounded response ingestion and OrcaRouter login diff --git a/tests/adapters/openai/openai-chat-required-tool.test.ts b/tests/adapters/openai/openai-chat-required-tool.test.ts index 4d8e0359e5f..6ec76cb51de 100644 --- a/tests/adapters/openai/openai-chat-required-tool.test.ts +++ b/tests/adapters/openai/openai-chat-required-tool.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; +import { withRequiredChatToolResult } from "../../../src/adapters/openai-chat/required-tool-result"; import { createTestTranslatorBudget } from "../../helpers/translator-budget"; import type { AdapterEvent, OcxParsedRequest, OcxToolChoice } from "../../../src/types"; @@ -7,6 +8,21 @@ const usage = { prompt_tokens: 7, completion_tokens: 2, total_tokens: 9 }; const tools = Array.from({ length: 151 }, (_, i) => ({ name: `marker_${i}`, description: "Synthetic marker", parameters: { type: "object", properties: {} } })); const request = (choice: OcxToolChoice | undefined): OcxParsedRequest => ({ modelId: "fixture", stream: false, context: { messages: [{ role: "user", content: "Use a marker", timestamp: 0 }], tools }, options: { toolChoice: choice } }); + +test("an explicitly unfinished call cannot satisfy a required client call", async () => { + const adapter = withRequiredChatToolResult({ + name: "fixture", buildRequest: () => ({ url: "https://fixture.invalid", method: "POST", headers: {}, body: "{}" }), + async *parseStream() { + yield { type: "tool_call_start", id: "call", name: "marker_0" }; + yield { type: "tool_call_delta", arguments: "{}" }; + yield { type: "tool_call_end", incomplete: true }; + yield { type: "done" }; + }, + }); + await adapter.buildRequest(request("required")); + const events = await Array.fromAsync(adapter.parseStream(new Response(), createTestTranslatorBudget())); + expect(events.at(-1)).toMatchObject({ type: "error", code: "required_tool_not_called" }); +}); async function parse(choice: OcxToolChoice | undefined, stream: boolean, call?: string, finish = "stop") { const adapter = createOpenAIChatAdapter({ adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", apiKey: "fixture-key" }); await adapter.buildRequest(request(choice), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }); diff --git a/tests/advisor/advisor-retention.test.ts b/tests/advisor/advisor-retention.test.ts index 73459c4d3dc..74b2e11b6c6 100644 --- a/tests/advisor/advisor-retention.test.ts +++ b/tests/advisor/advisor-retention.test.ts @@ -30,6 +30,29 @@ async function run(firstEvents: AsyncIterable, budget?: Translator return { events, requests, questions }; } +test("an explicitly unfinished advisor call cannot execute or trigger a continuation", async () => { + const result = await run(stream([ + { type: "tool_call_start", id: "unfinished", name: "advisor" }, + { type: "tool_call_delta", arguments: '{"question":"fixture"}' }, + { type: "tool_call_end", incomplete: true }, + { type: "incomplete", reason: "max_output_tokens" }, + ])); + expect(result.questions).toEqual([]); + expect(result.requests).toEqual([]); + expect(result.events.at(-1)).toMatchObject({ type: "incomplete", reason: "max_output_tokens" }); +}); + +test("an unfinished sibling prevents replaying a completed advisor call", async () => { + const result = await run(stream([ + ...call("completed", '{"question":"fixture"}'), + { type: "tool_call_start", id: "unfinished", name: "advisor" }, + { type: "tool_call_delta", arguments: "{}" }, + { type: "tool_call_end", incomplete: true }, done, + ])); + expect(result.questions).toEqual([]); + expect(result.requests).toEqual([]); +}); + test("parser call release cannot free the Advisor's retained argument copies", async () => { const budget = createTranslatorBudget({ maxTurnBytes: 2_048 }); let closed = 0; diff --git a/tests/images/loop-terminal-refusal.test.ts b/tests/images/loop-terminal-refusal.test.ts index e8a7f618aa1..00c6265bb2b 100644 --- a/tests/images/loop-terminal-refusal.test.ts +++ b/tests/images/loop-terminal-refusal.test.ts @@ -24,8 +24,10 @@ afterEach(() => { }); const cases = [true, false, undefined].flatMap(terminalRefusalAsFailed => - ["direct", "hidden", "rotated"].map(path => ({ terminalRefusalAsFailed, path }))); -test.each(cases)("a refused image_gen iteration stops and meters usage: %j", async ({ terminalRefusalAsFailed, path }) => { + ["direct", "hidden", "rotated"].map(path => ({ terminalRefusalAsFailed, path, reason: "content_filter", unfinished: false }))) + .concat([{ terminalRefusalAsFailed: false, path: "direct", reason: "max_output_tokens", unfinished: true }, + { terminalRefusalAsFailed: false, path: "direct", reason: "max_output_tokens", unfinished: false }]); +test.each(cases)("a refused image_gen iteration stops and meters usage: %j", async ({ terminalRefusalAsFailed, path, reason, unfinished }) => { const hasPrior = path !== "direct"; const reported: (OcxUsage | undefined)[] = []; const rotated: OcxUsage[] = []; @@ -47,13 +49,13 @@ test.each(cases)("a refused image_gen iteration stops and meters usage: %j", asy yield { type: "tool_call_start", id: "call_1", name: "image_gen" } satisfies AdapterEvent; // Missing prompt gives the prior round a local tool error without sending a media request. yield { type: "tool_call_delta", arguments: hasPrior && routedRequests === 1 ? "{}" : '{"prompt":"a cat"}' } satisfies AdapterEvent; - yield { type: "tool_call_end" } satisfies AdapterEvent; + yield { type: "tool_call_end", ...(unfinished && !(hasPrior && routedRequests === 1) ? { incomplete: true as const } : {}) } satisfies AdapterEvent; if (hasPrior && routedRequests === 1) { yield { type: "done", usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 } } satisfies AdapterEvent; return; } yield { - type: "incomplete", reason: "content_filter", retryable: false, + type: "incomplete", reason, ...(reason === "content_filter" ? { retryable: false } : {}), message: 'upstream ended the turn with stop_reason "refusal"', usage: { inputTokens: 10, outputTokens: 3, totalTokens: 13 }, } satisfies AdapterEvent; @@ -92,6 +94,6 @@ test.each(cases)("a refused image_gen iteration stops and meters usage: %j", asy }); expect(text).toContain(`event: response.${terminalRefusalAsFailed ? "failed" : "incomplete"}`); if (terminalRefusalAsFailed) expect(text).toContain('"code":"invalid_prompt"'); - else expect(text).toContain('"reason":"content_filter"'); + else expect(text).toContain(`"reason":"${reason}"`); expect(text).not.toContain("event: response.completed"); }); diff --git a/tests/providers/zed-openai-contract.test.ts b/tests/providers/zed-openai-contract.test.ts index 183813e88e9..b7cae67f5be 100644 --- a/tests/providers/zed-openai-contract.test.ts +++ b/tests/providers/zed-openai-contract.test.ts @@ -6,6 +6,8 @@ import { bridgeToResponsesSSE, buildResponseJSON } from "../../src/bridge"; import { zedEventStream } from "../../src/adapters/zed"; import { scrubZedCredentials } from "../../src/providers/zed"; import { foldChatCompletion } from "../../src/protocols/encoders/chat"; +import { scanEventsForWebSearch } from "../../src/web-search/loop"; +import { anthropicSearchAssistantContent } from "../../src/web-search/anthropic-history"; // Each source chunk contains one frame, so the tests measure retained state separately. function stream(frames: unknown[]): ReadableStream { @@ -106,6 +108,29 @@ describe("Zed OpenAI terminal contract", () => { } finally { budget.dispose(); } }); + test.each(["anthropic", "google", "x_ai", "open_ai"] as const)("%s untyped error envelopes scrub diagnostic strings", async family => { + const credentials = { userId: "fixture-user-42", accessToken: "fixture-token-xyz" }; + const secret = credentials.userId + " " + credentials.accessToken; + const body = new Response(JSON.stringify({ event: { error: { message: secret, code: secret, type: secret } } }) + "\n").body!; + const text = await new Response(zedEventStream(body, family, text => scrubZedCredentials(text, credentials))).text(); + expect(text).not.toContain(credentials.accessToken); + expect(text).not.toContain(credentials.userId); + }); + + test("search scanning preserves an unfinished call without making it executable", () => { + const events: AdapterEvent[] = [ + { type: "tool_call_start", id: "search", name: "web_search" }, + { type: "tool_call_delta", arguments: '{"query":"fixture"}' }, + { type: "tool_call_end", incomplete: true }, + { type: "incomplete", reason: "max_output_tokens" }, + ]; + const split = scanEventsForWebSearch(events); + expect(split.calls).toEqual([]); + expect(split.passthrough).toEqual(events); + expect(split.hasMalformedToolCall).toBe(true); + expect(() => anthropicSearchAssistantContent(events)).toThrow("unfinished tool call"); + }); + test.each([false, true])("truncated tools preserve terminal reason and usage through the %s streaming bridge", async streaming => { const budget = createTranslatorBudget(); const events = translateZedOpenAiStream(stream([ diff --git a/tests/web-search/web-search-terminal-refusal.test.ts b/tests/web-search/web-search-terminal-refusal.test.ts index d2f5f670ebc..ce8c211d64d 100644 --- a/tests/web-search/web-search-terminal-refusal.test.ts +++ b/tests/web-search/web-search-terminal-refusal.test.ts @@ -14,8 +14,10 @@ const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); const cases = [true, false, undefined].flatMap(terminalRefusalAsFailed => - ["direct", "hidden", "rotated"].map(path => ({ terminalRefusalAsFailed, path }))); -test.each(cases)("a refused web_search iteration stops and meters usage: %j", async ({ terminalRefusalAsFailed, path }) => { + ["direct", "hidden", "rotated"].map(path => ({ terminalRefusalAsFailed, path, reason: "content_filter", unfinished: false }))) + .concat([{ terminalRefusalAsFailed: false, path: "direct", reason: "max_output_tokens", unfinished: true }, + { terminalRefusalAsFailed: false, path: "direct", reason: "max_output_tokens", unfinished: false }]); +test.each(cases)("a refused web_search iteration stops and meters usage: %j", async ({ terminalRefusalAsFailed, path, reason, unfinished }) => { const hasPrior = path !== "direct"; const reported: (OcxUsage | undefined)[] = []; const rotated: OcxUsage[] = []; @@ -35,13 +37,13 @@ test.each(cases)("a refused web_search iteration stops and meters usage: %j", as async *parseStream() { yield { type: "tool_call_start", id: "search-1", name: "web_search" } satisfies AdapterEvent; yield { type: "tool_call_delta", arguments: JSON.stringify({ query: "fixture query" }) } satisfies AdapterEvent; - yield { type: "tool_call_end" } satisfies AdapterEvent; + yield { type: "tool_call_end", ...(unfinished && !(hasPrior && routedSends === 1) ? { incomplete: true as const } : {}) } satisfies AdapterEvent; if (hasPrior && routedSends === 1) { yield { type: "done", usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 } } satisfies AdapterEvent; return; } yield { - type: "incomplete", reason: "content_filter", retryable: false, + type: "incomplete", reason, ...(reason === "content_filter" ? { retryable: false } : {}), message: 'upstream ended the turn with stop_reason "refusal"', usage: { inputTokens: 10, outputTokens: 3, totalTokens: 13 }, } satisfies AdapterEvent; @@ -78,6 +80,6 @@ test.each(cases)("a refused web_search iteration stops and meters usage: %j", as expect(terminal.response.usage).toMatchObject({ input_tokens: 10, output_tokens: 3, total_tokens: 13 }); expect(text).toContain(`event: response.${terminalRefusalAsFailed ? "failed" : "incomplete"}`); if (terminalRefusalAsFailed) expect(text).toContain('"code":"invalid_prompt"'); - else expect(text).toContain('"reason":"content_filter"'); + else expect(text).toContain(`"reason":"${reason}"`); expect(text).not.toContain("event: response.completed"); }); From 88541dd5748fd7abed3b4c36bb4bd00c2cb1bcf1 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:23:03 +0900 Subject: [PATCH 10/13] fix(zed): await authoritative tool status and terminate native errors --- src/adapters/zed-openai-stream.ts | 18 +++++++++-- src/adapters/zed.ts | 2 +- structure/providers-and-adapters.md | 2 +- tests/providers/zed-hosted-provider.test.ts | 3 +- tests/providers/zed-openai-contract.test.ts | 36 +++++++++++++++++++++ 5 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/adapters/zed-openai-stream.ts b/src/adapters/zed-openai-stream.ts index 7d86e0eadc2..2bae49556fc 100644 --- a/src/adapters/zed-openai-stream.ts +++ b/src/adapters/zed-openai-stream.ts @@ -31,6 +31,7 @@ interface CallState { ended: boolean; closed: boolean; incomplete: boolean; + argsDone: boolean; /** True only after this call's own done or completed item, not after EOF. */ allowFull: boolean; } @@ -84,7 +85,8 @@ function incompleteReason(payload: Record): string { * with empty output (Zed's stream-ended frame) must not wipe usage taken from the real * completion, and completed output must not repeat text or calls that already streamed. * Adapter tool deltas are unkeyed, so only the oldest open call is emitted. A newer call - * keeps every later delta until the older call's own terminal event, then replays in order. + * keeps every later delta until the older call's authoritative item end, then replays in order. + * Argument-done confirms bytes only; item status or successful response completion closes a call. * EOF and response.incomplete flush arguments that already arrived; they do not copy a full * arguments field that never came from that call's done or completed item. A compaction * ciphertext is carried on done, with the same retained lease as the Responses parser. @@ -271,6 +273,7 @@ export async function* translateZedOpenAiStream( for (const state of calls.values()) { if (state.ended || state.closed) continue; state.closed = true; + if (!allowFull) state.incomplete = true; if (allowFull) state.allowFull = true; } yield* pump(); @@ -316,6 +319,7 @@ export async function* translateZedOpenAiStream( ended: false, closed: false, incomplete: false, + argsDone: false, allowFull: false, }; calls.set(key, state); @@ -494,7 +498,7 @@ export async function* translateZedOpenAiStream( } function* onPayload(payload: Record): Generator { - if (payload.type === "error" || payload.type === "response.failed") { + if (payload.type === "error" || payload.type === "response.failed" || (payload.error !== undefined && payload.error !== null)) { terminal = true; retainUsage(payload.response); yield { type: "error", message: failureMessage(payload), ...(usage ? { usage } : {}) }; @@ -576,7 +580,9 @@ export async function* translateZedOpenAiStream( if (!key) return; const state = ensureCall(key, "", ""); rememberFull(state, payload.type === "response.custom_tool_call_input.done" ? payload.input : payload.arguments); - yield* closeCall(state, true); + state.argsDone = true; + state.allowFull = true; + yield* pump(); return; } if (payload.type === "response.completed") { @@ -589,6 +595,12 @@ export async function* translateZedOpenAiStream( if (terminalError) return; } } + // Arguments.done confirms bytes, not executable item completion. Wait for the item's + // status or the successful response terminal before dispatching its end marker. + for (const state of calls.values()) { + if (state.argsDone && !state.closed) yield* closeCall(state, true); + if (terminalError) return; + } } } diff --git a/src/adapters/zed.ts b/src/adapters/zed.ts index 9129a81770c..c2ca54d9018 100644 --- a/src/adapters/zed.ts +++ b/src/adapters/zed.ts @@ -244,7 +244,7 @@ export function zedEventStream( const scrubRecord = (record: Record, preserveType = false): Record => ({ ...scrubFields(record, preserveType), ...(typeof record.error === "string" ? { error: scrub(record.error) } - : isRecord(record.error) ? { error: scrubFields(record.error) } : {}), + : isRecord(record.error) ? { error: { ...scrubFields(record.error), metadata: undefined } } : {}), ...(isRecord(record.incomplete_details) ? { incomplete_details: scrubFields(record.incomplete_details) } : {}), }); emit(controller, diagnostic ? { diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 53376cfc7b4..67b5ecbd791 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -208,7 +208,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge; optional `minMaxOutputTokens` raises only smaller explicit positive integer output budgets. Unset preserves caller caps. Serialization and pre-admission spend estimates share `effectiveAzureOutputBudget`; see the [spend contract](transports/responses-spend.md#durable-spend-reservations). | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | -| `src/adapters/zed.ts`, `src/providers/zed.ts`, `src/oauth/zed.ts` | Zed Hosted AI native-app RSA login, account-scoped short-lived LLM-token exchange, bounded live model discovery, and provider-envelope translation through the existing Anthropic, Google, Responses, and Chat builders. Buffered delivery retains events under the shared translator budget and cancels delegated streams on early exit. The Responses delegate applies destination compatibility, strips OpenAI-private fields and unsupported built-in tools, reconciles removed tool choices through the shared hosted-tool policy, and maps OpenAI `developer` messages to Zed-supported `system` messages before wrapping the request. `src/adapters/zed-openai-stream.ts` translates the OpenAI stream: the current turn's `additional_tools` are promoted on a detached copy before replay repair, interleaved function and custom calls stay buffered until the oldest open call's own terminal event because adapter deltas are unkeyed, and end of stream or an incomplete response flushes only arguments that already arrived, marking unfinished calls incomplete. Text and reasoning wait behind an open call; invisible heartbeats report buffered progress. Item aliases share one execution identity and conflicting reuse fails closed. Missing tool names fail the turn, and no output follows a terminal error. Per-item reasoning and done-only text fallback suppress completed replays. A completed compaction item keeps its ciphertext on the done event and transfers that retained lease. Arguments, full snapshots, dedupe identities, queued events, text and opaque usage are budgeted and released on failure or cancellation; typed and untyped native error envelopes scrub every diagnostic string, including code/type, with the captured credential scrubber. `src/adapters/zed-additional-tools.ts` owns tool promotion, while `src/adapters/openai-responses/usage.ts` shares usage projection with Responses compaction. `tests/providers/zed-openai-contract.test.ts` covers the relay and client-boundary regressions. Experimental and unofficial; the Zed terms risk is intentionally surfaced in user docs and release review. | +| `src/adapters/zed.ts`, `src/providers/zed.ts`, `src/oauth/zed.ts` | Zed Hosted AI native-app RSA login, account-scoped short-lived LLM-token exchange, bounded live model discovery, and provider-envelope translation through the existing Anthropic, Google, Responses, and Chat builders. Buffered delivery retains events under the shared translator budget and cancels delegated streams on early exit. The Responses delegate applies destination compatibility, strips OpenAI-private fields and unsupported built-in tools, reconciles removed tool choices through the shared hosted-tool policy, and maps OpenAI `developer` messages to Zed-supported `system` messages before wrapping the request. `src/adapters/zed-openai-stream.ts` translates the OpenAI stream: the current turn's `additional_tools` are promoted on a detached copy before replay repair, interleaved function and custom calls stay buffered until the oldest open call's authoritative item end or successful response terminal because adapter deltas are unkeyed, and end of stream or an incomplete response flushes only arguments that already arrived, marking unfinished calls incomplete. Text and reasoning wait behind an open call; invisible heartbeats report buffered progress. Item aliases share one execution identity and conflicting reuse fails closed. Missing tool names fail the turn, and no output follows a terminal error. Per-item reasoning and done-only text fallback suppress completed replays. A completed compaction item keeps its ciphertext on the done event and transfers that retained lease. Arguments, full snapshots, dedupe identities, queued events, text and opaque usage are budgeted and released on failure or cancellation; typed and untyped native error envelopes scrub every diagnostic string, including code/type, with the captured credential scrubber and discard diagnostic metadata before delegation. Untyped error envelopes terminate the OpenAI relay. Argument-done frames confirm bytes only; item status or successful response completion authorizes executable closure. `src/adapters/zed-additional-tools.ts` owns tool promotion, while `src/adapters/openai-responses/usage.ts` shares usage projection with Responses compaction. `tests/providers/zed-openai-contract.test.ts` covers the relay and client-boundary regressions. Experimental and unofficial; the Zed terms risk is intentionally surfaced in user docs and release review. | | `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort anchors on the family's default member and selects the nearest enabled rung, rounding up first; an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays expressed as type-only `anyOf` branches in tool parameters. Shared constraints remain on the surrounding node, so nested schemas have one wire representation while outer enum/const, boolean constraints (including `not`, `oneOf`, and `allOf`) on null, and existing null-branch restrictions retain their conjunctive meaning. A null-only type keeps its null constraint even when an outer enum/const excludes null, preserving an unsatisfiable schema without producing an empty anyOf. Schema resource identifiers, anchors, definition maps, and existing anyOf branches remain on their original node. The type constraint is appended to existing allOf entries without shifting their indices, so JSON Pointer targets and reference scopes remain unchanged. A pre-output `invalid_argument` on a history whose word-piece estimate, using the sanitized and truncated tool descriptions actually sent on the wire, reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. The `COGNITION_BLOCKLIST_REWRITES` sanitizer rewrites exact phrases Cognition refuses with `permission_denied`; it runs only on instruction surfaces — tool descriptions and the #2 system prompt — while message text, replayed thinking (#11), and tool-call arguments stay byte-exact, because a rewrite there would silently change literal content such as patches or quoted file bytes; a trigger inside a data field surfaces as the upstream `permission_denied` instead (the Codex `` escalation boilerplate is the recorded #2 case). | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. Its registry entry is `deprecated`: existing saved providers keep the adapter and their stored configuration, while new-provider discovery (presets, the init menu, the featured list, and key logins) excludes it and `provider add` warns before creating one. | diff --git a/tests/providers/zed-hosted-provider.test.ts b/tests/providers/zed-hosted-provider.test.ts index 4132c6880ec..2046c1d6670 100644 --- a/tests/providers/zed-hosted-provider.test.ts +++ b/tests/providers/zed-hosted-provider.test.ts @@ -631,7 +631,7 @@ describe("Zed OpenAI tool relay", () => { { type: "tool_call_end", incomplete: true }, { type: "tool_call_start", id: "call_b", name: "b" }, { type: "tool_call_delta", arguments: "{\"y\":2}" }, - { type: "tool_call_end" }, + { type: "tool_call_end", incomplete: true }, { type: "incomplete", reason: "max_output_tokens" }, ]); }); @@ -739,6 +739,7 @@ describe("Zed OpenAI tool relay", () => { { type: "response.output_item.added", item: { id: "fc_a", type: "function_call", call_id: "call_a", name: "a", arguments: "" } }, { type: "response.function_call_arguments.delta", item_id: "fc_a", delta: "{\"x\":1}" }, { type: "response.function_call_arguments.done", item_id: "fc_a", arguments: "{\"x\":1}" }, + { type: "response.completed", response: { output: [] } }, ])).body, budget)) events.push(event); expect(events).toHaveLength(5); expect(budget.snapshot().currentBytes).toBe(0); diff --git a/tests/providers/zed-openai-contract.test.ts b/tests/providers/zed-openai-contract.test.ts index b7cae67f5be..09103bd6a5f 100644 --- a/tests/providers/zed-openai-contract.test.ts +++ b/tests/providers/zed-openai-contract.test.ts @@ -40,6 +40,7 @@ describe("Zed OpenAI terminal contract", () => { { type: "response.function_call_arguments.delta", item_id: "fc", delta: '{"x":' }, semantic, { type: "response.function_call_arguments.delta", item_id: "fc", delta: "1}" }, { type: "response.function_call_arguments.done", item_id: "fc", arguments: '{"x":1}' }, + { type: "response.completed", response: { output: [] } }, ]); const semanticIndex = events.findIndex(event => event.type === (channel === "text" ? "text_delta" : channel === "summary" ? "thinking_delta" : "reasoning_raw_delta")); expect(semanticIndex).toBeGreaterThan(events.findIndex(event => event.type === "tool_call_end")); @@ -117,6 +118,36 @@ describe("Zed OpenAI terminal contract", () => { expect(text).not.toContain(credentials.userId); }); + test("nested diagnostic metadata cannot carry credential strings to a delegate", async () => { + const credentials = { userId: "fixture-user-42", accessToken: "fixture-token-xyz" }; + const body = new Response(JSON.stringify({ event: { type: "error", error: { + message: "failure", metadata: { request_id: credentials.accessToken, nested: [credentials.userId] }, + } } }) + "\n").body!; + const text = await new Response(zedEventStream(body, "x_ai", text => scrubZedCredentials(text, credentials))).text(); + expect(text).not.toContain(credentials.accessToken); + expect(text).not.toContain(credentials.userId); + }); + + test("an untyped upstream error cannot become a successful completion", async () => { + expect(await collect([{ error: { message: "failure" } }, { type: "response.completed", response: { output: [] } }])) + .toEqual([{ type: "error", message: "failure" }]); + }); + + test.each([false, true])("argument completion cannot override a later incomplete item (stream=%s)", async streaming => { + const events = await collect([ + { type: "response.output_item.added", item: { type: "function_call", id: "fc", call_id: "call", name: "shell", arguments: "" } }, + { type: "response.function_call_arguments.done", item_id: "fc", arguments: "{}" }, + { type: "response.output_item.done", item: { type: "function_call", id: "fc", call_id: "call", name: "shell", arguments: "{}", status: "incomplete" } }, + { type: "response.incomplete", response: { incomplete_details: { reason: "max_output_tokens" } } }, + ]); + const result = streaming + ? (await new Response(bridgeToResponsesSSE((async function* () { yield* events; })(), "fixture")).text()).split("\n") + .filter(line => line.startsWith("data: {")) + .map(line => JSON.parse(line.slice(6))).findLast(event => event.type === "response.incomplete")?.response + : buildResponseJSON(events, "fixture"); + expect(result.output).toContainEqual(expect.objectContaining({ call_id: "call", status: "incomplete", arguments: "{}" })); + }); + test("search scanning preserves an unfinished call without making it executable", () => { const events: AdapterEvent[] = [ { type: "tool_call_start", id: "search", name: "web_search" }, @@ -206,6 +237,7 @@ describe("Zed OpenAI terminal contract", () => { { type: "response.function_call_arguments.delta", item_id: "b", delta: "{}" }, { type: "response.function_call_arguments.done", item_id: "a", arguments: "{}" }, { type: "response.function_call_arguments.done", item_id: "b", arguments: "{}" }, + { type: "response.completed", response: { output: [] } }, ]); expect(events[1]).toMatchObject({ type: "heartbeat" }); expect(events.filter(event => event.type === "tool_call_start").map(event => event.id)).toEqual(["call_a", "call_b"]); @@ -274,6 +306,7 @@ describe("Zed OpenAI retention bounds", () => { { type: "response.output_item.added", item: { type: "function_call", id: "fc", call_id: "call", name: "shell", arguments: "x" } }, { type: "response.function_call_arguments.delta", item_id: "fc", delta: "x".repeat(15) }, { type: "response.function_call_arguments.done", item_id: "fc" }, + { type: "response.completed", response: { output: [] } }, ], { maxCallArgumentBytes: 30 }); expect(events.at(-1)).toEqual({ type: "done" }); }); @@ -299,6 +332,7 @@ describe("Zed OpenAI retention bounds", () => { { type: "response.output_item.added", item: { type: "custom_tool_call", id: "fc", call_id: "call", name: "apply_patch", input: "" } }, { type: "response.custom_tool_call_input.delta", item_id: "fc", delta: "x".repeat(30) }, { type: "response.custom_tool_call_input.done", item_id: "fc", input: "x".repeat(30) }, + { type: "response.completed", response: { output: [] } }, ], { maxCallArgumentBytes: 30 })).at(-1)).toEqual({ type: "done" }); }); @@ -307,6 +341,7 @@ describe("Zed OpenAI retention bounds", () => { { type: "response.output_item.added", item: { type: "custom_tool_call", id: "fc", call_id: "call", name: "apply_patch", input: "prefix-stale" } }, { type: "response.custom_tool_call_input.delta", item_id: "fc", delta: "prefix" }, { type: "response.custom_tool_call_input.done", item_id: "fc", input: "prefix" }, + { type: "response.completed", response: { output: [] } }, ]); expect(events.filter(event => event.type === "tool_call_delta").map(event => event.arguments).join("")) .toBe("prefix"); @@ -347,6 +382,7 @@ describe("Zed OpenAI retention bounds", () => { { type: "response.custom_tool_call_input.delta", item_id: "fc", delta: "\ud83d" }, { type: "response.custom_tool_call_input.delta", item_id: "fc", delta: "\ude00" }, { type: "response.custom_tool_call_input.done", item_id: "fc" }, + { type: "response.completed", response: { output: [] } }, ], { maxCallArgumentBytes: 4 }); expect(events.filter(event => event.type === "tool_call_delta").map(event => event.arguments).join("")) .toBe("😀"); From 5b0dcaade9bc98619a9090eb8ef083300588475a Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:21:15 +0900 Subject: [PATCH 11/13] fix(zed): filter failure usage and reject conflicting tool completion Retain only known numeric usage counters on diagnostic terminals. Validate authoritative function/custom snapshots against received and delivered bytes before executable closure, including later speculative snapshots and deltas. Close outstanding calls incomplete and replay queued semantic output before the original failure. Port the independent review reproductions into the registered suite and document the relay contracts. Co-authored-by: Andrew Wang <59988150+andrew05060414@users.noreply.github.com> --- .../src/content/docs/guides/providers.md | 5 +- scripts/test-layout/layout-adapters.json | 1 + src/adapters/zed-diagnostic-usage.ts | 22 ++ src/adapters/zed-openai-stream.ts | 71 +++++-- src/adapters/zed.ts | 2 + structure/providers-and-adapters.md | 2 +- structure/transports/byte-accounting.md | 4 +- tests/fixtures/test-layout-expected.json | 1 + tests/providers/zed-failure-relay.test.ts | 190 ++++++++++++++++++ tests/providers/zed-openai-contract.test.ts | 1 + 10 files changed, 273 insertions(+), 26 deletions(-) create mode 100644 src/adapters/zed-diagnostic-usage.ts create mode 100644 tests/providers/zed-failure-relay.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 482d243fc0f..fdc3fdaa153 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -380,8 +380,9 @@ For Zed-hosted OpenAI models, OpenCodex translates `developer` instructions to ` Current-turn tool declarations carried in Codex's `additional_tools` input are promoted into the supported tool catalog. Function calls and custom-tool input are relayed in order, including interleaved calls, so a tool-using turn can continue after its introductory text. Incomplete calls -keep only received arguments and remain incomplete; cancellation releases buffered data. The -relay is covered by synthetic stream fixtures; live hosted-stream compatibility remains unverified. +keep only received arguments and remain incomplete; cancellation releases buffered data. Conflicting +completed tool input fails the turn with incomplete calls. On upstream failure, received text and +reasoning remain visible and usage retains numeric counters only. The relay is covered by synthetic stream fixtures; live hosted-stream compatibility remains unverified. For the canonical Kimi Coding Plan presets (`kimi` account login and `kimi-code` API key), opencodex forwards only a caller-supplied stable `prompt_cache_key` to the Chat Completions request diff --git a/scripts/test-layout/layout-adapters.json b/scripts/test-layout/layout-adapters.json index cc7117ed973..02443ef5c78 100644 --- a/scripts/test-layout/layout-adapters.json +++ b/scripts/test-layout/layout-adapters.json @@ -1,5 +1,6 @@ { "zed-openai-contract.test.ts": "providers", + "zed-failure-relay.test.ts": "providers", "cursor-native-grep-globs.test.ts": "providers/cursor", "tool-identity-hash.test.ts": "adapters", "eventstream-fragmentation.test.ts": "responses", diff --git a/src/adapters/zed-diagnostic-usage.ts b/src/adapters/zed-diagnostic-usage.ts new file mode 100644 index 00000000000..0868c2068f7 --- /dev/null +++ b/src/adapters/zed-diagnostic-usage.ts @@ -0,0 +1,22 @@ +/** Diagnostic terminals carry counters only; opaque usage extensions are untrusted. */ +export function zedDiagnosticUsage(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const source = value as Record; + const counters = (record: Record, keys: string[]): Record => Object.fromEntries( + keys.flatMap(key => { + const count = record[key]; + return typeof count === "number" && Number.isFinite(count) && count >= 0 ? [[key, count]] : []; + }), + ); + const usage: Record = counters(source, ["input_tokens", "output_tokens", "total_tokens"]); + for (const [field, keys] of [ + ["input_tokens_details", ["cached_tokens", "cache_write_tokens", "cache_write_1h_tokens"]], + ["output_tokens_details", ["reasoning_tokens"]], + ] as const) { + const details = source[field]; + if (details && typeof details === "object" && !Array.isArray(details)) { + usage[field] = counters(details as Record, [...keys]); + } + } + return usage; +} diff --git a/src/adapters/zed-openai-stream.ts b/src/adapters/zed-openai-stream.ts index 2bae49556fc..d78ed067da6 100644 --- a/src/adapters/zed-openai-stream.ts +++ b/src/adapters/zed-openai-stream.ts @@ -5,6 +5,7 @@ import { transferCompactionCiphertextLease } from "../responses/compaction"; import { truncationReasonFor } from "../responses/truncated-stop-reason"; import { unnamedToolCallEvent } from "./openai-chat/response-events"; import { usageFromResponsesPayload } from "./openai-responses/usage"; +import { zedDiagnosticUsage } from "./zed-diagnostic-usage"; import { createHash, randomUUID } from "node:crypto"; function isRecord(value: unknown): value is Record { @@ -40,6 +41,10 @@ class ZedToolIdentityError extends Error { constructor() { super("Zed stream reused a tool call identity with conflicting data"); } } +class ZedToolArgumentsError extends Error { + constructor() { super("Zed stream completed a tool call with conflicting arguments"); } +} + /** UTF-8 bytes a fragment adds to an existing string, without re-measuring the whole buffer. */ function appendedUtf8Bytes(previousBytes: number, lastCodeUnit: number, fragment: string): number { if (!fragment) return previousBytes; @@ -143,26 +148,28 @@ export async function* translateZedOpenAiStream( } /** Emit the oldest open call. Newer calls stay buffered until that call closes. */ - function* pump(): Generator { + function* pump(preserveFailure = false): Generator { while (true) { if (terminalError) return; const next = [...calls.values()].find(state => !state.ended); if (!next) return; if (!next.name) { if (!next.closed) return; - next.ended = true; - releaseCall(next); - terminal = true; - terminalError = true; - yield unnamedToolCallEvent(usage); - return; + if (!preserveFailure) { + next.ended = true; + releaseCall(next); + terminal = true; + terminalError = true; + yield unnamedToolCallEvent(usage); + return; + } } - if (!next.started) { + if (next.name && !next.started) { next.started = true; yield { type: "tool_call_start", id: next.callId, name: next.name }; } const text = argumentText(next); - if (text.length > next.emitted) { + if (next.name && text.length > next.emitted) { const delta = text.slice(next.emitted); next.emitted = text.length; yield { type: "tool_call_delta", arguments: delta }; @@ -170,7 +177,7 @@ export async function* translateZedOpenAiStream( if (!next.closed) return; next.ended = true; next.finalHash = createHash("sha256").update(text).digest("hex"); - yield { type: "tool_call_end", ...(!next.allowFull || next.incomplete ? { incomplete: true } : {}) }; + if (next.name) yield { type: "tool_call_end", ...(!next.allowFull || next.incomplete ? { incomplete: true } : {}) }; releaseCall(next); for (const entry of next.after) { budget.releaseRetained(entry.bytes, { kind: "retained_collectors" }); @@ -183,6 +190,11 @@ export async function* translateZedOpenAiStream( function* appendArgs(state: CallState, fragment: string): Generator { if (!fragment || state.ended) return; + // A full done snapshot may have supplied bytes ahead of these later deltas. + const overlap = Math.min(fragment.length, state.emitted - state.args.length); + if (overlap > 0 && fragment.slice(0, overlap) !== argumentText(state).slice(state.args.length, state.args.length + overlap)) { + throw new ZedToolArgumentsError(); + } const nextBytes = appendedUtf8Bytes(state.argsBytes, state.lastCodeUnit, fragment); const scope = { kind: "tool_args" as const, callId: state.budgetKey }; const reservation = budget.reserveTransient(nextBytes, scope); @@ -194,13 +206,18 @@ export async function* translateZedOpenAiStream( yield* pump(); } - function rememberFull(state: CallState, full: unknown): void { + function rememberFull(state: CallState, full: unknown, authoritative = false): void { // A closed call's buffers were already released; charging again would outlive their owner. - if (typeof full !== "string" || full.length === 0) return; + if (typeof full !== "string" || (!authoritative && full.length === 0)) return; if (state.ended) { if (state.finalHash && state.finalHash !== createHash("sha256").update(full).digest("hex")) throw new ZedToolIdentityError(); return; } + if (!authoritative && state.allowFull) return; + // Added-item snapshots are speculative. A completion must agree with received deltas + // and with any full snapshot bytes already delivered, including empty/shorter values. + if (authoritative && (!full.startsWith(state.args) + || !full.startsWith(argumentText(state).slice(0, state.emitted)))) throw new ZedToolArgumentsError(); if (full === state.pendingFull) return; if (full === state.args) { budget.releaseRetained(state.fullBytes, { kind: "tool_args", callId: state.budgetKey }); @@ -269,14 +286,14 @@ export async function* translateZedOpenAiStream( yield* pump(); } - function* flushUnclosed(allowFull: boolean): Generator { + function* flushUnclosed(allowFull: boolean, preserveFailure = false): Generator { for (const state of calls.values()) { - if (state.ended || state.closed) continue; + if (state.ended) continue; state.closed = true; if (!allowFull) state.incomplete = true; if (allowFull) state.allowFull = true; } - yield* pump(); + yield* pump(preserveFailure); } function retainCompaction(response: unknown): void { @@ -348,8 +365,15 @@ export async function* translateZedOpenAiStream( yield { type: "heartbeat" }; } - function retainUsage(response: unknown): void { - const next = usageFromResponsesPayload(response); + function retainUsage(response: unknown, diagnostic = false): void { + if (diagnostic && usage) { + const { rawUsage: _raw, ...counters } = usage; + usage = counters; + budget.releaseRetained(usageRawBytes, { kind: "retained_collectors" }); + usageRawBytes = 0; + } + const next = usageFromResponsesPayload(diagnostic && isRecord(response) + ? { usage: zedDiagnosticUsage(response.usage) } : response); if (!next) return; const bytes = next.rawUsage ? Buffer.byteLength(JSON.stringify(next.rawUsage), "utf8") : 0; const reservation = budget.reserveTransient(bytes, { kind: "retained_collectors" }); @@ -450,7 +474,7 @@ export async function* translateZedOpenAiStream( const name = typeof item.name === "string" ? item.name : ""; const state = ensureCall(key, callId, name, item.type); if (item.status === "incomplete") state.incomplete = true; - rememberFull(state, item.type === "custom_tool_call" ? item.input : item.arguments); + rememberFull(state, item.type === "custom_tool_call" ? item.input : item.arguments, done); if (done) yield* closeCall(state, true); else yield* pump(); } @@ -500,13 +524,14 @@ export async function* translateZedOpenAiStream( function* onPayload(payload: Record): Generator { if (payload.type === "error" || payload.type === "response.failed" || (payload.error !== undefined && payload.error !== null)) { terminal = true; - retainUsage(payload.response); + retainUsage(payload.response ?? payload, true); + yield* flushUnclosed(false, true); yield { type: "error", message: failureMessage(payload), ...(usage ? { usage } : {}) }; return; } if (payload.type === "response.incomplete") { terminal = true; - retainUsage(payload.response); + retainUsage(payload.response, true); yield* flushUnclosed(false); if (terminalError) return; yield { type: "incomplete", reason: incompleteReason(payload), ...(usage ? { usage } : {}) }; @@ -579,7 +604,7 @@ export async function* translateZedOpenAiStream( const key = typeof payload.item_id === "string" ? payload.item_id : undefined; if (!key) return; const state = ensureCall(key, "", ""); - rememberFull(state, payload.type === "response.custom_tool_call_input.done" ? payload.input : payload.arguments); + rememberFull(state, payload.type === "response.custom_tool_call_input.done" ? payload.input : payload.arguments, true); state.argsDone = true; state.allowFull = true; yield* pump(); @@ -629,7 +654,9 @@ export async function* translateZedOpenAiStream( ciphertextTransferred = true; yield done; } catch (error) { - if (!(error instanceof ZedToolIdentityError)) throw error; + if (!(error instanceof ZedToolIdentityError) && !(error instanceof ZedToolArgumentsError)) throw error; + retainUsage(undefined, true); + yield* flushUnclosed(false, true); yield { type: "error", message: error.message, ...(usage ? { usage } : {}) }; } finally { if (!ciphertextTransferred) budget.releaseRetained(compactionEncryptedContentBytes, { kind: "retained_collectors" }); diff --git a/src/adapters/zed.ts b/src/adapters/zed.ts index c2ca54d9018..a9bb6104df4 100644 --- a/src/adapters/zed.ts +++ b/src/adapters/zed.ts @@ -6,6 +6,7 @@ import { createGoogleAdapter } from "./google"; import { createOpenAIChatAdapter } from "./openai-chat"; import { createResponsesPassthroughAdapter } from "./openai-responses"; import { liftZedAdditionalTools, translateZedOpenAiStream } from "./zed-openai-stream"; +import { zedDiagnosticUsage } from "./zed-diagnostic-usage"; import { createAdapterPhysicalSend } from "./physical-send"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../types"; import { createTranslatorBudget, releaseTranslatedEvent, retainTranslatedEvent, type TranslatorBudget } from "../lib/translator-budget"; @@ -243,6 +244,7 @@ export function zedEventStream( ); const scrubRecord = (record: Record, preserveType = false): Record => ({ ...scrubFields(record, preserveType), + ...(provider === "open_ai" ? { usage: zedDiagnosticUsage(record.usage) } : {}), ...(typeof record.error === "string" ? { error: scrub(record.error) } : isRecord(record.error) ? { error: { ...scrubFields(record.error), metadata: undefined } } : {}), ...(isRecord(record.incomplete_details) ? { incomplete_details: scrubFields(record.incomplete_details) } : {}), diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 67b5ecbd791..f9e944d8e0b 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -208,7 +208,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge; optional `minMaxOutputTokens` raises only smaller explicit positive integer output budgets. Unset preserves caller caps. Serialization and pre-admission spend estimates share `effectiveAzureOutputBudget`; see the [spend contract](transports/responses-spend.md#durable-spend-reservations). | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | -| `src/adapters/zed.ts`, `src/providers/zed.ts`, `src/oauth/zed.ts` | Zed Hosted AI native-app RSA login, account-scoped short-lived LLM-token exchange, bounded live model discovery, and provider-envelope translation through the existing Anthropic, Google, Responses, and Chat builders. Buffered delivery retains events under the shared translator budget and cancels delegated streams on early exit. The Responses delegate applies destination compatibility, strips OpenAI-private fields and unsupported built-in tools, reconciles removed tool choices through the shared hosted-tool policy, and maps OpenAI `developer` messages to Zed-supported `system` messages before wrapping the request. `src/adapters/zed-openai-stream.ts` translates the OpenAI stream: the current turn's `additional_tools` are promoted on a detached copy before replay repair, interleaved function and custom calls stay buffered until the oldest open call's authoritative item end or successful response terminal because adapter deltas are unkeyed, and end of stream or an incomplete response flushes only arguments that already arrived, marking unfinished calls incomplete. Text and reasoning wait behind an open call; invisible heartbeats report buffered progress. Item aliases share one execution identity and conflicting reuse fails closed. Missing tool names fail the turn, and no output follows a terminal error. Per-item reasoning and done-only text fallback suppress completed replays. A completed compaction item keeps its ciphertext on the done event and transfers that retained lease. Arguments, full snapshots, dedupe identities, queued events, text and opaque usage are budgeted and released on failure or cancellation; typed and untyped native error envelopes scrub every diagnostic string, including code/type, with the captured credential scrubber and discard diagnostic metadata before delegation. Untyped error envelopes terminate the OpenAI relay. Argument-done frames confirm bytes only; item status or successful response completion authorizes executable closure. `src/adapters/zed-additional-tools.ts` owns tool promotion, while `src/adapters/openai-responses/usage.ts` shares usage projection with Responses compaction. `tests/providers/zed-openai-contract.test.ts` covers the relay and client-boundary regressions. Experimental and unofficial; the Zed terms risk is intentionally surfaced in user docs and release review. | +| `src/adapters/zed.ts`, `src/providers/zed.ts`, `src/oauth/zed.ts` | Zed Hosted AI native-app RSA login, account-scoped short-lived LLM-token exchange, bounded live model discovery, and provider-envelope translation through the existing Anthropic, Google, Responses, and Chat builders. Buffered delivery retains events under the shared translator budget and cancels delegated streams on early exit. The Responses delegate applies destination compatibility, strips OpenAI-private fields and unsupported built-in tools, reconciles removed tool choices through the shared hosted-tool policy, and maps OpenAI `developer` messages to Zed-supported `system` messages before wrapping the request. `src/adapters/zed-openai-stream.ts` translates the OpenAI stream: the current turn's `additional_tools` are promoted on a detached copy before replay repair, interleaved function and custom calls stay buffered until the oldest open call's authoritative item end or successful response terminal because adapter deltas are unkeyed, and end of stream or an incomplete response flushes only arguments that already arrived, marking unfinished calls incomplete. Text and reasoning wait behind an open call; invisible heartbeats report buffered progress. Item aliases share one execution identity and conflicting reuse fails closed. Missing tool names fail the turn, and no output follows a terminal error. Per-item reasoning and done-only text fallback suppress completed replays. A completed compaction item keeps its ciphertext on the done event and transfers that retained lease. Arguments, full snapshots, dedupe identities, queued events, text and opaque usage are budgeted and released on failure or cancellation; typed and untyped native error envelopes scrub every diagnostic string, including code/type, with the captured credential scrubber and discard diagnostic metadata before delegation. Untyped error envelopes terminate the OpenAI relay. Argument-done frames confirm bytes only; item status or successful response completion authorizes executable closure. Authoritative function/custom snapshots must extend received arguments and already delivered bytes; conflicts close outstanding calls incomplete and fail the turn. Failed terminals close outstanding calls incomplete and replay queued text/reasoning before the original error. Diagnostic usage retains only finite nonnegative numeric token counters through `src/adapters/zed-diagnostic-usage.ts`, including nested cache/reasoning counters; opaque extensions are discarded. `tests/providers/zed-failure-relay.test.ts` covers JSON/SSE failure projection and completion conflicts. `src/adapters/zed-additional-tools.ts` owns tool promotion, while `src/adapters/openai-responses/usage.ts` shares usage projection with Responses compaction. `tests/providers/zed-openai-contract.test.ts` covers the relay and client-boundary regressions. Experimental and unofficial; the Zed terms risk is intentionally surfaced in user docs and release review. | | `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort anchors on the family's default member and selects the nearest enabled rung, rounding up first; an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays expressed as type-only `anyOf` branches in tool parameters. Shared constraints remain on the surrounding node, so nested schemas have one wire representation while outer enum/const, boolean constraints (including `not`, `oneOf`, and `allOf`) on null, and existing null-branch restrictions retain their conjunctive meaning. A null-only type keeps its null constraint even when an outer enum/const excludes null, preserving an unsatisfiable schema without producing an empty anyOf. Schema resource identifiers, anchors, definition maps, and existing anyOf branches remain on their original node. The type constraint is appended to existing allOf entries without shifting their indices, so JSON Pointer targets and reference scopes remain unchanged. A pre-output `invalid_argument` on a history whose word-piece estimate, using the sanitized and truncated tool descriptions actually sent on the wire, reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. The `COGNITION_BLOCKLIST_REWRITES` sanitizer rewrites exact phrases Cognition refuses with `permission_denied`; it runs only on instruction surfaces — tool descriptions and the #2 system prompt — while message text, replayed thinking (#11), and tool-call arguments stay byte-exact, because a rewrite there would silently change literal content such as patches or quoted file bytes; a trigger inside a data field surfaces as the upstream `permission_denied` instead (the Codex `` escalation boilerplate is the recorded #2 case). | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. Its registry entry is `deprecated`: existing saved providers keep the adapter and their stored configuration, while new-provider discovery (presets, the init menu, the featured list, and key logins) excludes it and `provider add` warns before creating one. | diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 2cce24bcdb6..9f59e7d8799 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -377,4 +377,6 @@ Each argument or full snapshot replacement reserves its complete new logical siz the old physical bytes; two retained copies spend the turn cap without doubling a call's logical limit. Dedupe identities include an entry allowance, so even empty items remain bounded. Queued semantic events and opaque usage are charged until emitted or replaced; cancellation, read failure and -identity refusal release every relay-owned buffer. Ciphertext alone transfers with its done lease. +identity/argument refusal release every relay-owned buffer. Failed terminals close outstanding calls +incomplete and replay queued semantics before the error; diagnostic usage keeps only numeric counters, +releasing any previously retained opaque usage. Ciphertext alone transfers with its done lease. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 3714f54f1f5..bc62f5f2795 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,6 +1,7 @@ { "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "tool-identity-hash.test.ts": "adapters", "zed-openai-contract.test.ts": "providers", + "zed-failure-relay.test.ts": "providers", "server-combo-required-tool-contract.test.ts": "server", "responses-anthropic-compaction-prefix.test.ts": "responses", "service-proxy-env.test.ts": "service", diff --git a/tests/providers/zed-failure-relay.test.ts b/tests/providers/zed-failure-relay.test.ts new file mode 100644 index 00000000000..8d3e22e3181 --- /dev/null +++ b/tests/providers/zed-failure-relay.test.ts @@ -0,0 +1,190 @@ +import { expect, test } from "bun:test"; +import { translateZedOpenAiStream } from "../../src/adapters/zed-openai-stream"; +import { zedEventStream } from "../../src/adapters/zed"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import { scrubZedCredentials } from "../../src/providers/zed"; +import { bridgeToResponsesSSE, buildResponseJSON } from "../../src/bridge"; +import type { AdapterEvent } from "../../src/types"; + +const credentials = { userId: "fixture-user-42", accessToken: "fixture-token-xyz" }; + +async function collect(frames: unknown[], normalize = true) { + const source = new Response(normalize + ? frames.map(event => JSON.stringify({ event }) + "\n").join("") + JSON.stringify({ status: "stream_ended" }) + "\n" + : frames.map(event => `data: ${JSON.stringify(event)}\n\n`).join("")).body!; + const budget = createTranslatorBudget(); + try { + return await Array.fromAsync(translateZedOpenAiStream( + normalize ? zedEventStream(source, "open_ai", value => scrubZedCredentials(value, credentials)) : source, budget, + )); + } finally { + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + budget.dispose(); + } +} + +test("the translator independently filters failure counters and inherited opaque usage", async () => { + const events = await collect([ + { type: "response.completed", response: { output: [], usage: { + input_tokens: 4, output_tokens: 2, metadata: { token: credentials.accessToken }, + } } }, + { type: "response.failed", response: { error: { message: "failure" } } }, + ], false); + expect(events.at(-1)).toEqual({ type: "error", message: "failure", usage: { inputTokens: 4, outputTokens: 2 } }); + const malformed = await collect([{ type: "error", error: { message: "failure" }, usage: { + input_tokens: credentials.accessToken, output_tokens: 2, total_tokens: -1, + input_tokens_details: { cached_tokens: { token: credentials.accessToken }, cache_write_tokens: 3, cache_write_1h_tokens: 1 }, + output_tokens_details: { reasoning_tokens: credentials.userId }, + } }], false); + expect(malformed.at(-1)).toEqual({ type: "error", message: "failure", usage: { + inputTokens: 0, outputTokens: 2, cacheCreationInputTokens: 3, cacheCreation1hInputTokens: 1, + } }); + expect(await wire(malformed, true)).not.toContain(credentials.accessToken); +}); + +for (const kind of ["function_call", "custom_tool_call"] as const) { + const field = kind === "function_call" ? "arguments" : "input"; + const event = kind === "function_call" ? "response.function_call_arguments" : "response.custom_tool_call_input"; + for (const intervening of ["added", "delta"]) { + test(`${kind} cannot replace delivered completion bytes through a later ${intervening}`, async () => { + const item = { type: kind, id: "a", call_id: "call", name: "shell", [field]: "" }; + const events = await collect([ + { type: "response.output_item.added", item }, + { type: `${event}.delta`, item_id: "a", delta: "{" }, + { type: `${event}.done`, item_id: "a", [field]: '{"x":1}' }, + intervening === "added" + ? { type: "response.output_item.added", item: { ...item, [field]: '{"x":2}' } } + : { type: `${event}.delta`, item_id: "a", delta: '"x":2}' }, + { type: "response.output_item.done", item: { ...item, [field]: '{"x":2}', status: "completed" } }, + { type: "response.completed", response: { output: [] } }, + ]); + expect(events.some(value => value.type === "tool_call_end" && !value.incomplete)).toBe(false); + expect(events).toContainEqual({ type: "tool_call_end", incomplete: true }); + expect(events.at(-1)?.type).toBe("error"); + }); + } + test(`${kind} accepts matching/prefix completions and clears speculative input on empty completion`, async () => { + for (const streamed of ['{"x":', '{"x":1}']) { + const item = { type: kind, id: "a", call_id: "call", name: "shell", [field]: "speculative" }; + const events = await collect([ + { type: "response.output_item.added", item }, + { type: `${event}.delta`, item_id: "a", delta: streamed }, + { type: "response.output_item.done", item: { ...item, [field]: '{"x":1}', status: "completed" } }, + { type: "response.completed", response: { output: [] } }, + ]); + expect(events.filter(value => value.type === "tool_call_delta").map(value => value.arguments).join("")).toBe('{"x":1}'); + expect(events).toContainEqual({ type: "tool_call_end" }); + expect(events.at(-1)?.type).toBe("done"); + } + const item = { type: kind, id: "a", call_id: "call", name: "shell", [field]: "speculative" }; + const events = await collect([ + { type: "response.output_item.added", item }, + { type: "response.output_item.done", item: { ...item, [field]: "", status: "completed" } }, + { type: "response.completed", response: { output: [] } }, + ]); + expect(events.some(value => value.type === "tool_call_delta")).toBe(false); + expect(events).toContainEqual({ type: "tool_call_end" }); + expect(events.at(-1)?.type).toBe("done"); + }); +} + +test("upstream failure wins over an unnamed pending call and fences a completed buffered call", async () => { + const events = await collect([ + { type: "response.function_call_arguments.delta", item_id: "unnamed", delta: "{" }, + { type: "response.output_item.added", item: { type: "function_call", id: "b", name: "shell", arguments: "" } }, + { type: "response.output_item.done", item: { type: "function_call", id: "b", name: "shell", arguments: "{}", status: "completed" } }, + { type: "response.failed", response: { error: { message: "original failure" }, usage: { input_tokens: 2, output_tokens: 1 } } }, + ]); + expect(events.filter(value => value.type === "tool_call_end")).toEqual([{ type: "tool_call_end", incomplete: true }]); + expect(events.at(-1)).toEqual({ type: "error", message: "original failure", usage: { inputTokens: 2, outputTokens: 1 } }); +}); + +async function wire(events: AdapterEvent[], streaming: boolean) { + return streaming + ? await new Response(bridgeToResponsesSSE((async function* () { yield* events; })(), "fixture")).text() + : JSON.stringify(buildResponseJSON(events, "fixture")); +} + +for (const streaming of [false, true]) { + for (const terminal of ["response.failed", "error", "response.incomplete"]) { + test(`diagnostic usage retains only numeric counters (${terminal}, stream=${streaming})`, async () => { + const events = await collect([{ type: terminal, response: { + error: { message: "failure" }, incomplete_details: { reason: "max_output_tokens" }, usage: { + input_tokens: 4, output_tokens: 2, total_tokens: 6, + metadata: { account: credentials.userId, request_id: credentials.accessToken }, + input_tokens_details: { cached_tokens: 1, metadata: { token: credentials.accessToken } }, + output_tokens_details: { reasoning_tokens: 1, account: credentials.userId }, + }, + } }]); + const output = await wire(events, streaming); + expect(output).not.toContain(credentials.accessToken); + expect(output).not.toContain(credentials.userId); + expect(events.at(-1)).toMatchObject({ usage: { + inputTokens: 4, outputTokens: 2, totalTokens: 6, cachedInputTokens: 1, reasoningOutputTokens: 1, + } }); + expect(events.at(-1)).not.toHaveProperty("usage.rawUsage"); + expect(output).toContain('"input_tokens":4'); + }); + } +} + +for (const kind of ["function_call", "custom_tool_call"] as const) { + const field = kind === "function_call" ? "arguments" : "input"; + const event = kind === "function_call" ? "response.function_call_arguments" : "response.custom_tool_call_input"; + for (const completion of ["arguments.done", "output_item.done", "response.completed"]) { + for (const snapshot of ['{"x":2}', '{"x":', ""]) { + test(`conflicting authoritative ${kind} ${completion} refuses ${JSON.stringify(snapshot)}`, async () => { + const item = { type: kind, id: "a", call_id: "call", name: "shell", [field]: snapshot, status: "completed" }; + const authoritative = completion === "arguments.done" ? { type: `${event}.done`, item_id: "a", [field]: snapshot } + : completion === "output_item.done" ? { type: "response.output_item.done", item } + : { type: "response.completed", response: { output: [item] } }; + const events = await collect([ + { type: "response.output_item.added", item: { ...item, [field]: "", status: "in_progress" } }, + { type: `${event}.delta`, item_id: "a", delta: '{"x":1}' }, + authoritative, + { type: "response.completed", response: { output: [] } }, + ]); + expect(events.some(value => value.type === "tool_call_end" && !value.incomplete)).toBe(false); + expect(events).toContainEqual({ type: "tool_call_end", incomplete: true }); + expect(events.at(-1)?.type).toBe("error"); + const result = buildResponseJSON(events, "fixture"); + expect(result.status).toBe("failed"); + expect(result.output).toContainEqual(expect.objectContaining({ type: "function_call", status: "incomplete" })); + expect(await wire(events, true)).toContain('"status":"incomplete"'); + }); + } + } +} + +for (const terminal of ["response.failed", "error", "untyped"]) { + test(`queued text and reasoning survive ${terminal} after incomplete calls`, async () => { + const events = await collect([ + { type: "response.output_item.added", item: { type: "function_call", id: "a", call_id: "call", name: "shell", arguments: "" } }, + { type: "response.function_call_arguments.delta", item_id: "a", delta: '{"x":' }, + { type: "response.output_text.delta", item_id: "m", delta: "received explanation" }, + { type: "response.reasoning_summary_text.delta", item_id: "r", delta: "received summary" }, + { type: "response.reasoning_text.delta", item_id: "r", delta: "received reasoning" }, + { type: "response.output_item.added", item: { type: "custom_tool_call", id: "b", call_id: "call2", name: "other", input: "" } }, + { type: "response.custom_tool_call_input.delta", item_id: "b", delta: "partial" }, + { ...(terminal === "untyped" ? {} : { type: terminal }), error: { message: "upstream failed" }, + response: { usage: { input_tokens: 4, output_tokens: 2 } } }, + ]); + expect(events.filter(value => value.type === "tool_call_end")).toEqual([ + { type: "tool_call_end", incomplete: true }, { type: "tool_call_end", incomplete: true }, + ]); + expect(events.filter(value => ["text_delta", "thinking_delta", "reasoning_raw_delta"].includes(value.type))).toEqual([ + { type: "text_delta", text: "received explanation" }, + { type: "thinking_delta", thinking: "received summary" }, + { type: "reasoning_raw_delta", text: "received reasoning" }, + ]); + expect(events.at(-1)).toMatchObject({ type: "error", message: "upstream failed", usage: { inputTokens: 4, outputTokens: 2 } }); + const result = buildResponseJSON(events, "fixture"); + expect(JSON.stringify(result.output)).toContain("received explanation"); + expect(result.status).toBe("failed"); + const output = await wire(events, true); + expect(output).toContain("received explanation"); + expect(output).toContain("received summary"); + expect(output).toContain("received reasoning"); + expect(output).toContain("response.failed"); + }); +} diff --git a/tests/providers/zed-openai-contract.test.ts b/tests/providers/zed-openai-contract.test.ts index 09103bd6a5f..0825d69fd9d 100644 --- a/tests/providers/zed-openai-contract.test.ts +++ b/tests/providers/zed-openai-contract.test.ts @@ -260,6 +260,7 @@ describe("Zed OpenAI terminal contract", () => { ])).toEqual([ { type: "tool_call_start", id: "call", name: "shell" }, { type: "tool_call_delta", arguments: "{" }, + { type: "tool_call_end", incomplete: true }, { type: "error", message: "failure", usage: { inputTokens: 7, outputTokens: 3 } }, ]); }); From 7c3cebbc5d30b70d3b8e138442f96449162a9549 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:03:18 +0900 Subject: [PATCH 12/13] fix(zed): retain tool input authority and reconcile semantic arguments Validate late custom input and function argument deltas against retained authority regardless of parallel emission order. Preserve delivered bytes while accepting equivalent JSON snapshots, fragmented late serialization, and completed replays. Separate raw, custom, and semantic replay fingerprints. Port independent review reproductions and add JSON/SSE, partial-prefix, contradiction, numeric, replay, and budget regressions. Keep round-1 failure usage filtering, queued output, and incomplete-call protections. Co-authored-by: Andrew Wang <59988150+andrew05060414@users.noreply.github.com> --- .../src/content/docs/guides/providers.md | 2 +- scripts/test-layout/layout-adapters.json | 1 + src/adapters/zed-openai-stream.ts | 73 ++++- src/adapters/zed-tool-arguments.ts | 103 ++++++ structure/providers-and-adapters.md | 2 +- structure/transports/byte-accounting.md | 2 + tests/fixtures/test-layout-expected.json | 1 + .../zed-argument-consistency.test.ts | 304 ++++++++++++++++++ 8 files changed, 470 insertions(+), 18 deletions(-) create mode 100644 src/adapters/zed-tool-arguments.ts create mode 100644 tests/providers/zed-argument-consistency.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index fdc3fdaa153..15db7c2e2b8 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -381,7 +381,7 @@ Current-turn tool declarations carried in Codex's `additional_tools` input are p supported tool catalog. Function calls and custom-tool input are relayed in order, including interleaved calls, so a tool-using turn can continue after its introductory text. Incomplete calls keep only received arguments and remain incomplete; cancellation releases buffered data. Conflicting -completed tool input fails the turn with incomplete calls. On upstream failure, received text and +completed custom input, including later deltas, fails the turn with incomplete calls. Function arguments accept equivalent JSON values and compatible partial snapshots while preserving delivered bytes. On upstream failure, received text and reasoning remain visible and usage retains numeric counters only. The relay is covered by synthetic stream fixtures; live hosted-stream compatibility remains unverified. For the canonical Kimi Coding Plan presets (`kimi` account login and `kimi-code` API key), diff --git a/scripts/test-layout/layout-adapters.json b/scripts/test-layout/layout-adapters.json index 02443ef5c78..f6fddf39428 100644 --- a/scripts/test-layout/layout-adapters.json +++ b/scripts/test-layout/layout-adapters.json @@ -1,6 +1,7 @@ { "zed-openai-contract.test.ts": "providers", "zed-failure-relay.test.ts": "providers", + "zed-argument-consistency.test.ts": "providers", "cursor-native-grep-globs.test.ts": "providers/cursor", "tool-identity-hash.test.ts": "adapters", "eventstream-fragmentation.test.ts": "responses", diff --git a/src/adapters/zed-openai-stream.ts b/src/adapters/zed-openai-stream.ts index d78ed067da6..a93f83ca72e 100644 --- a/src/adapters/zed-openai-stream.ts +++ b/src/adapters/zed-openai-stream.ts @@ -6,7 +6,8 @@ import { truncationReasonFor } from "../responses/truncated-stop-reason"; import { unnamedToolCallEvent } from "./openai-chat/response-events"; import { usageFromResponsesPayload } from "./openai-responses/usage"; import { zedDiagnosticUsage } from "./zed-diagnostic-usage"; -import { createHash, randomUUID } from "node:crypto"; +import { consistentZedArgumentDelta, reconcileZedArguments, zedArgumentFingerprint } from "./zed-tool-arguments"; +import { randomUUID } from "node:crypto"; function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); @@ -24,6 +25,7 @@ interface CallState { name: string; args: string; pendingFull?: string; + authoritative: boolean; argsBytes: number; fullBytes: number; lastCodeUnit: number; @@ -143,16 +145,32 @@ export async function* translateZedOpenAiStream( function argumentText(state: CallState): string { const full = state.pendingFull; - if (state.allowFull && typeof full === "string" && full.startsWith(state.args)) return full; + if (state.allowFull && typeof full === "string" && (state.authoritative || full.startsWith(state.args))) return full; return state.args; } + function argumentWorkspace(texts: string[], work: () => T): T { + const reservation = budget.reserveTransient(texts.reduce((size, text) => size + Buffer.byteLength(text, "utf8"), 0), + { kind: "retained_collectors" }); + try { return work(); } finally { reservation.release(); } + } + + function fingerprint(state: CallState, text: string): string { + return argumentWorkspace([text], () => zedArgumentFingerprint(text, state.kind === "custom_tool_call")); + } + /** Emit the oldest open call. Newer calls stay buffered until that call closes. */ function* pump(preserveFailure = false): Generator { while (true) { if (terminalError) return; const next = [...calls.values()].find(state => !state.ended); if (!next) return; + if (next.closed && next.allowFull && !next.incomplete && next.authoritative) { + const full = next.pendingFull ?? next.args; + const consistent = argumentWorkspace([next.args, full], () => + reconcileZedArguments(next.args, full, next.kind === "custom_tool_call") !== undefined); + if (!consistent) throw new ZedToolArgumentsError(); + } if (!next.name) { if (!next.closed) return; if (!preserveFailure) { @@ -176,7 +194,7 @@ export async function* translateZedOpenAiStream( } if (!next.closed) return; next.ended = true; - next.finalHash = createHash("sha256").update(text).digest("hex"); + next.finalHash = fingerprint(next, text); if (next.name) yield { type: "tool_call_end", ...(!next.allowFull || next.incomplete ? { incomplete: true } : {}) }; releaseCall(next); for (const entry of next.after) { @@ -190,15 +208,26 @@ export async function* translateZedOpenAiStream( function* appendArgs(state: CallState, fragment: string): Generator { if (!fragment || state.ended) return; - // A full done snapshot may have supplied bytes ahead of these later deltas. - const overlap = Math.min(fragment.length, state.emitted - state.args.length); - if (overlap > 0 && fragment.slice(0, overlap) !== argumentText(state).slice(state.args.length, state.args.length + overlap)) { - throw new ZedToolArgumentsError(); - } const nextBytes = appendedUtf8Bytes(state.argsBytes, state.lastCodeUnit, fragment); const scope = { kind: "tool_args" as const, callId: state.budgetKey }; const reservation = budget.reserveTransient(nextBytes, scope); - state.args += fragment; + try { + const next = state.args + fragment; + if (state.authoritative) { + const full = state.pendingFull ?? state.args; + const consistent = argumentWorkspace([next, full], () => + consistentZedArgumentDelta(next, full, state.kind === "custom_tool_call")); + if (!consistent) throw new ZedToolArgumentsError(); + // The original string is still the delivery/authority owner while the late stream + // catches up using an equivalent serialization. Transfer its existing lease. + if (state.pendingFull === undefined) { + state.pendingFull = state.args; + state.fullBytes = state.argsBytes; + state.argsBytes = 0; + } + } + state.args = next; + } catch (error) { reservation.release(); throw error; } reservation.commitRetained(); budget.releaseRetained(state.argsBytes, scope); state.argsBytes = nextBytes; @@ -210,14 +239,25 @@ export async function* translateZedOpenAiStream( // A closed call's buffers were already released; charging again would outlive their owner. if (typeof full !== "string" || (!authoritative && full.length === 0)) return; if (state.ended) { - if (state.finalHash && state.finalHash !== createHash("sha256").update(full).digest("hex")) throw new ZedToolIdentityError(); + if (state.finalHash && state.finalHash !== fingerprint(state, full)) throw new ZedToolIdentityError(); return; } if (!authoritative && state.allowFull) return; - // Added-item snapshots are speculative. A completion must agree with received deltas - // and with any full snapshot bytes already delivered, including empty/shorter values. - if (authoritative && (!full.startsWith(state.args) - || !full.startsWith(argumentText(state).slice(0, state.emitted)))) throw new ZedToolArgumentsError(); + // Completed input remains authoritative even when another call blocks all emission. + if (authoritative) { + const snapshot = full; + full = argumentWorkspace([state.args, snapshot], () => { + const custom = state.kind === "custom_tool_call"; + const confirmed = state.pendingFull ?? state.args; + if (state.authoritative && fingerprint(state, confirmed) !== fingerprint(state, snapshot)) return; + if (state.authoritative) return confirmed; + const received = reconcileZedArguments(state.args, snapshot, custom); + if (received === undefined) return; + return reconcileZedArguments(argumentText(state).slice(0, state.emitted), received, custom); + }); + if (typeof full !== "string") throw new ZedToolArgumentsError(); + state.authoritative = true; + } if (full === state.pendingFull) return; if (full === state.args) { budget.releaseRetained(state.fullBytes, { kind: "tool_args", callId: state.budgetKey }); @@ -328,6 +368,7 @@ export async function* translateZedOpenAiStream( after: [], name, args: "", + authoritative: false, argsBytes: 0, fullBytes: 0, lastCodeUnit: 0, @@ -595,7 +636,7 @@ export async function* translateZedOpenAiStream( ) { const key = typeof payload.item_id === "string" ? payload.item_id : undefined; if (!key) return; - const state = ensureCall(key, "", ""); + const state = ensureCall(key, "", "", payload.type === "response.custom_tool_call_input.delta" ? "custom_tool_call" : "function_call"); yield* appendArgs(state, payload.delta); if (!state.started && !state.ended && payload.delta) yield { type: "heartbeat" }; return; @@ -603,7 +644,7 @@ export async function* translateZedOpenAiStream( if (payload.type === "response.function_call_arguments.done" || payload.type === "response.custom_tool_call_input.done") { const key = typeof payload.item_id === "string" ? payload.item_id : undefined; if (!key) return; - const state = ensureCall(key, "", ""); + const state = ensureCall(key, "", "", payload.type === "response.custom_tool_call_input.done" ? "custom_tool_call" : "function_call"); rememberFull(state, payload.type === "response.custom_tool_call_input.done" ? payload.input : payload.arguments, true); state.argsDone = true; state.allowFull = true; diff --git a/src/adapters/zed-tool-arguments.ts b/src/adapters/zed-tool-arguments.ts new file mode 100644 index 00000000000..de358734c87 --- /dev/null +++ b/src/adapters/zed-tool-arguments.ts @@ -0,0 +1,103 @@ +import { createHash } from "node:crypto"; + +/** Object order is immaterial; array order and string contents remain significant. */ +export function zedArgumentFingerprint(text: string, custom: boolean): string { + const hash = createHash("sha256"); + if (custom) return hash.update("custom:").update(text).digest("hex"); + let value: unknown; + try { value = JSON.parse(text); } catch { return hash.update("raw:").update(text).digest("hex"); } + hash.update("json:"); + const stack: unknown[] = [value]; + while (stack.length) { + const next = stack.pop(); + if (next !== null && typeof next === "object") { + if (Array.isArray(next)) { + hash.update(`array:${next.length}:`); + for (let i = next.length - 1; i >= 0; i--) stack.push(next[i]); + } else { + const record = next as Record; + const keys = Object.keys(record).sort(); + hash.update(`object:${keys.length}:`); + for (let i = keys.length - 1; i >= 0; i--) stack.push(record[keys[i]!], keys[i]!); + } + } else { + // JSON.parse can produce +/-Infinity from a valid exponent such as 1e400. + // JSON.stringify would collapse both numbers and null to the same token. + const scalar = typeof next === "number" ? Object.is(next, -0) ? "-0" : String(next) : JSON.stringify(next); + hash.update(`scalar:${typeof next}:${scalar.length}:${scalar}`); + } + } + return hash.digest("hex"); +} + +function equalJson(left: string, right: string): boolean { + try { return Bun.deepEquals(JSON.parse(left), JSON.parse(right)); } catch { return false; } +} + +function unfinishedJsonValue(text: string): boolean { + const value = text.trimStart(); + if (!value) return true; + if (value[0] !== "{" && value[0] !== "[" && value[0] !== '"') { + return ["true", "false", "null"].some(literal => literal.startsWith(value)) + || /^-?(?:(?:0|[1-9]\d*)(?:\.\d*)?(?:[eE][+-]?\d*)?)?$/.test(value); + } + let depth = 0; + let quoted = false; + let escaped = false; + for (let i = 0; i < value.length; i++) { + const char = value[i]; + if (escaped) { escaped = false; continue; } + if (quoted && char === "\\") { escaped = true; continue; } + if (char === '"') { + quoted = !quoted; + if (!quoted && value[0] === '"') return false; + } else if (!quoted) { + if (char === "{" || char === "[") depth++; + if (char === "}" || char === "]") { + if (--depth <= 0) return false; + } + } + } + return true; +} + +/** A number token or unfinished value may still extend; closure validates the final prefix. */ +export function consistentZedArgumentDelta(prefix: string, full: string, custom: boolean): boolean { + if (custom) return full.startsWith(prefix); + try { + const value: unknown = JSON.parse(prefix); + return typeof value === "number" || Bun.deepEquals(value, JSON.parse(full)); + } catch { return unfinishedJsonValue(prefix); } +} + +/** Locate a prefix modulo insignificant JSON whitespace, never whitespace inside strings. */ +function prefixEnd(prefix: string, full: string): number | undefined { + let p = 0; + let f = 0; + let quoted = false; + let escaped = false; + while (p < prefix.length) { + if (!quoted) { + while (p < prefix.length && /[\x20\t\r\n]/.test(prefix[p]!)) p++; + while (f < full.length && /[\x20\t\r\n]/.test(full[f]!)) f++; + if (p === prefix.length) break; + } + const char = prefix[p++]; + if (char !== full[f++]) return; + if (escaped) escaped = false; + else if (quoted && char === "\\") escaped = true; + else if (char === '"') quoted = !quoted; + } + return f; +} + +/** Keep every delivered byte and append only a suffix that still represents the full value. */ +export function reconcileZedArguments(prefix: string, full: string, custom: boolean): string | undefined { + if (full.startsWith(prefix)) return full; + if (custom) return; + if (equalJson(prefix, full)) return prefix; + const end = prefixEnd(prefix, full); + if (end === undefined) return; + const rebuilt = prefix + full.slice(end); + return equalJson(rebuilt, full) ? rebuilt : undefined; +} diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index f9e944d8e0b..1d24b96e319 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -208,7 +208,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge; optional `minMaxOutputTokens` raises only smaller explicit positive integer output budgets. Unset preserves caller caps. Serialization and pre-admission spend estimates share `effectiveAzureOutputBudget`; see the [spend contract](transports/responses-spend.md#durable-spend-reservations). | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | -| `src/adapters/zed.ts`, `src/providers/zed.ts`, `src/oauth/zed.ts` | Zed Hosted AI native-app RSA login, account-scoped short-lived LLM-token exchange, bounded live model discovery, and provider-envelope translation through the existing Anthropic, Google, Responses, and Chat builders. Buffered delivery retains events under the shared translator budget and cancels delegated streams on early exit. The Responses delegate applies destination compatibility, strips OpenAI-private fields and unsupported built-in tools, reconciles removed tool choices through the shared hosted-tool policy, and maps OpenAI `developer` messages to Zed-supported `system` messages before wrapping the request. `src/adapters/zed-openai-stream.ts` translates the OpenAI stream: the current turn's `additional_tools` are promoted on a detached copy before replay repair, interleaved function and custom calls stay buffered until the oldest open call's authoritative item end or successful response terminal because adapter deltas are unkeyed, and end of stream or an incomplete response flushes only arguments that already arrived, marking unfinished calls incomplete. Text and reasoning wait behind an open call; invisible heartbeats report buffered progress. Item aliases share one execution identity and conflicting reuse fails closed. Missing tool names fail the turn, and no output follows a terminal error. Per-item reasoning and done-only text fallback suppress completed replays. A completed compaction item keeps its ciphertext on the done event and transfers that retained lease. Arguments, full snapshots, dedupe identities, queued events, text and opaque usage are budgeted and released on failure or cancellation; typed and untyped native error envelopes scrub every diagnostic string, including code/type, with the captured credential scrubber and discard diagnostic metadata before delegation. Untyped error envelopes terminate the OpenAI relay. Argument-done frames confirm bytes only; item status or successful response completion authorizes executable closure. Authoritative function/custom snapshots must extend received arguments and already delivered bytes; conflicts close outstanding calls incomplete and fail the turn. Failed terminals close outstanding calls incomplete and replay queued text/reasoning before the original error. Diagnostic usage retains only finite nonnegative numeric token counters through `src/adapters/zed-diagnostic-usage.ts`, including nested cache/reasoning counters; opaque extensions are discarded. `tests/providers/zed-failure-relay.test.ts` covers JSON/SSE failure projection and completion conflicts. `src/adapters/zed-additional-tools.ts` owns tool promotion, while `src/adapters/openai-responses/usage.ts` shares usage projection with Responses compaction. `tests/providers/zed-openai-contract.test.ts` covers the relay and client-boundary regressions. Experimental and unofficial; the Zed terms risk is intentionally surfaced in user docs and release review. | +| `src/adapters/zed.ts`, `src/providers/zed.ts`, `src/oauth/zed.ts` | Zed Hosted AI native-app RSA login, account-scoped short-lived LLM-token exchange, bounded live model discovery, and provider-envelope translation through the existing Anthropic, Google, Responses, and Chat builders. Buffered delivery retains events under the shared translator budget and cancels delegated streams on early exit. The Responses delegate applies destination compatibility, strips OpenAI-private fields and unsupported built-in tools, reconciles removed tool choices through the shared hosted-tool policy, and maps OpenAI `developer` messages to Zed-supported `system` messages before wrapping the request. `src/adapters/zed-openai-stream.ts` translates the OpenAI stream: the current turn's `additional_tools` are promoted on a detached copy before replay repair, interleaved function and custom calls stay buffered until the oldest open call's authoritative item end or successful response terminal because adapter deltas are unkeyed, and end of stream or an incomplete response flushes only arguments that already arrived, marking unfinished calls incomplete. Text and reasoning wait behind an open call; invisible heartbeats report buffered progress. Item aliases share one execution identity and conflicting reuse fails closed. Missing tool names fail the turn, and no output follows a terminal error. Per-item reasoning and done-only text fallback suppress completed replays. A completed compaction item keeps its ciphertext on the done event and transfers that retained lease. Arguments, full snapshots, dedupe identities, queued events, text and opaque usage are budgeted and released on failure or cancellation; typed and untyped native error envelopes scrub every diagnostic string, including code/type, with the captured credential scrubber and discard diagnostic metadata before delegation. Untyped error envelopes terminate the OpenAI relay. Argument-done frames confirm bytes only; item status or successful response completion authorizes executable closure. Authoritative custom snapshots and every later delta agree byte-for-byte regardless of emission order; extensions or contradictions close outstanding calls incomplete and fail the turn. Function arguments use JSON-value equality for complete snapshots and replay fingerprints, ignoring object-key order; compatible whitespace-adjusted prefixes append only a validated suffix and preserve delivered bytes. `src/adapters/zed-tool-arguments.ts` owns semantic reconciliation and domain-separated replay fingerprints. Extensible late function fragments stay buffered under authority and are reconciled again before executable closure, including calls held behind another call; `tests/providers/zed-argument-consistency.test.ts` covers late, buffered and JSON/SSE cases. Failed terminals close outstanding calls incomplete and replay queued text/reasoning before the original error. Diagnostic usage retains only finite nonnegative numeric token counters through `src/adapters/zed-diagnostic-usage.ts`, including nested cache/reasoning counters; opaque extensions are discarded. `tests/providers/zed-failure-relay.test.ts` covers JSON/SSE failure projection and completion conflicts. `src/adapters/zed-additional-tools.ts` owns tool promotion, while `src/adapters/openai-responses/usage.ts` shares usage projection with Responses compaction. `tests/providers/zed-openai-contract.test.ts` covers the relay and client-boundary regressions. Experimental and unofficial; the Zed terms risk is intentionally surfaced in user docs and release review. | | `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort anchors on the family's default member and selects the nearest enabled rung, rounding up first; an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays expressed as type-only `anyOf` branches in tool parameters. Shared constraints remain on the surrounding node, so nested schemas have one wire representation while outer enum/const, boolean constraints (including `not`, `oneOf`, and `allOf`) on null, and existing null-branch restrictions retain their conjunctive meaning. A null-only type keeps its null constraint even when an outer enum/const excludes null, preserving an unsatisfiable schema without producing an empty anyOf. Schema resource identifiers, anchors, definition maps, and existing anyOf branches remain on their original node. The type constraint is appended to existing allOf entries without shifting their indices, so JSON Pointer targets and reference scopes remain unchanged. A pre-output `invalid_argument` on a history whose word-piece estimate, using the sanitized and truncated tool descriptions actually sent on the wire, reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. The `COGNITION_BLOCKLIST_REWRITES` sanitizer rewrites exact phrases Cognition refuses with `permission_denied`; it runs only on instruction surfaces — tool descriptions and the #2 system prompt — while message text, replayed thinking (#11), and tool-call arguments stay byte-exact, because a rewrite there would silently change literal content such as patches or quoted file bytes; a trigger inside a data field surfaces as the upstream `permission_denied` instead (the Codex `` escalation boilerplate is the recorded #2 case). | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. Its registry entry is `deprecated`: existing saved providers keep the adapter and their stored configuration, while new-provider discovery (presets, the init menu, the featured list, and key logins) excludes it and `provider add` warns before creating one. | diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 9f59e7d8799..1d6da54a7df 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -375,6 +375,8 @@ Explicitly selected canonical Codex Lite ordinary unreferenced history notes reu The Zed OpenAI relay uses private per-call budget keys so client encoders cannot close its leases. Each argument or full snapshot replacement reserves its complete new logical size before releasing the old physical bytes; two retained copies spend the turn cap without doubling a call's logical limit. +JSON reconciliation and semantic replay hashing reserve transient source bytes; rebuilt suffixes +are admitted before retention. Late deltas retain the authoritative delivery string and its lease. Dedupe identities include an entry allowance, so even empty items remain bounded. Queued semantic events and opaque usage are charged until emitted or replaced; cancellation, read failure and identity/argument refusal release every relay-owned buffer. Failed terminals close outstanding calls diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index bc62f5f2795..acb8fb93197 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -2,6 +2,7 @@ "tool-identity-hash.test.ts": "adapters", "zed-openai-contract.test.ts": "providers", "zed-failure-relay.test.ts": "providers", + "zed-argument-consistency.test.ts": "providers", "server-combo-required-tool-contract.test.ts": "server", "responses-anthropic-compaction-prefix.test.ts": "responses", "service-proxy-env.test.ts": "service", diff --git a/tests/providers/zed-argument-consistency.test.ts b/tests/providers/zed-argument-consistency.test.ts new file mode 100644 index 00000000000..dad193f3854 --- /dev/null +++ b/tests/providers/zed-argument-consistency.test.ts @@ -0,0 +1,304 @@ +import { expect, test } from 'bun:test'; +import { translateZedOpenAiStream } from '../../src/adapters/zed-openai-stream'; +import { zedEventStream } from '../../src/adapters/zed'; +import { createTranslatorBudget, type TranslatorBudgetOptions } from '../../src/lib/translator-budget'; +import { bridgeToResponsesSSE, buildResponseJSON } from '../../src/bridge'; + +async function collect(frames: unknown[], options?: TranslatorBudgetOptions) { + const source = new Response(frames.map(event => JSON.stringify({event})+'\n').join('')+'{"status":"stream_ended"}\n').body!; + const budget = createTranslatorBudget(options); + try { return await Array.fromAsync(translateZedOpenAiStream(zedEventStream(source,'open_ai'),budget)); } + finally { expect(budget.snapshot()).toMatchObject({currentBytes:0,activeCalls:0}); budget.dispose(); } +} +const item = (args: string) => ({type:'function_call',id:'a',call_id:'call',name:'shell',arguments:args,status:'completed'}); +const added = {type:'response.output_item.added',item:{...item(''),status:'in_progress'}}; +const delta = (args: string) => ({type:'response.function_call_arguments.delta',item_id:'a',delta:args}); +const ended = (args: string) => ({type:'response.output_item.done',item:item(args)}); +const completed = (output: unknown[] = []) => ({type:'response.completed',response:{output}}); + +for (const [label,streamed,final] of [ + ['whitespace','{"x":1}','{ "x": 1 }'], + ['key order','{"x":1,"y":2}','{"y":2,"x":1}'], + ['nested order','{"x":{"a":1,"b":2}}','{"x":{"b":2,"a":1}}'], + ['partial with whitespace','{"x":','{ "x": 1 }'], +] as const) test(`equivalent function JSON must succeed: ${label}`,async()=>{ + const events = await collect([added,delta(streamed),ended(final),completed()]); + expect(events.at(-1)?.type).toBe('done'); + expect(events).toContainEqual({type:'tool_call_end'}); + expect(buildResponseJSON(events,'fixture').status).toBe('completed'); +}); + +const argumentDeltas = (events: Awaited>) => + events.filter(event => event.type === 'tool_call_delta').map(event => event.arguments).join(''); + +for (const [label, prefix, final] of [ + ['full whitespace', '{"x":1}', '{ "x": 1 }'], + ['partial whitespace', '{"x":', '{ "x": 1 }'], + ['partial string whitespace', '{"x":"a ', '{ "x": "a b" }'], + ['nested key order', '{"x":{"a":1,"b":2}}', '{"x":{"b":2,"a":1}}'], + ['array values', '[1,{"a":true,"b":null}]', '[1, {"b": null, "a": true}]'], +] as const) { + for (const streaming of [false, true]) test(`function wire preserves delivered bytes: ${label}, stream=${streaming}`, async () => { + const events = await collect([added, delta(prefix), ended(final), completed()]); + const delivered = argumentDeltas(events); + expect(delivered.startsWith(prefix)).toBe(true); + expect(JSON.parse(delivered)).toEqual(JSON.parse(final)); + let result = buildResponseJSON(events, 'fixture'); + if (streaming) { + const wire = await new Response(bridgeToResponsesSSE((async function* () { yield* events; })(), 'fixture')).text(); + result = wire.split('\n').filter(line => line.startsWith('data: {')).map(line => JSON.parse(line.slice(6))) + .findLast(event => event.type === 'response.completed')?.response; + } + expect(result.status).toBe('completed'); + expect(result.output).toContainEqual(expect.objectContaining({ type: 'function_call', arguments: delivered, status: 'completed' })); + }); +} + +for (const [first, full, late] of [ + ['{"x":', '{"x":1}', ' 1}'], + ['{"x":1}', '{ "x": 1 }', ' '], + ['', '{"x":1,"y":2}', '{"y":2,"x":1}'], +] as const) test(`late equivalent function bytes never rewrite delivered input: ${JSON.stringify(late)}`, async () => { + const events = await collect([added, delta(first), + { type: 'response.function_call_arguments.done', item_id: 'a', arguments: full }, + delta(late), ended(full), completed()]); + expect(events.at(-1)?.type).toBe('done'); + const delivered = argumentDeltas(events); + expect(JSON.parse(delivered)).toEqual(JSON.parse(full)); + expect(delivered).toBe(first === '' ? full : first === '{"x":' ? '{"x":1}' : first); +}); + +for (const kind of ['function_call', 'custom_tool_call'] as const) { + const field = kind === 'function_call' ? 'arguments' : 'input'; + const event = kind === 'function_call' ? 'response.function_call_arguments' : 'response.custom_tool_call_input'; + for (const parallel of [false, true]) { + for (const contradictory of ['{"x":2}', '{"x":1} extra']) test(`${kind} refuses late contradiction with zero/visible emission: ${parallel}, ${contradictory}`, async () => { + const target = { type: kind, id: 'b', call_id: 'call-b', name: 'fixture_tool', [field]: '' }; + const events = await collect([ + ...(parallel ? [added] : []), + { type: 'response.output_item.added', item: target }, + { type: `${event}.done`, item_id: 'b', [field]: '{"x":1}' }, + { type: `${event}.delta`, item_id: 'b', delta: contradictory }, + ...(parallel ? [delta('{}'), ended('{}')] : []), completed(), + ]); + expect(events.at(-1)?.type).toBe('error'); + expect(events.some(value => value.type === 'tool_call_end' && !value.incomplete)).toBe(false); + expect(events).toContainEqual({ type: 'tool_call_end', incomplete: true }); + }); + } + test(`${kind} accepts identical late delivery behind a parallel call`, async () => { + const target = { type: kind, id: 'b', call_id: 'call-b', name: 'fixture_tool', [field]: '' }; + const events = await collect([added, { type: 'response.output_item.added', item: target }, + { type: `${event}.done`, item_id: 'b', [field]: '{"x":1}' }, + { type: `${event}.delta`, item_id: 'b', delta: '{"x":1}' }, + delta('{}'), ended('{}'), completed()]); + expect(events.at(-1)?.type).toBe('done'); + expect(events.filter(value => value.type === 'tool_call_end' && !value.incomplete)).toHaveLength(2); + }); + test(`${kind} refuses extension of authoritative empty input without an added item`, async () => { + const events = await collect([ + { type: `${event}.done`, item_id: 'a', [field]: '' }, + { type: `${event}.delta`, item_id: 'a', delta: '{}' }, + { type: 'response.output_item.done', item: { type: kind, id: 'a', name: 'fixture_tool', [field]: '{}' } }, + completed(), + ]); + expect(events.at(-1)?.type).toBe('error'); + expect(events.some(value => value.type === 'tool_call_end' && !value.incomplete)).toBe(false); + }); +} + +for (const conflicting of ['{"x":2}', '{"x":', '{"x":"1"}', '[1,2]', '{"x":1} trailing']) { + test(`completed function replay rejects changed or malformed values: ${conflicting}`, async () => { + const events = await collect([added, delta('{"x":1}'), ended('{"x":1}'), completed([item(conflicting)])]); + expect(events.at(-1)?.type).toBe('error'); + expect(events.filter(value => value.type === 'tool_call_end')).toHaveLength(1); + }); +} + +test('semantic argument reconstruction still enforces the call ceiling and releases every lease', async () => { + await expect(collect([added, delta('{"x":'), ended('{ "x": "long value" }'), completed()], + { maxCallArgumentBytes: 12 })).rejects.toMatchObject({ name: 'TranslatorBudgetExceededError' }); +}); + +test('semantic replay fingerprint distinguishes overflowing JSON numbers from null and opposite infinity', async () => { + for (const conflicting of ['{"x":null}', '{"x":-1e400}']) { + const events = await collect([added, delta('{"x":1e400}'), ended('{"x":1e400}'), completed([item(conflicting)])]); + expect(events.at(-1)?.type).toBe('error'); + } +}); + +for (const parallel of [false, true]) test(`late reordered function JSON accepts split token boundaries (parallel=${parallel})`, async () => { + const target = { ...item(''), id: 'b', call_id: 'call-b' }; + const events = await collect([ + ...(parallel ? [added] : []), { type: 'response.output_item.added', item: target }, + { type: 'response.function_call_arguments.done', item_id: 'b', arguments: '{"x":1,"y":2}' }, + ...['{"y":2,', '"x":1}'].map(value => ({ type: 'response.function_call_arguments.delta', item_id: 'b', delta: value })), + ...(parallel ? [delta('{}'), ended('{}')] : []), completed(), + ]); + expect(events.at(-1)?.type).toBe('done'); + expect(events.filter(value => value.type === 'tool_call_end' && !value.incomplete)).toHaveLength(parallel ? 2 : 1); +}); + +test('malformed function replay cannot impersonate the canonical fingerprint input', async () => { + const events = await collect([added, delta('{}'), ended('{}'), completed([item('object:0:')])]); + expect(events.at(-1)?.type).toBe('error'); +}); + +for (const [full, late] of [ + ['{"x":1,"y":2}', '{"y":2,"x":1}'], + ['{"x":{"a":1,"b":2},"y":[true,null]}', '{"y":[true,null],"x":{"b":2,"a":1}}'], + ['{"x":"a b"}', '{"\\u0078":"\\u0061 b"}'], + ['{"x":"a\\\"\\\\b\\n"}', '{"x":"a\\u0022\\u005cb\\u000a"}'], +] as const) { + for (const parallel of [false, true]) test(`late equivalent JSON survives single-character fragments: ${late}, parallel=${parallel}`, async () => { + const target = { ...item(''), id: 'b', call_id: 'call-b' }; + const events = await collect([...(parallel ? [added] : []), + { type: 'response.output_item.added', item: target }, + { type: 'response.function_call_arguments.done', item_id: 'b', arguments: full }, + ...late.split('').map(value => ({ type: 'response.function_call_arguments.delta', item_id: 'b', delta: value })), + ...(parallel ? [delta('{}'), ended('{}')] : []), completed()]); + expect(events.at(-1)?.type).toBe('done'); + expect(events.filter(value => value.type === 'tool_call_end' && !value.incomplete)).toHaveLength(parallel ? 2 : 1); + expect(buildResponseJSON(events, 'fixture').output).toContainEqual(expect.objectContaining({ call_id: 'call-b', arguments: full, status: 'completed' })); + }); +} + +for (const late of ['{"y":3,', '{"unknown":', '{"y":[', '{"x":"wrong', '{"y":2 "x"', '{"x":1,"x":']) { + test(`partial semantic input rejects proven contradictions: ${late}`, async () => { + const events = await collect([added, + { type: 'response.function_call_arguments.done', item_id: 'a', arguments: '{"x":1,"y":2}' }, + delta(late), completed()]); + expect(events.at(-1)?.type).toBe('error'); + expect(events.some(value => value.type === 'tool_call_end' && !value.incomplete)).toBe(false); + }); +} + +test('raw function arguments cannot impersonate a later parsed JSON replay', async () => { + const events = await collect([added, delta('object:0:'), ended('object:0:'), completed([item('{}')])]); + expect(events.at(-1)?.type).toBe('error'); +}); + +for (const streaming of [false, true]) test(`split late JSON preserves successful client projection (stream=${streaming})`, async () => { + const events = await collect([added, + { type: 'response.function_call_arguments.done', item_id: 'a', arguments: '{"x":1,"y":2}' }, + delta('{"y":2,'), delta('"x":1}'), ended('{"x":1,"y":2}'), completed()]); + let result = buildResponseJSON(events, 'fixture'); + if (streaming) { + const wire = await new Response(bridgeToResponsesSSE((async function* () { yield* events; })(), 'fixture')).text(); + result = wire.split('\n').filter(line => line.startsWith('data: {')).map(line => JSON.parse(line.slice(6))) + .findLast(event => event.type === 'response.completed')?.response; + } + expect(result.status).toBe('completed'); + expect(result.output).toContainEqual(expect.objectContaining({ type: 'function_call', arguments: '{"x":1,"y":2}', status: 'completed' })); +}); + +for (const [full, pieces] of [ + ['0.25', ['2', '.5e-1']], + ['{"x":1}', ['{"x":1,"', 'x":1}']], +] as const) test(`equivalent numeric or duplicate-member serialization is independent of fragmentation: ${full}`, async () => { + const events = await collect([added, + { type: 'response.function_call_arguments.done', item_id: 'a', arguments: full }, + ...pieces.map(value => delta(value)), completed()]); + expect(events.at(-1)?.type).toBe('done'); + expect(argumentDeltas(events)).toBe(full); +}); + +for (const [full, late] of [['{"x":[1]}', '{"x":[1,'], ['{"x":1}', '{"x":1.e']] as const) { + test(`impossible late prefix cannot reach executable closure: ${late}`, async () => { + const events = await collect([added, + { type: 'response.function_call_arguments.done', item_id: 'a', arguments: full }, delta(late), completed()]); + expect(events.at(-1)?.type).toBe('error'); + expect(events.some(value => value.type === 'tool_call_end' && !value.incomplete)).toBe(false); + }); +} + +test('signed zero is distinct in both complete comparison and completed replay', async () => { + for (const frames of [ + [added, delta('{"x":-0}'), ended('{"x":0}'), completed()], + [added, delta('{"x":-0}'), ended('{"x":-0}'), completed([item('{"x":0}')])], + [added, { type: 'response.function_call_arguments.done', item_id: 'a', arguments: '{"x":-0}' }, + { type: 'response.function_call_arguments.done', item_id: 'a', arguments: '{"x":0}' }, completed()], + ]) expect((await collect(frames)).at(-1)?.type).toBe('error'); +}); + +test('semantically equal response replay after item completion must not fail the turn',async()=>{ + const events = await collect([added,delta('{"x":1,"y":2}'),ended('{"x":1,"y":2}'),completed([item('{"y":2,"x":1}')])]); + expect(events.at(-1)?.type).toBe('done'); + expect(buildResponseJSON(events,'fixture').status).toBe('completed'); +}); + +test('parallel buffered function can accept an equivalent final JSON snapshot',async()=>{ + const b={...item(''),id:'b',call_id:'call-b'}; + const events=await collect([added,{type:'response.output_item.added',item:b}, + {type:'response.function_call_arguments.delta',item_id:'b',delta:'{"x":1,"y":2}'}, + {type:'response.output_item.done',item:{...b,arguments:'{"y":2,"x":1}'}}, + delta('{}'),ended('{}'),completed()]); + expect(events.at(-1)?.type).toBe('done'); + expect(events.filter(e=>e.type==='tool_call_end'&&!e.incomplete)).toHaveLength(2); +}); + +test('custom input must not extend beyond authoritative input.done before response completion',async()=>{ + const custom={type:'custom_tool_call',id:'a',call_id:'call',name:'shell',input:'',status:'in_progress'}; + const events=await collect([{type:'response.output_item.added',item:custom}, + {type:'response.custom_tool_call_input.delta',item_id:'a',delta:'echo'}, + {type:'response.custom_tool_call_input.done',item_id:'a',input:'echo safe'}, + {type:'response.custom_tool_call_input.delta',item_id:'a',delta:' safe; destructive'},completed()]); + expect(events.some(e=>e.type==='tool_call_end'&&!e.incomplete)).toBe(false); + expect(events.at(-1)?.type).toBe('error'); +}); + +test('custom full-input contradiction is detected even while another call blocks emission',async()=>{ + const custom={type:'custom_tool_call',id:'b',call_id:'call-b',name:'shell',input:'',status:'in_progress'}; + const events=await collect([added,{type:'response.output_item.added',item:custom}, + {type:'response.custom_tool_call_input.done',item_id:'b',input:'echo safe'}, + {type:'response.custom_tool_call_input.delta',item_id:'b',delta:'echo other'}, + delta('{}'),ended('{}'),completed()]); + expect(events.at(-1)?.type).toBe('error'); + expect(events.filter(e=>e.type==='tool_call_start'&&e.id==='call-b')).toHaveLength(1); + const at=events.findIndex(e=>e.type==='tool_call_start'&&e.id==='call-b'); + expect(events.slice(at).some(e=>e.type==='tool_call_end'&&!e.incomplete)).toBe(false); +}); + +test('success preserves every known counter and opaque success usage through both bridges',async()=>{ + const usage={input_tokens:11,output_tokens:7,total_tokens:18, + input_tokens_details:{cached_tokens:3,cache_write_tokens:4,cache_write_1h_tokens:2}, + output_tokens_details:{reasoning_tokens:5},vendor:{fixture:'safe'}}; + const events=await collect([{type:'response.completed',response:{output:[],usage}}]); + expect(events.at(-1)).toMatchObject({type:'done',usage:{inputTokens:11,outputTokens:7,totalTokens:18, + cachedInputTokens:3,cacheCreationInputTokens:4,cacheCreation1hInputTokens:2,reasoningOutputTokens:5,rawUsage:usage}}); + expect(buildResponseJSON(events,'fixture').usage).toMatchObject(usage); + const wire=await new Response(bridgeToResponsesSSE((async function*(){yield* events;})(),'fixture')).text(); + expect(wire).toContain('"cache_write_1h_tokens":2'); + expect(wire).toContain('"vendor":{"fixture":"safe"}'); +}); + +test('literal prefix function completion remains successful',async()=>{ + const events=await collect([added,delta('{"x":'),ended('{"x":1}'),completed()]); + expect(events.at(-1)?.type).toBe('done'); +}); + +test('custom whitespace differences remain genuine conflicts',async()=>{ + const custom={type:'custom_tool_call',id:'a',call_id:'call',name:'shell',input:'',status:'in_progress'}; + const events=await collect([{type:'response.output_item.added',item:custom}, + {type:'response.custom_tool_call_input.delta',item_id:'a',delta:'echo a'}, + {type:'response.output_item.done',item:{...custom,status:'completed',input:'echo a'}},completed()]); + expect(events.at(-1)?.type).toBe('error'); + expect(events.some(e=>e.type==='tool_call_end'&&!e.incomplete)).toBe(false); +}); + +for (const streaming of [false,true]) test(`wire projection refuses changed custom input after input.done (stream=${streaming})`,async()=>{ + const custom={type:'custom_tool_call',id:'a',call_id:'call',name:'fixture_custom',input:'',status:'in_progress'}; + const events=await collect([{type:'response.output_item.added',item:custom}, + {type:'response.custom_tool_call_input.delta',item_id:'a',delta:'echo'}, + {type:'response.custom_tool_call_input.done',item_id:'a',input:'echo safe'}, + {type:'response.custom_tool_call_input.delta',item_id:'a',delta:' safe; destructive'},completed()]); + const freeform=new Set(['fixture_custom']); + let result: any; + if(streaming){ + const text=await new Response(bridgeToResponsesSSE((async function*(){yield* events;})(),'fixture',undefined,freeform)).text(); + result=text.split('\n').filter(line=>line.startsWith('data: {')).map(line=>JSON.parse(line.slice(6))) + .findLast(event=>['response.completed','response.failed','response.incomplete'].includes(event.type))?.response; + }else result=buildResponseJSON(events,'fixture',{freeformToolNames:freeform}); + expect(result.output).toContainEqual(expect.objectContaining({type:'custom_tool_call',call_id:'call',status:'incomplete'})); + expect(result.status).toBe('failed'); +}); From b7f89fbc8fb839f88e2d8b6ff48939f169a6a1d6 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:37:31 +0900 Subject: [PATCH 13/13] fix(zed): compare ordered lossless JSON tokens incrementally Replace parsed value equality with an iterative JSON scanner shared by argument reconciliation, late-fragment validation and replay fingerprints. Keep number literals exact, decode string escapes, preserve object key order, and bound nesting at 65,536 containers with a clear failure above the limit. Retain authoritative tokens and advance only over newly received fragments; account for token and scanner storage and release it on every exit path. Preserve empty parameter-free calls, custom input byte equality, diagnostic usage filtering, queued failure output, and incomplete-call protections. Add numeric precision, deep JSON, linear-work, escape, grammar and retention regressions; document the conservative comparison contract. Co-authored-by: Andrew Wang <59988150+andrew05060414@users.noreply.github.com> --- .../src/content/docs/guides/providers.md | 2 +- scripts/test-layout/layout-adapters.json | 1 + src/adapters/zed-json-tokens.ts | 166 ++++++++++++++++++ src/adapters/zed-openai-stream.ts | 43 +++-- src/adapters/zed-tool-arguments.ts | 146 +++++++-------- structure/providers-and-adapters.md | 2 +- structure/transports/byte-accounting.md | 6 +- tests/fixtures/test-layout-expected.json | 1 + .../zed-argument-consistency.test.ts | 54 ++++-- .../providers/zed-lossless-arguments.test.ts | 158 +++++++++++++++++ 10 files changed, 460 insertions(+), 119 deletions(-) create mode 100644 src/adapters/zed-json-tokens.ts create mode 100644 tests/providers/zed-lossless-arguments.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 15db7c2e2b8..c9fde9a44e6 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -381,7 +381,7 @@ Current-turn tool declarations carried in Codex's `additional_tools` input are p supported tool catalog. Function calls and custom-tool input are relayed in order, including interleaved calls, so a tool-using turn can continue after its introductory text. Incomplete calls keep only received arguments and remain incomplete; cancellation releases buffered data. Conflicting -completed custom input, including later deltas, fails the turn with incomplete calls. Function arguments accept equivalent JSON values and compatible partial snapshots while preserving delivered bytes. On upstream failure, received text and +completed custom input, including later deltas, fails the turn with incomplete calls. Function arguments accept JSON whitespace differences outside strings, decoded string escapes, and compatible partial snapshots while preserving delivered bytes. Number literals must match exactly (`1` and `1.0` conflict); object-key order and duplicate members must match too. These conservative conflicts fail the turn. JSON nesting is limited to 65,536 containers; deeper input fails with a clear depth-limit message. Late fragments are validated incrementally. On upstream failure, received text and reasoning remain visible and usage retains numeric counters only. The relay is covered by synthetic stream fixtures; live hosted-stream compatibility remains unverified. For the canonical Kimi Coding Plan presets (`kimi` account login and `kimi-code` API key), diff --git a/scripts/test-layout/layout-adapters.json b/scripts/test-layout/layout-adapters.json index f6fddf39428..8b56d203765 100644 --- a/scripts/test-layout/layout-adapters.json +++ b/scripts/test-layout/layout-adapters.json @@ -2,6 +2,7 @@ "zed-openai-contract.test.ts": "providers", "zed-failure-relay.test.ts": "providers", "zed-argument-consistency.test.ts": "providers", + "zed-lossless-arguments.test.ts": "providers", "cursor-native-grep-globs.test.ts": "providers/cursor", "tool-identity-hash.test.ts": "adapters", "eventstream-fragmentation.test.ts": "responses", diff --git a/src/adapters/zed-json-tokens.ts b/src/adapters/zed-json-tokens.ts new file mode 100644 index 00000000000..2d8cc4d2ce8 --- /dev/null +++ b/src/adapters/zed-json-tokens.ts @@ -0,0 +1,166 @@ +/** Containers, including the root, are bounded independently of the JS call stack. */ +export const ZED_JSON_MAX_DEPTH = 65_536; + +export class ZedJsonError extends Error { + constructor(detail = 'invalid JSON') { super(`Zed tool arguments: ${detail}`); } +} + +// Disjoint alphabets preserve token boundaries, exact number spelling and decoded UTF-16. +// A surrogate pair has the same two units whether literal or escaped; lone units stay distinct. +const STRING = 0x10000; +const NUMBER = 0x20000; +const LITERAL = 0x30000; +const whitespace = (c: string) => c === ' ' || c === '\t' || c === '\r' || c === '\n'; +const digit = (c: string) => c >= '0' && c <= '9'; + +/** Single-pass, resumable JSON lexer + iterative grammar validator. No value conversion. */ +export class ZedJsonScanner { + steps = 0; + offset = 0; + private frames = [8]; // object: 0..4, array: 5..7, root: 8(value)/9(done) + private mode: 'plain' | 'string' | 'number' | 'literal' = 'plain'; + private number = ''; + private literal = ''; + private literalAt = 0; + private escape = false; + private unicodeLeft = 0; + private unicodeValue = 0; + + constructor(private emit: (token: number, end: number) => void) {} + + private value(): void { + const top = this.frames.length - 1; + const state = this.frames[top]; + if (state === 8) this.frames[top] = 9; + else if (state === 3) this.frames[top] = 4; + else if (state === 5 || state === 6) this.frames[top] = 7; + else throw new ZedJsonError(); + } + + private numberEnd(): void { + if (!['zero', 'int', 'frac', 'exp'].includes(this.number)) throw new ZedJsonError(); + this.mode = 'plain'; + } + + push(fragment: string): void { + for (let i = 0; i < fragment.length; i++) { + this.steps++; + const c = fragment[i]!; + const code = fragment.charCodeAt(i); + this.offset++; + if (this.mode === 'string') { + if (this.unicodeLeft) { + const hex = code >= 48 && code <= 57 ? code - 48 + : code >= 65 && code <= 70 ? code - 55 : code >= 97 && code <= 102 ? code - 87 : -1; + if (hex < 0) throw new ZedJsonError(); + this.unicodeValue = this.unicodeValue * 16 + hex; + if (--this.unicodeLeft === 0) this.emit(STRING + this.unicodeValue, this.offset); + } else if (this.escape) { + this.escape = false; + if (c === 'u') { this.unicodeLeft = 4; this.unicodeValue = 0; } + else { + const decoded = '"\\/bfnrt'.indexOf(c); + if (decoded < 0) throw new ZedJsonError(); + this.emit(STRING + [34, 92, 47, 8, 12, 10, 13, 9][decoded]!, this.offset); + } + } else if (c === '\\') this.escape = true; + else if (c === '"') { this.mode = 'plain'; this.emit(34, this.offset); } + else { + if (code < 32) throw new ZedJsonError(); + this.emit(STRING + code, this.offset); + } + continue; + } + if (this.mode === 'literal') { + if (c !== this.literal[this.literalAt++]) throw new ZedJsonError(); + this.emit(LITERAL + code, this.offset); + if (this.literalAt === this.literal.length) this.mode = 'plain'; + continue; + } + if (this.mode === 'number') { + let next = ''; + if (this.number === 'minus') next = c === '0' ? 'zero' : c >= '1' && c <= '9' ? 'int' : ''; + else if (this.number === 'zero' || this.number === 'int') { + next = this.number === 'int' && digit(c) ? 'int' : c === '.' ? 'dot' : /[eE]/.test(c) ? 'e' : ''; + } else if (this.number === 'dot' || this.number === 'frac') { + next = digit(c) ? 'frac' : this.number === 'frac' && /[eE]/.test(c) ? 'e' : ''; + } else if (this.number === 'e') next = c === '+' || c === '-' ? 'sign' : digit(c) ? 'exp' : ''; + else if (this.number === 'sign' || this.number === 'exp') next = digit(c) ? 'exp' : ''; + if (next) { this.number = next; this.emit(NUMBER + code, this.offset); continue; } + this.numberEnd(); + // The delimiter belongs to the grammar, not to the numeric token. + } + if (whitespace(c)) continue; + const top = this.frames.length - 1; + const state = this.frames[top]; + if (c === '}' || c === ']') { + if (c === '}' ? state !== 0 && state !== 4 : state !== 5 && state !== 7) throw new ZedJsonError(); + this.frames.pop(); + } else if (c === ':') { + if (state !== 2) throw new ZedJsonError(); + this.frames[top] = 3; + } else if (c === ',') { + if (state !== 4 && state !== 7) throw new ZedJsonError(); + this.frames[top] = state === 4 ? 1 : 6; + } else if (c === '"') { + if (state === 0 || state === 1) this.frames[top] = 2; + else this.value(); + this.mode = 'string'; + } else { + this.value(); + if (c === '{' || c === '[') { + if (this.frames.length > ZED_JSON_MAX_DEPTH) { + throw new ZedJsonError(`JSON nesting depth limit exceeded (${ZED_JSON_MAX_DEPTH})`); + } + this.frames.push(c === '{' ? 0 : 5); + } else if (c === '-' || digit(c)) { + this.mode = 'number'; + this.number = c === '-' ? 'minus' : c === '0' ? 'zero' : 'int'; + this.emit(NUMBER + code, this.offset); + continue; + } else if (c === 't' || c === 'f' || c === 'n') { + this.mode = 'literal'; + this.literal = c === 't' ? 'true' : c === 'f' ? 'false' : 'null'; + this.literalAt = 1; + this.emit(LITERAL + code, this.offset); + continue; + } else throw new ZedJsonError(); + } + this.emit(code, this.offset); + } + } + + finish(): void { + if (this.mode === 'number') this.numberEnd(); + if (this.mode !== 'plain' || this.frames.length !== 1 || this.frames[0] !== 9) throw new ZedJsonError('incomplete JSON'); + } + + get complete(): boolean { + return this.frames.length === 1 && this.frames[0] === 9 + && (this.mode === 'plain' || (this.mode === 'number' && ['zero', 'int', 'frac', 'exp'].includes(this.number))); + } + + /** Complete a split escape using the next authoritative decoded string unit. */ + escapeSuffix(token: number | undefined): string | undefined { + if (!this.escape && !this.unicodeLeft) return ''; + if (token === undefined || token < STRING || token >= NUMBER) return; + const hex = (token - STRING).toString(16).padStart(4, '0'); + if (this.escape) return 'u' + hex; + const read = 4 - this.unicodeLeft; + if (parseInt(hex.slice(0, read) || '0', 16) !== this.unicodeValue) return; + return hex.slice(read); + } +} + +export function zedJsonTokens(text: string, positions?: Uint32Array): Uint32Array { + const tokens = new Uint32Array(text.length); + let length = 0; + const scanner = new ZedJsonScanner((token, end) => { + tokens[length] = token; + if (positions) positions[length] = end; + length++; + }); + scanner.push(text); + scanner.finish(); + return tokens.subarray(0, length); +} diff --git a/src/adapters/zed-openai-stream.ts b/src/adapters/zed-openai-stream.ts index a93f83ca72e..d8d682f1f64 100644 --- a/src/adapters/zed-openai-stream.ts +++ b/src/adapters/zed-openai-stream.ts @@ -6,7 +6,8 @@ import { truncationReasonFor } from "../responses/truncated-stop-reason"; import { unnamedToolCallEvent } from "./openai-chat/response-events"; import { usageFromResponsesPayload } from "./openai-responses/usage"; import { zedDiagnosticUsage } from "./zed-diagnostic-usage"; -import { consistentZedArgumentDelta, reconcileZedArguments, zedArgumentFingerprint } from "./zed-tool-arguments"; +import { reconcileZedArguments, zedArgumentFingerprint, ZedArgumentAuthority, zedArgumentAuthorityBytes } from "./zed-tool-arguments"; +import { ZedJsonError } from "./zed-json-tokens"; import { randomUUID } from "node:crypto"; function isRecord(value: unknown): value is Record { @@ -26,6 +27,8 @@ interface CallState { args: string; pendingFull?: string; authoritative: boolean; + authority?: ZedArgumentAuthority; + authorityBytes?: number; argsBytes: number; fullBytes: number; lastCodeUnit: number; @@ -150,7 +153,9 @@ export async function* translateZedOpenAiStream( } function argumentWorkspace(texts: string[], work: () => T): T { - const reservation = budget.reserveTransient(texts.reduce((size, text) => size + Buffer.byteLength(text, "utf8"), 0), + // Source copies, canonical tokens, reconciliation positions and iterative grammar stacks. + const reservation = budget.reserveTransient(texts.reduce((size, text) => size + Buffer.byteLength(text, "utf8"), 0) + + 12 * Math.max(0, ...texts.map(text => text.length)), { kind: "retained_collectors" }); try { return work(); } finally { reservation.release(); } } @@ -193,8 +198,10 @@ export async function* translateZedOpenAiStream( yield { type: "tool_call_delta", arguments: delta }; } if (!next.closed) return; + // Failed/incomplete input is never executable or eligible for successful replay dedupe. + // In particular, do not scan over-limit JSON again while flushing its failure. + next.finalHash = next.allowFull && !next.incomplete ? fingerprint(next, text) : undefined; next.ended = true; - next.finalHash = fingerprint(next, text); if (next.name) yield { type: "tool_call_end", ...(!next.allowFull || next.incomplete ? { incomplete: true } : {}) }; releaseCall(next); for (const entry of next.after) { @@ -214,10 +221,7 @@ export async function* translateZedOpenAiStream( try { const next = state.args + fragment; if (state.authoritative) { - const full = state.pendingFull ?? state.args; - const consistent = argumentWorkspace([next, full], () => - consistentZedArgumentDelta(next, full, state.kind === "custom_tool_call")); - if (!consistent) throw new ZedToolArgumentsError(); + state.authority!.push(fragment); // The original string is still the delivery/authority owner while the late stream // catches up using an equivalent serialization. Transfer its existing lease. if (state.pendingFull === undefined) { @@ -246,16 +250,26 @@ export async function* translateZedOpenAiStream( // Completed input remains authoritative even when another call blocks all emission. if (authoritative) { const snapshot = full; - full = argumentWorkspace([state.args, snapshot], () => { + const confirmed = state.pendingFull ?? state.args; + if (state.authoritative) { + if (fingerprint(state, confirmed) !== fingerprint(state, snapshot)) throw new ZedToolArgumentsError(); + full = confirmed; + } else full = argumentWorkspace([state.args, snapshot], () => { const custom = state.kind === "custom_tool_call"; - const confirmed = state.pendingFull ?? state.args; - if (state.authoritative && fingerprint(state, confirmed) !== fingerprint(state, snapshot)) return; - if (state.authoritative) return confirmed; const received = reconcileZedArguments(state.args, snapshot, custom); if (received === undefined) return; return reconcileZedArguments(argumentText(state).slice(0, state.emitted), received, custom); }); if (typeof full !== "string") throw new ZedToolArgumentsError(); + if (!state.authoritative) { + const custom = state.kind === "custom_tool_call"; + const bytes = zedArgumentAuthorityBytes(full, custom); + const reservation = budget.reserveTransient(bytes, { kind: "retained_collectors" }); + try { state.authority = new ZedArgumentAuthority(full, custom, state.args); } + catch (error) { reservation.release(); throw error; } + reservation.commitRetained(); + state.authorityBytes = bytes; + } state.authoritative = true; } if (full === state.pendingFull) return; @@ -280,6 +294,9 @@ export async function* translateZedOpenAiStream( */ function releaseCall(state: CallState): void { budget.closeCall(state.budgetKey); + budget.releaseRetained(state.authorityBytes ?? 0, { kind: "retained_collectors" }); + state.authority = undefined; + state.authorityBytes = 0; state.args = ""; state.argsBytes = 0; state.pendingFull = undefined; @@ -293,7 +310,7 @@ export async function* translateZedOpenAiStream( * reaches the flush path, so the buffers must not outlive the request. */ function releaseStreamBuffers(): void { - for (const state of calls.values()) budget.closeCall(state.budgetKey); + for (const state of calls.values()) releaseCall(state); calls.clear(); aliases.clear(); callIds.clear(); @@ -695,7 +712,7 @@ export async function* translateZedOpenAiStream( ciphertextTransferred = true; yield done; } catch (error) { - if (!(error instanceof ZedToolIdentityError) && !(error instanceof ZedToolArgumentsError)) throw error; + if (!(error instanceof ZedToolIdentityError) && !(error instanceof ZedToolArgumentsError) && !(error instanceof ZedJsonError)) throw error; retainUsage(undefined, true); yield* flushUnclosed(false, true); yield { type: "error", message: error.message, ...(usage ? { usage } : {}) }; diff --git a/src/adapters/zed-tool-arguments.ts b/src/adapters/zed-tool-arguments.ts index de358734c87..c1b3b03a386 100644 --- a/src/adapters/zed-tool-arguments.ts +++ b/src/adapters/zed-tool-arguments.ts @@ -1,103 +1,79 @@ import { createHash } from "node:crypto"; +import { ZedJsonError, ZedJsonScanner, zedJsonTokens } from "./zed-json-tokens"; -/** Object order is immaterial; array order and string contents remain significant. */ +/** Ordered lexical tokens are shared by equality, incremental validation and replay hashes. */ export function zedArgumentFingerprint(text: string, custom: boolean): string { const hash = createHash("sha256"); - if (custom) return hash.update("custom:").update(text).digest("hex"); - let value: unknown; - try { value = JSON.parse(text); } catch { return hash.update("raw:").update(text).digest("hex"); } - hash.update("json:"); - const stack: unknown[] = [value]; - while (stack.length) { - const next = stack.pop(); - if (next !== null && typeof next === "object") { - if (Array.isArray(next)) { - hash.update(`array:${next.length}:`); - for (let i = next.length - 1; i >= 0; i--) stack.push(next[i]); - } else { - const record = next as Record; - const keys = Object.keys(record).sort(); - hash.update(`object:${keys.length}:`); - for (let i = keys.length - 1; i >= 0; i--) stack.push(record[keys[i]!], keys[i]!); - } - } else { - // JSON.parse can produce +/-Infinity from a valid exponent such as 1e400. - // JSON.stringify would collapse both numbers and null to the same token. - const scalar = typeof next === "number" ? Object.is(next, -0) ? "-0" : String(next) : JSON.stringify(next); - hash.update(`scalar:${typeof next}:${scalar.length}:${scalar}`); - } + // UTF-16 hashing also distinguishes lone surrogates, which UTF-8 replacement would collapse. + if (custom) return hash.update("custom:").update(text, "utf16le").digest("hex"); + let tokens: Uint32Array; + try { tokens = zedJsonTokens(text); } + catch (error) { + if (error instanceof ZedJsonError && error.message.includes('depth limit')) throw error; + return hash.update("raw:").update(text, "utf16le").digest("hex"); } - return hash.digest("hex"); + return hash.update("json:").update(Buffer.from(tokens.buffer, tokens.byteOffset, tokens.byteLength)).digest("hex"); } -function equalJson(left: string, right: string): boolean { - try { return Bun.deepEquals(JSON.parse(left), JSON.parse(right)); } catch { return false; } -} +/** Retained authoritative token stream; each late source unit is scanned exactly once. */ +export class ZedArgumentAuthority { + private tokens?: Uint32Array; + private scanner?: ZedJsonScanner; + private at = 0; + get steps(): number { return this.scanner?.steps ?? this.at; } -function unfinishedJsonValue(text: string): boolean { - const value = text.trimStart(); - if (!value) return true; - if (value[0] !== "{" && value[0] !== "[" && value[0] !== '"') { - return ["true", "false", "null"].some(literal => literal.startsWith(value)) - || /^-?(?:(?:0|[1-9]\d*)(?:\.\d*)?(?:[eE][+-]?\d*)?)?$/.test(value); + constructor(private full: string, private custom: boolean, prefix = '') { + // The existing protocol uses an empty field for a parameter-free call (not JSON null). + if (!custom && full !== '') { + this.tokens = zedJsonTokens(full); + this.scanner = new ZedJsonScanner(token => { + if (token !== this.tokens![this.at++]) throw new ZedJsonError('conflicting arguments'); + }); + } + this.push(prefix); } - let depth = 0; - let quoted = false; - let escaped = false; - for (let i = 0; i < value.length; i++) { - const char = value[i]; - if (escaped) { escaped = false; continue; } - if (quoted && char === "\\") { escaped = true; continue; } - if (char === '"') { - quoted = !quoted; - if (!quoted && value[0] === '"') return false; - } else if (!quoted) { - if (char === "{" || char === "[") depth++; - if (char === "}" || char === "]") { - if (--depth <= 0) return false; - } + + push(fragment: string): void { + if (this.scanner) this.scanner.push(fragment); + else { + if (!this.full.startsWith(fragment, this.at)) throw new ZedJsonError('conflicting custom input'); + this.at += fragment.length; } } - return true; } -/** A number token or unfinished value may still extend; closure validates the final prefix. */ -export function consistentZedArgumentDelta(prefix: string, full: string, custom: boolean): boolean { - if (custom) return full.startsWith(prefix); - try { - const value: unknown = JSON.parse(prefix); - return typeof value === "number" || Bun.deepEquals(value, JSON.parse(full)); - } catch { return unfinishedJsonValue(prefix); } +/** Retained bytes cover the token buffer and the bounded incremental grammar stack. */ +export function zedArgumentAuthorityBytes(text: string, custom: boolean): number { + return custom ? 0 : text.length * 4 + Math.min(text.length, 65_536) * 8 + 64; } -/** Locate a prefix modulo insignificant JSON whitespace, never whitespace inside strings. */ -function prefixEnd(prefix: string, full: string): number | undefined { - let p = 0; - let f = 0; - let quoted = false; - let escaped = false; - while (p < prefix.length) { - if (!quoted) { - while (p < prefix.length && /[\x20\t\r\n]/.test(prefix[p]!)) p++; - while (f < full.length && /[\x20\t\r\n]/.test(full[f]!)) f++; - if (p === prefix.length) break; +/** Keep delivered bytes; append only a suffix of the same ordered lexical JSON value. */ +export function reconcileZedArguments(prefix: string, full: string, custom: boolean): string | undefined { + if (custom) return full.startsWith(prefix) ? full : undefined; + if (full === '') return prefix === '' ? '' : undefined; + const positions = new Uint32Array(full.length); + const tokens = zedJsonTokens(full, positions); + let at = 0; + let end = 0; + const scanner = new ZedJsonScanner((token) => { + if (token !== tokens[at]) throw new ZedJsonError('conflicting arguments'); + end = positions[at++]!; + }); + try { + scanner.push(prefix); + if (scanner.complete) { + scanner.finish(); + return at === tokens.length ? prefix : undefined; } - const char = prefix[p++]; - if (char !== full[f++]) return; - if (escaped) escaped = false; - else if (quoted && char === "\\") escaped = true; - else if (char === '"') quoted = !quoted; + const escape = scanner.escapeSuffix(tokens[at]); + if (escape === undefined) return; + if (escape) end = positions[at]!; + const suffix = escape + full.slice(end); + scanner.push(suffix); + scanner.finish(); + return at === tokens.length ? prefix + suffix : undefined; + } catch (error) { + if (error instanceof ZedJsonError && !error.message.includes('depth limit')) return; + throw error; } - return f; -} - -/** Keep every delivered byte and append only a suffix that still represents the full value. */ -export function reconcileZedArguments(prefix: string, full: string, custom: boolean): string | undefined { - if (full.startsWith(prefix)) return full; - if (custom) return; - if (equalJson(prefix, full)) return prefix; - const end = prefixEnd(prefix, full); - if (end === undefined) return; - const rebuilt = prefix + full.slice(end); - return equalJson(rebuilt, full) ? rebuilt : undefined; } diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 1d24b96e319..715c5f30665 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -208,7 +208,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge; optional `minMaxOutputTokens` raises only smaller explicit positive integer output budgets. Unset preserves caller caps. Serialization and pre-admission spend estimates share `effectiveAzureOutputBudget`; see the [spend contract](transports/responses-spend.md#durable-spend-reservations). | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | -| `src/adapters/zed.ts`, `src/providers/zed.ts`, `src/oauth/zed.ts` | Zed Hosted AI native-app RSA login, account-scoped short-lived LLM-token exchange, bounded live model discovery, and provider-envelope translation through the existing Anthropic, Google, Responses, and Chat builders. Buffered delivery retains events under the shared translator budget and cancels delegated streams on early exit. The Responses delegate applies destination compatibility, strips OpenAI-private fields and unsupported built-in tools, reconciles removed tool choices through the shared hosted-tool policy, and maps OpenAI `developer` messages to Zed-supported `system` messages before wrapping the request. `src/adapters/zed-openai-stream.ts` translates the OpenAI stream: the current turn's `additional_tools` are promoted on a detached copy before replay repair, interleaved function and custom calls stay buffered until the oldest open call's authoritative item end or successful response terminal because adapter deltas are unkeyed, and end of stream or an incomplete response flushes only arguments that already arrived, marking unfinished calls incomplete. Text and reasoning wait behind an open call; invisible heartbeats report buffered progress. Item aliases share one execution identity and conflicting reuse fails closed. Missing tool names fail the turn, and no output follows a terminal error. Per-item reasoning and done-only text fallback suppress completed replays. A completed compaction item keeps its ciphertext on the done event and transfers that retained lease. Arguments, full snapshots, dedupe identities, queued events, text and opaque usage are budgeted and released on failure or cancellation; typed and untyped native error envelopes scrub every diagnostic string, including code/type, with the captured credential scrubber and discard diagnostic metadata before delegation. Untyped error envelopes terminate the OpenAI relay. Argument-done frames confirm bytes only; item status or successful response completion authorizes executable closure. Authoritative custom snapshots and every later delta agree byte-for-byte regardless of emission order; extensions or contradictions close outstanding calls incomplete and fail the turn. Function arguments use JSON-value equality for complete snapshots and replay fingerprints, ignoring object-key order; compatible whitespace-adjusted prefixes append only a validated suffix and preserve delivered bytes. `src/adapters/zed-tool-arguments.ts` owns semantic reconciliation and domain-separated replay fingerprints. Extensible late function fragments stay buffered under authority and are reconciled again before executable closure, including calls held behind another call; `tests/providers/zed-argument-consistency.test.ts` covers late, buffered and JSON/SSE cases. Failed terminals close outstanding calls incomplete and replay queued text/reasoning before the original error. Diagnostic usage retains only finite nonnegative numeric token counters through `src/adapters/zed-diagnostic-usage.ts`, including nested cache/reasoning counters; opaque extensions are discarded. `tests/providers/zed-failure-relay.test.ts` covers JSON/SSE failure projection and completion conflicts. `src/adapters/zed-additional-tools.ts` owns tool promotion, while `src/adapters/openai-responses/usage.ts` shares usage projection with Responses compaction. `tests/providers/zed-openai-contract.test.ts` covers the relay and client-boundary regressions. Experimental and unofficial; the Zed terms risk is intentionally surfaced in user docs and release review. | +| `src/adapters/zed.ts`, `src/providers/zed.ts`, `src/oauth/zed.ts` | Zed Hosted AI native-app RSA login, account-scoped short-lived LLM-token exchange, bounded live model discovery, and provider-envelope translation through the existing Anthropic, Google, Responses, and Chat builders. Buffered delivery retains events under the shared translator budget and cancels delegated streams on early exit. The Responses delegate applies destination compatibility, strips OpenAI-private fields and unsupported built-in tools, reconciles removed tool choices through the shared hosted-tool policy, and maps OpenAI `developer` messages to Zed-supported `system` messages before wrapping the request. `src/adapters/zed-openai-stream.ts` translates the OpenAI stream: the current turn's `additional_tools` are promoted on a detached copy before replay repair, interleaved function and custom calls stay buffered until the oldest open call's authoritative item end or successful response terminal because adapter deltas are unkeyed, and end of stream or an incomplete response flushes only arguments that already arrived, marking unfinished calls incomplete. Text and reasoning wait behind an open call; invisible heartbeats report buffered progress. Item aliases share one execution identity and conflicting reuse fails closed. Missing tool names fail the turn, and no output follows a terminal error. Per-item reasoning and done-only text fallback suppress completed replays. A completed compaction item keeps its ciphertext on the done event and transfers that retained lease. Arguments, full snapshots, dedupe identities, queued events, text and opaque usage are budgeted and released on failure or cancellation; typed and untyped native error envelopes scrub every diagnostic string, including code/type, with the captured credential scrubber and discard diagnostic metadata before delegation. Untyped error envelopes terminate the OpenAI relay. Argument-done frames confirm bytes only; item status or successful response completion authorizes executable closure. Authoritative custom snapshots and every later delta agree byte-for-byte regardless of emission order; extensions or contradictions close outstanding calls incomplete and fail the turn. Function arguments compare ordered lexical JSON tokens for complete snapshots and replay fingerprints: whitespace outside strings is ignored, strings compare decoded Unicode units, number literals remain exact, and object-key order (including duplicate members) stays significant; compatible whitespace-adjusted prefixes append only a validated suffix and preserve delivered bytes. `src/adapters/zed-tool-arguments.ts` owns lexical reconciliation and domain-separated replay fingerprints. `src/adapters/zed-json-tokens.ts` scans JSON iteratively with a 65,536-container nesting limit, reporting a clear limit failure above it. Late fragments scan only new source units against retained authoritative tokens, including calls held behind another call; `tests/providers/zed-argument-consistency.test.ts` covers late, buffered and JSON/SSE cases. Failed terminals close outstanding calls incomplete and replay queued text/reasoning before the original error. Diagnostic usage retains only finite nonnegative numeric token counters through `src/adapters/zed-diagnostic-usage.ts`, including nested cache/reasoning counters; opaque extensions are discarded. `tests/providers/zed-failure-relay.test.ts` covers JSON/SSE failure projection and completion conflicts. `src/adapters/zed-additional-tools.ts` owns tool promotion, while `src/adapters/openai-responses/usage.ts` shares usage projection with Responses compaction. `tests/providers/zed-openai-contract.test.ts` covers the relay and client-boundary regressions. Experimental and unofficial; the Zed terms risk is intentionally surfaced in user docs and release review. | | `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort anchors on the family's default member and selects the nearest enabled rung, rounding up first; an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays expressed as type-only `anyOf` branches in tool parameters. Shared constraints remain on the surrounding node, so nested schemas have one wire representation while outer enum/const, boolean constraints (including `not`, `oneOf`, and `allOf`) on null, and existing null-branch restrictions retain their conjunctive meaning. A null-only type keeps its null constraint even when an outer enum/const excludes null, preserving an unsatisfiable schema without producing an empty anyOf. Schema resource identifiers, anchors, definition maps, and existing anyOf branches remain on their original node. The type constraint is appended to existing allOf entries without shifting their indices, so JSON Pointer targets and reference scopes remain unchanged. A pre-output `invalid_argument` on a history whose word-piece estimate, using the sanitized and truncated tool descriptions actually sent on the wire, reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. The `COGNITION_BLOCKLIST_REWRITES` sanitizer rewrites exact phrases Cognition refuses with `permission_denied`; it runs only on instruction surfaces — tool descriptions and the #2 system prompt — while message text, replayed thinking (#11), and tool-call arguments stay byte-exact, because a rewrite there would silently change literal content such as patches or quoted file bytes; a trigger inside a data field surfaces as the upstream `permission_denied` instead (the Codex `` escalation boilerplate is the recorded #2 case). | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. Its registry entry is `deprecated`: existing saved providers keep the adapter and their stored configuration, while new-provider discovery (presets, the init menu, the featured list, and key logins) excludes it and `provider add` warns before creating one. | diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 1d6da54a7df..9cd9c95e295 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -375,8 +375,10 @@ Explicitly selected canonical Codex Lite ordinary unreferenced history notes reu The Zed OpenAI relay uses private per-call budget keys so client encoders cannot close its leases. Each argument or full snapshot replacement reserves its complete new logical size before releasing the old physical bytes; two retained copies spend the turn cap without doubling a call's logical limit. -JSON reconciliation and semantic replay hashing reserve transient source bytes; rebuilt suffixes -are admitted before retention. Late deltas retain the authoritative delivery string and its lease. +Lexical JSON reconciliation and replay hashing reserve source, token, position and grammar-stack +workspace; rebuilt suffixes are admitted before retention. Authoritative tokens and the bounded +late scanner stack spend the turn budget until call release. Each late source unit is scanned once; +UTF-8 accounting measures only the new fragment. The delivery string keeps its existing lease. Dedupe identities include an entry allowance, so even empty items remain bounded. Queued semantic events and opaque usage are charged until emitted or replaced; cancellation, read failure and identity/argument refusal release every relay-owned buffer. Failed terminals close outstanding calls diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index acb8fb93197..ff0f2918fd3 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -3,6 +3,7 @@ "zed-openai-contract.test.ts": "providers", "zed-failure-relay.test.ts": "providers", "zed-argument-consistency.test.ts": "providers", + "zed-lossless-arguments.test.ts": "providers", "server-combo-required-tool-contract.test.ts": "server", "responses-anthropic-compaction-prefix.test.ts": "responses", "service-proxy-env.test.ts": "service", diff --git a/tests/providers/zed-argument-consistency.test.ts b/tests/providers/zed-argument-consistency.test.ts index dad193f3854..90930b13caa 100644 --- a/tests/providers/zed-argument-consistency.test.ts +++ b/tests/providers/zed-argument-consistency.test.ts @@ -21,8 +21,13 @@ for (const [label,streamed,final] of [ ['key order','{"x":1,"y":2}','{"y":2,"x":1}'], ['nested order','{"x":{"a":1,"b":2}}','{"x":{"b":2,"a":1}}'], ['partial with whitespace','{"x":','{ "x": 1 }'], -] as const) test(`equivalent function JSON must succeed: ${label}`,async()=>{ +] as const) test(`function lexical snapshot consistency: ${label}`,async()=>{ const events = await collect([added,delta(streamed),ended(final),completed()]); + if (label.includes('order')) { + expect(events.at(-1)?.type).toBe('error'); + expect(events.some(e => e.type === 'tool_call_end' && !e.incomplete)).toBe(false); + return; + } expect(events.at(-1)?.type).toBe('done'); expect(events).toContainEqual({type:'tool_call_end'}); expect(buildResponseJSON(events,'fixture').status).toBe('completed'); @@ -40,6 +45,11 @@ for (const [label, prefix, final] of [ ] as const) { for (const streaming of [false, true]) test(`function wire preserves delivered bytes: ${label}, stream=${streaming}`, async () => { const events = await collect([added, delta(prefix), ended(final), completed()]); + if (label === 'nested key order' || label === 'array values') { + expect(events.at(-1)?.type).toBe('error'); + expect(buildResponseJSON(events, 'fixture').status).toBe('failed'); + return; + } const delivered = argumentDeltas(events); expect(delivered.startsWith(prefix)).toBe(true); expect(JSON.parse(delivered)).toEqual(JSON.parse(final)); @@ -62,6 +72,11 @@ for (const [first, full, late] of [ const events = await collect([added, delta(first), { type: 'response.function_call_arguments.done', item_id: 'a', arguments: full }, delta(late), ended(full), completed()]); + if (first === '') { + expect(events.at(-1)?.type).toBe('error'); + expect(events.some(e => e.type === 'tool_call_end' && !e.incomplete)).toBe(false); + return; + } expect(events.at(-1)?.type).toBe('done'); const delivered = argumentDeltas(events); expect(JSON.parse(delivered)).toEqual(JSON.parse(full)); @@ -127,7 +142,7 @@ test('semantic replay fingerprint distinguishes overflowing JSON numbers from nu } }); -for (const parallel of [false, true]) test(`late reordered function JSON accepts split token boundaries (parallel=${parallel})`, async () => { +for (const parallel of [false, true]) test(`late reordered function JSON conflicts across split token boundaries (parallel=${parallel})`, async () => { const target = { ...item(''), id: 'b', call_id: 'call-b' }; const events = await collect([ ...(parallel ? [added] : []), { type: 'response.output_item.added', item: target }, @@ -135,8 +150,8 @@ for (const parallel of [false, true]) test(`late reordered function JSON accepts ...['{"y":2,', '"x":1}'].map(value => ({ type: 'response.function_call_arguments.delta', item_id: 'b', delta: value })), ...(parallel ? [delta('{}'), ended('{}')] : []), completed(), ]); - expect(events.at(-1)?.type).toBe('done'); - expect(events.filter(value => value.type === 'tool_call_end' && !value.incomplete)).toHaveLength(parallel ? 2 : 1); + expect(events.at(-1)?.type).toBe('error'); + expect(events.some(value => value.type === 'tool_call_end' && !value.incomplete)).toBe(false); }); test('malformed function replay cannot impersonate the canonical fingerprint input', async () => { @@ -157,6 +172,11 @@ for (const [full, late] of [ { type: 'response.function_call_arguments.done', item_id: 'b', arguments: full }, ...late.split('').map(value => ({ type: 'response.function_call_arguments.delta', item_id: 'b', delta: value })), ...(parallel ? [delta('{}'), ended('{}')] : []), completed()]); + if (full.includes('"y"')) { + expect(events.at(-1)?.type).toBe('error'); + expect(events.some(value => value.type === 'tool_call_end' && !value.incomplete)).toBe(false); + return; + } expect(events.at(-1)?.type).toBe('done'); expect(events.filter(value => value.type === 'tool_call_end' && !value.incomplete)).toHaveLength(parallel ? 2 : 1); expect(buildResponseJSON(events, 'fixture').output).toContainEqual(expect.objectContaining({ call_id: 'call-b', arguments: full, status: 'completed' })); @@ -178,7 +198,7 @@ test('raw function arguments cannot impersonate a later parsed JSON replay', asy expect(events.at(-1)?.type).toBe('error'); }); -for (const streaming of [false, true]) test(`split late JSON preserves successful client projection (stream=${streaming})`, async () => { +for (const streaming of [false, true]) test(`split reordered late JSON fails client projection (stream=${streaming})`, async () => { const events = await collect([added, { type: 'response.function_call_arguments.done', item_id: 'a', arguments: '{"x":1,"y":2}' }, delta('{"y":2,'), delta('"x":1}'), ended('{"x":1,"y":2}'), completed()]); @@ -186,21 +206,21 @@ for (const streaming of [false, true]) test(`split late JSON preserves successfu if (streaming) { const wire = await new Response(bridgeToResponsesSSE((async function* () { yield* events; })(), 'fixture')).text(); result = wire.split('\n').filter(line => line.startsWith('data: {')).map(line => JSON.parse(line.slice(6))) - .findLast(event => event.type === 'response.completed')?.response; + .findLast(event => event.type === 'response.failed')?.response; } - expect(result.status).toBe('completed'); - expect(result.output).toContainEqual(expect.objectContaining({ type: 'function_call', arguments: '{"x":1,"y":2}', status: 'completed' })); + expect(result.status).toBe('failed'); + expect(result.output).toContainEqual(expect.objectContaining({ type: 'function_call', status: 'incomplete' })); }); for (const [full, pieces] of [ ['0.25', ['2', '.5e-1']], ['{"x":1}', ['{"x":1,"', 'x":1}']], -] as const) test(`equivalent numeric or duplicate-member serialization is independent of fragmentation: ${full}`, async () => { +] as const) test(`changed numeric or duplicate-member serialization conflicts across fragments: ${full}`, async () => { const events = await collect([added, { type: 'response.function_call_arguments.done', item_id: 'a', arguments: full }, ...pieces.map(value => delta(value)), completed()]); - expect(events.at(-1)?.type).toBe('done'); - expect(argumentDeltas(events)).toBe(full); + expect(events.at(-1)?.type).toBe('error'); + expect(events.some(e => e.type === 'tool_call_end' && !e.incomplete)).toBe(false); }); for (const [full, late] of [['{"x":[1]}', '{"x":[1,'], ['{"x":1}', '{"x":1.e']] as const) { @@ -221,20 +241,20 @@ test('signed zero is distinct in both complete comparison and completed replay', ]) expect((await collect(frames)).at(-1)?.type).toBe('error'); }); -test('semantically equal response replay after item completion must not fail the turn',async()=>{ +test('reordered response replay after item completion fails the turn',async()=>{ const events = await collect([added,delta('{"x":1,"y":2}'),ended('{"x":1,"y":2}'),completed([item('{"y":2,"x":1}')])]); - expect(events.at(-1)?.type).toBe('done'); - expect(buildResponseJSON(events,'fixture').status).toBe('completed'); + expect(events.at(-1)?.type).toBe('error'); + expect(buildResponseJSON(events,'fixture').status).toBe('failed'); }); -test('parallel buffered function can accept an equivalent final JSON snapshot',async()=>{ +test('parallel buffered function rejects a reordered final JSON snapshot',async()=>{ const b={...item(''),id:'b',call_id:'call-b'}; const events=await collect([added,{type:'response.output_item.added',item:b}, {type:'response.function_call_arguments.delta',item_id:'b',delta:'{"x":1,"y":2}'}, {type:'response.output_item.done',item:{...b,arguments:'{"y":2,"x":1}'}}, delta('{}'),ended('{}'),completed()]); - expect(events.at(-1)?.type).toBe('done'); - expect(events.filter(e=>e.type==='tool_call_end'&&!e.incomplete)).toHaveLength(2); + expect(events.at(-1)?.type).toBe('error'); + expect(events.some(e=>e.type==='tool_call_end'&&!e.incomplete)).toBe(false); }); test('custom input must not extend beyond authoritative input.done before response completion',async()=>{ diff --git a/tests/providers/zed-lossless-arguments.test.ts b/tests/providers/zed-lossless-arguments.test.ts new file mode 100644 index 00000000000..75a830f7e5c --- /dev/null +++ b/tests/providers/zed-lossless-arguments.test.ts @@ -0,0 +1,158 @@ +import { expect, test } from 'bun:test'; +import { translateZedOpenAiStream } from '../../src/adapters/zed-openai-stream'; +import { zedEventStream } from '../../src/adapters/zed'; +import { createTranslatorBudget } from '../../src/lib/translator-budget'; +import { bridgeToResponsesSSE, buildResponseJSON } from '../../src/bridge'; +import { ZedJsonScanner, zedJsonTokens, ZED_JSON_MAX_DEPTH } from '../../src/adapters/zed-json-tokens'; +import { ZedArgumentAuthority, reconcileZedArguments, zedArgumentFingerprint } from '../../src/adapters/zed-tool-arguments'; + +const item = (argumentsText: string, id = 'a') => ({ type: 'function_call', id, + call_id: 'call-' + id, name: 'fixture', status: 'completed', arguments: argumentsText }); +const added = (id = 'a') => ({ type: 'response.output_item.added', item: { ...item('', id), status: 'in_progress' } }); +const delta = (text: string, id = 'a') => ({ type: 'response.function_call_arguments.delta', item_id: id, delta: text }); +const argsDone = (text: string) => ({ type: 'response.function_call_arguments.done', item_id: 'a', arguments: text }); +const ended = (text: string, id = 'a') => ({ type: 'response.output_item.done', item: item(text, id) }); +const completed = (output: unknown[] = []) => ({ type: 'response.completed', response: { output } }); + +async function collect(frames: unknown[]) { + const budget = createTranslatorBudget(); + try { + const source = new Response(frames.map(event => JSON.stringify({ event }) + '\n').join('') + + '{"status":"stream_ended"}\n').body!; + return await Array.fromAsync(translateZedOpenAiStream(zedEventStream(source, 'open_ai'), budget)); + } finally { + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + budget.dispose(); + } +} + +for (const [name, left, right] of [ + ['integer precision', '{"id":9007199254740992}', '{"id":9007199254740993}'], + ['fraction precision', '{"x":1}', '{"x":1.0000000000000001}'], + ['underflow', '{"x":0}', '{"x":1e-400}'], + ['overflow', '{"x":1e400}', '{"x":2e400}'], +] as const) for (const mode of ['visible', 'buffered', 'late', 'replay'] as const) { + test(`round3 rejects distinct numbers: ${name}, ${mode}`, async () => { + const id = mode === 'buffered' ? 'b' : 'a'; + const frames = mode === 'late' ? [added(), argsDone(left), delta(right), completed()] + : mode === 'replay' ? [added(), delta(left), ended(left), completed([item(right)])] + : [...(mode === 'buffered' ? [added()] : []), added(id), delta(left, id), ended(right, id), + ...(mode === 'buffered' ? [ended('{}')] : []), completed()]; + const events = await collect(frames); + expect(events.at(-1)?.type).toBe('error'); + expect(buildResponseJSON(events, 'fixture').status).toBe('failed'); + const wire = await new Response(bridgeToResponsesSSE((async function* () { yield* events; })(), 'fixture')).text(); + const terminal = wire.split('\n').filter(line => line.startsWith('data: {')).map(line => JSON.parse(line.slice(6))) + .findLast(event => ['response.completed', 'response.failed', 'response.incomplete'].includes(event.type)); + expect(terminal?.response.status).toBe('failed'); + }); +} + +test('round3 equivalent object-rooted JSON at depth 50000 succeeds iteratively', async () => { + const text = '{"x":' + '['.repeat(50000) + '1' + ']'.repeat(50000) + '}'; + const events = await collect([added(), delta(text), ended(' ' + text), completed()]); + expect(events.at(-1)?.type).toBe('done'); +}); + +test('round3 one MiB late fragments do only linear JSON parsing and byte measurement', async () => { + const text = '{"x":"' + 'a'.repeat(1048500) + '"}'; + const frames = [added(), argsDone(text)]; + for (let i = 0; i < text.length; i += 64) frames.push(delta(text.slice(i, i + 64)) as typeof frames[number]); + frames.push(ended(text) as typeof frames[number], completed() as typeof frames[number]); + const parse = JSON.parse; + const byteLength = Buffer.byteLength; + let measured = 0; + JSON.parse = ((input: string, ...rest: []) => { measured += input.length; return parse(input, ...rest); }) as typeof JSON.parse; + Buffer.byteLength = ((input: Parameters[0], ...rest: [BufferEncoding?]) => { + measured += input.length; + return byteLength(input, ...rest); + }) as typeof byteLength; + let events; + try { events = await collect(frames); } + finally { JSON.parse = parse; Buffer.byteLength = byteLength; } + expect(events.at(-1)?.type).toBe('done'); + expect(measured).toBeLessThan(text.length * 100); +}, 30000); + +test('scanner steps bound one MiB of 64-byte fragments linearly', () => { + const text = '{"x":"' + 'a'.repeat(1048500) + '"}'; + const authority = new ZedArgumentAuthority(text, false); + for (let i = 0; i < text.length; i += 64) authority.push(text.slice(i, i + 64)); + expect(authority.steps).toBe(text.length); +}); + +test('explicit nesting limit accepts its boundary and refuses the next container clearly', async () => { + const text = '['.repeat(ZED_JSON_MAX_DEPTH) + '0' + ']'.repeat(ZED_JSON_MAX_DEPTH); + expect(() => zedJsonTokens(text)).not.toThrow(); + expect(() => zedJsonTokens('[' + text + ']')).toThrow(`JSON nesting depth limit exceeded (${ZED_JSON_MAX_DEPTH})`); + const events = await collect([added(), delta('[' + text + ']'), ended(' [' + text + ']'), completed()]); + expect(events.at(-1)).toMatchObject({ type: 'error', message: expect.stringContaining('nesting depth limit') }); + expect(events.some(e => e.type === 'tool_call_end' && !e.incomplete)).toBe(false); +}); + +for (const [left, right, equal] of [ + ['{"x":1}', '{ "x" : 1 }', true], + ['{"x":1}', '{"x":1.0}', false], + ['{"x":1}', '{"x":1e0}', false], + ['{"x":1e0}', '{"x":1E0}', false], + ['{"x":1,"y":2}', '{"y":2,"x":1}', false], + ['{"x":1,"x":2}', '{"x":2}', false], + ['{"x":"\\u0041"}', '{"\\u0078":"A"}', true], + ['{"x":"\\ud83d\\ude00"}', '{"x":"😀"}', true], + ['{"x":"\\ud800"}', '{"x":"\\ud801"}', false], + ['{"x":"\\u00e9"}', '{"x":"e\\u0301"}', false], + ['{"__proto__":1,"constructor":2}', '{ "__proto__": 1, "constructor": 2 }', true], +] as const) test(`lexical comparison and replay share tokens: ${left} -> ${right}`, async () => { + expect(zedArgumentFingerprint(left, false) === zedArgumentFingerprint(right, false)).toBe(equal); + const events = await collect([added(), delta(left), ended(right), completed([item(right)])]); + expect(events.at(-1)?.type).toBe(equal ? 'done' : 'error'); +}); + +for (const prefix of ['{"x":"\\', '{"x":"\\u', '{"x":"\\u0', '{"x":"\\u00', '{"x":"\\u004']) { + test(`split Unicode escape can complete without rewriting delivered bytes: ${prefix}`, () => { + const result = reconcileZedArguments(prefix, '{ "x": "A" }', false); + expect(result?.startsWith(prefix)).toBe(true); + expect(JSON.parse(result!)).toEqual({ x: 'A' }); + }); +} + +for (const text of ['0', '-0', '-123.45e+67', 'true', 'false', 'null', '[]', '{}', '{"x":[true,null,"a"]}']) { + test(`valid JSON scanner survives every fragment boundary: ${text}`, () => { + const expected = zedJsonTokens(text); + for (let split = 0; split <= text.length; split++) { + const actual: number[] = []; + const scanner = new ZedJsonScanner(token => actual.push(token)); + scanner.push(text.slice(0, split)); scanner.push(text.slice(split)); scanner.finish(); + expect(actual).toEqual(Array.from(expected)); + expect(scanner.steps).toBe(text.length); + } + }); +} + +for (const text of ['', '01', '+1', '1.', '1e', '-', '[1,]', '{"x":}', '{"x",1}', '{x:1}', 'true false', + '"\\v"', '"\\u000x"', '"\n"', '[}', '{]', '{"a":1,}', 'nullx', '1 2']) { + test(`invalid lexical JSON fails without recursion: ${JSON.stringify(text)}`, () => { + expect(() => zedJsonTokens(text)).toThrow(); + }); +} + +test('custom input fingerprints preserve exact bytes including lone surrogates', () => { + expect(zedArgumentFingerprint('echo A', true)).not.toBe(zedArgumentFingerprint('echo A', true)); + expect(zedArgumentFingerprint('\ud800', true)).not.toBe(zedArgumentFingerprint('\ud801', true)); +}); + +test('cancelling after authority emission releases retained tokens and scanner state', async () => { + const text = '{"x":"' + 'a'.repeat(65536) + '"}'; + const source = new Response([added(), argsDone(text)].map(event => 'data: ' + JSON.stringify(event) + '\n\n').join('')); + const budget = createTranslatorBudget(); + const stream = translateZedOpenAiStream(source.body, budget); + try { + expect((await stream.next()).value).toMatchObject({ type: 'tool_call_start' }); + expect((await stream.next()).value).toMatchObject({ type: 'tool_call_delta', arguments: text }); + expect(budget.snapshot().currentBytes).toBeGreaterThan(text.length * 4); + } finally { + await stream.return(); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + budget.dispose(); + } +});