diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 76ac74b1316..7bea6e5bed5 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -382,6 +382,13 @@ 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. Conflicting +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), 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 49d42e432b1..b123ec82b2d 100644 --- a/scripts/test-layout/layout-adapters.json +++ b/scripts/test-layout/layout-adapters.json @@ -1,4 +1,8 @@ { + "zed-openai-contract.test.ts": "providers", + "zed-failure-relay.test.ts": "providers", + "zed-argument-consistency.test.ts": "providers", + "zed-lossless-arguments.test.ts": "providers", "qoder-image-input.test.ts": "providers", "qoder-cn-image-validation.test.ts": "providers", "coding-agent-history-projection.test.ts": "adapters", diff --git a/src/adapters/openai-chat/required-tool-result.ts b/src/adapters/openai-chat/required-tool-result.ts index cffac7d63c6..95a6ab6f7a4 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/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 86ae5e5340b..5ca4e467882 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-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-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 new file mode 100644 index 00000000000..d8d682f1f64 --- /dev/null +++ b/src/adapters/zed-openai-stream.ts @@ -0,0 +1,723 @@ +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 { zedDiagnosticUsage } from "./zed-diagnostic-usage"; +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 { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +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; + authoritative: boolean; + authority?: ZedArgumentAuthority; + authorityBytes?: number; + argsBytes: number; + fullBytes: number; + lastCodeUnit: number; + emitted: number; + started: boolean; + ended: boolean; + closed: boolean; + incomplete: boolean; + argsDone: boolean; + /** True only after this call's own done or completed item, not after EOF. */ + allowFull: boolean; +} + +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; + 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; +} + +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 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 truncationReasonFor(details.reason) ?? 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. + * 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 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. + */ +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 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" && (state.authoritative || full.startsWith(state.args))) return full; + return state.args; + } + + function argumentWorkspace(texts: string[], work: () => T): T { + // 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(); } + } + + 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) { + next.ended = true; + releaseCall(next); + terminal = true; + terminalError = true; + yield unnamedToolCallEvent(usage); + return; + } + } + if (next.name && !next.started) { + next.started = true; + yield { type: "tool_call_start", id: next.callId, name: next.name }; + } + const text = argumentText(next); + if (next.name && 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; + // 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; + 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" }); + 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); + const scope = { kind: "tool_args" as const, callId: state.budgetKey }; + const reservation = budget.reserveTransient(nextBytes, scope); + try { + const next = state.args + fragment; + if (state.authoritative) { + 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) { + 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; + state.lastCodeUnit = fragment.charCodeAt(fragment.length - 1); + yield* pump(); + } + + 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" || (!authoritative && full.length === 0)) return; + if (state.ended) { + if (state.finalHash && state.finalHash !== fingerprint(state, full)) throw new ZedToolIdentityError(); + return; + } + if (!authoritative && state.allowFull) return; + // Completed input remains authoritative even when another call blocks all emission. + if (authoritative) { + const snapshot = full; + 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 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; + 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; + } + + /** + * 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.budgetKey); + budget.releaseRetained(state.authorityBytes ?? 0, { kind: "retained_collectors" }); + state.authority = undefined; + state.authorityBytes = 0; + 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()) releaseCall(state); + 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 = ""; + } + + function* closeCall(state: CallState, allowFull: boolean): Generator { + if (state.ended) return; + state.closed = true; + if (allowFull) state.allowFull = true; + yield* pump(); + } + + function* flushUnclosed(allowFull: boolean, preserveFailure = false): Generator { + for (const state of calls.values()) { + if (state.ended) continue; + state.closed = true; + if (!allowFull) state.incomplete = true; + if (allowFull) state.allowFull = true; + } + yield* pump(preserveFailure); + } + + 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, 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: "", + authoritative: false, + argsBytes: 0, + fullBytes: 0, + lastCodeUnit: 0, + emitted: 0, + started: false, + ended: false, + closed: false, + incomplete: false, + argsDone: false, + allowFull: false, + }; + calls.set(key, state); + budget.openCall(state.budgetKey); + } + 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, 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" }); + 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, nextBytes = Buffer.byteLength(text, "utf8"), last = text.charCodeAt(text.length - 1)): void { + if (itemId) { + retainIdentity(`text:${itemId}`); + const previousBytes = textBytesByItem.get(itemId) ?? 0; + 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; + } + 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 { + if (!text) return; + // 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; + noteText(undefined, ""); + } else if (unscopedText.startsWith(text)) { + noteText(undefined, unscopedText.slice(text.length)); + noteText(itemId, text); + return; + } + } + if (!previous) { + noteText(itemId, text); + yield* semantic({ type: "text_delta", text, ...(phase ? { phase } : {}) }); + return; + } + if (text === previous || previous.endsWith(text)) { + noteText(itemId, previous); + return; + } + if (text.startsWith(previous)) { + const tail = text.slice(previous.length); + noteText(itemId, text); + if (tail) yield* semantic({ type: "text_delta", text: tail, ...(phase ? { phase } : {}) }); + return; + } + noteText(itemId, previous + text); + 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 { + 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 { + 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, item.type); + if (item.status === "incomplete") state.incomplete = true; + rememberFull(state, item.type === "custom_tool_call" ? item.input : item.arguments, done); + if (done) yield* closeCall(state, true); + else yield* pump(); + } + + function* emitReasoningItem(item: Record): Generator { + 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; + thinkingItems.add(key); + yield* semantic({ type: "thinking_delta", thinking: text }); + } + } + 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; + rawReasoningItems.add(key); + yield* semantic({ 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") 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; + } + 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" || (payload.error !== undefined && payload.error !== null)) { + terminal = true; + 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, true); + yield* flushUnclosed(false); + if (terminalError) return; + yield { type: "incomplete", reason: incompleteReason(payload), ...(usage ? { usage } : {}) }; + 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; + 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* 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; + 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; + 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 ( + (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; + 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; + } + 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, "", "", 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; + yield* pump(); + return; + } + 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; + } + } + // 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; + } + } + } + + 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; + } + + const unfinished = [...calls.values()].some(state => !state.closed || state.incomplete); + yield* flushUnclosed(false); + if (terminal) return; + if (unfinished) { + yield { type: "incomplete", reason: "adapter_eof", ...(usage ? { usage } : {}) }; + return; + } + const done: AdapterEvent = { + type: "done", + ...(usage ? { usage } : {}), + ...(compactionEncryptedContent ? { compactionEncryptedContent } : {}), + }; + transferCompactionCiphertextLease(done, budget, compactionEncryptedContentBytes); + ciphertextTransferred = true; + yield done; + } catch (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 } : {}) }; + } finally { + if (!ciphertextTransferred) budget.releaseRetained(compactionEncryptedContentBytes, { kind: "retained_collectors" }); + releaseStreamBuffers(); + } +} diff --git a/src/adapters/zed-tool-arguments.ts b/src/adapters/zed-tool-arguments.ts new file mode 100644 index 00000000000..c1b3b03a386 --- /dev/null +++ b/src/adapters/zed-tool-arguments.ts @@ -0,0 +1,79 @@ +import { createHash } from "node:crypto"; +import { ZedJsonError, ZedJsonScanner, zedJsonTokens } from "./zed-json-tokens"; + +/** Ordered lexical tokens are shared by equality, incremental validation and replay hashes. */ +export function zedArgumentFingerprint(text: string, custom: boolean): string { + const hash = createHash("sha256"); + // 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.update("json:").update(Buffer.from(tokens.buffer, tokens.byteOffset, tokens.byteLength)).digest("hex"); +} + +/** 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; } + + 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); + } + + 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; + } + } +} + +/** 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; +} + +/** 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 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; + } +} diff --git a/src/adapters/zed.ts b/src/adapters/zed.ts index 0004090896c..a9bb6104df4 100644 --- a/src/adapters/zed.ts +++ b/src/adapters/zed.ts @@ -5,6 +5,8 @@ import type { AnthropicCacheRetention } from "./anthropic/cache-retention"; 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"; @@ -66,6 +68,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); @@ -213,7 +236,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 = 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), + ...(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) } : {}), + }); + emit(controller, diagnostic ? { + ...scrubRecord(event, true), + ...(isRecord(event.response) ? { response: scrubRecord(event.response) } : {}), + } : event); } return body.pipeThrough(output); @@ -256,7 +295,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; @@ -314,6 +354,12 @@ export function createZedAdapter(provider: OcxProviderConfig, cacheRetention?: A : 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"); @@ -410,7 +456,11 @@ export function createZedAdapter(provider: OcxProviderConfig, cacheRetention?: A 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/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 1e1a07268d1..f62e2f3f8cf 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -1184,6 +1184,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/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/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/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/types/request.ts b/src/types/request.ts index 4d5170b45cf..810d55cd49c 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -479,7 +479,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/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/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 55801cf81f1..1df65eb8c1f 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -212,7 +212,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 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. | @@ -502,7 +502,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 e8e8ecc87af..23248833810 100644 --- a/structure/providers/chat-required-tool-results.md +++ b/structure/providers/chat-required-tool-results.md @@ -1,6 +1,6 @@ # 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. The guard forwards the per-exchange stream context unchanged, preserving the opt-in [completed tool blocks](completed-tool-blocks.md) contract while counting early closed calls toward the same required-tool guarantee. diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 605d1eaa9f0..0490d770a68 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -379,3 +379,16 @@ 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. +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 +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/structure/transports/inventory.md b/structure/transports/inventory.md index 37699c0a913..511a454fb27 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/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 1177c119bca..0adfb7e0dd4 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 83fad737cdd..df7a9f9f71f 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/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/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 31e0693b93f..846240de3ed 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -2,6 +2,10 @@ "claude-intercept-acl-barrier.test.ts": "claude-integration", "codex-catalog-heal-reanchor.test.ts": "codex-integration", "tool-identity-hash.test.ts": "adapters", + "zed-openai-contract.test.ts": "providers", + "zed-failure-relay.test.ts": "providers", + "zed-argument-consistency.test.ts": "providers", + "zed-lossless-arguments.test.ts": "providers", "qoder-image-input.test.ts": "providers", "qoder-cn-image-validation.test.ts": "providers", "coding-agent-history-projection.test.ts": "adapters", 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-argument-consistency.test.ts b/tests/providers/zed-argument-consistency.test.ts new file mode 100644 index 00000000000..90930b13caa --- /dev/null +++ b/tests/providers/zed-argument-consistency.test.ts @@ -0,0 +1,324 @@ +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(`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'); +}); + +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()]); + 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)); + 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()]); + 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)); + 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 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 }, + { 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('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 () => { + 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()]); + 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' })); + }); +} + +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 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()]); + 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.failed')?.response; + } + 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(`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('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) { + 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('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('error'); + expect(buildResponseJSON(events,'fixture').status).toBe('failed'); +}); + +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('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()=>{ + 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'); +}); 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-hosted-provider.test.ts b/tests/providers/zed-hosted-provider.test.ts index 7442777fba2..2046c1d6670 100644 --- a/tests/providers/zed-hosted-provider.test.ts +++ b/tests/providers/zed-hosted-provider.test.ts @@ -4,6 +4,9 @@ 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 { 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"; @@ -362,6 +365,464 @@ 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: {} } }, + ]); + }); + + 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 }); + 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 } }, + ]); + }); + + 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 } }, + ]); + }); + + 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: "heartbeat" }, + { 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: "heartbeat" }, + { 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: "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", incomplete: true }, + { 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", incomplete: true }, + { type: "incomplete", reason: "adapter_eof" }, + ]); + }); + + 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}" }, + { type: "response.completed", response: { output: [] } }, + ])).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(); + } + }); + + 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", () => { async function fixture(limit: number, full = false) { const catalog = [jsonResponse({ default_organization_id: "org-1" }), 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(); + } +}); diff --git a/tests/providers/zed-openai-contract.test.ts b/tests/providers/zed-openai-contract.test.ts new file mode 100644 index 00000000000..0825d69fd9d --- /dev/null +++ b/tests/providers/zed-openai-contract.test.ts @@ -0,0 +1,392 @@ +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"; +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 { + 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}' }, + { 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")); + 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(["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("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" }, + { 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([ + { 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("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: "" } }, + { 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: "{}" }, + { 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"]); + }); + + 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: "tool_call_end", incomplete: true }, + { 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" }, + { type: "response.completed", response: { output: [] } }, + ], { 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) }, + { type: "response.completed", response: { output: [] } }, + ], { 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" }, + { type: "response.completed", response: { output: [] } }, + ]); + 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" }, + { type: "response.completed", response: { output: [] } }, + ], { maxCallArgumentBytes: 4 }); + expect(events.filter(event => event.type === "tool_call_delta").map(event => event.arguments).join("")) + .toBe("😀"); + expect(events.at(-1)).toEqual({ type: "done" }); + }); +}); 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"); });