diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 901c75b2425..94c006e724f 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -771,7 +771,7 @@ OpenCodex provides official adapter support for Tencent Cloud's CodeBuddy Code C - Global: [CodeBuddy Global API Keys](https://www.codebuddy.ai/profile/keys) - CN: [CodeBuddy CN API Keys](https://copilot.tencent.com/profile/keys) - **Region Isolation:** `codebuddy` and `codebuddy-cn` use separate canonical endpoints (`https://www.codebuddy.ai` and `https://www.codebuddy.cn`) and isolated child environments (`CODEBUDDY_INTERNET_ENVIRONMENT=public` vs `internal`). Credentials are strictly region-scoped and never exchanged across environments. Overriding the canonical base URL fails closed. -- **Tool Ownership:** In v1, the CLI is spawned with `--tools ""` and `--strict-mcp-config`, ensuring Codex maintains exclusive tool ownership. The provider operates in text and reasoning mode; client tool execution is not delegated to the vendor CLI. +- **Tool Ownership:** In v1, the CLI is spawned with `--tools ""` and `--strict-mcp-config`, ensuring Codex maintains exclusive tool ownership. The provider operates in text and reasoning mode; client tool execution is not delegated to the vendor CLI. If the CLI writes an unquoted DSML `calls` control line followed by a `functions.*` invoke control line into text or reasoning, OpenCodex refuses the turn instead of forwarding the scaffold or interpreting it as an executable call. DSML discussed or quoted in prose, inline code, fenced code, or source examples remains ordinary answer text. - **Entitlements and Billing:** The provider uses the same vendor-documented CodeBuddy account/CLI authentication surface. Availability and billing of free, promotional, trial, or subscription credits remain determined by the user's CodeBuddy account entitlement. ### Official Qoder CLI (Global & CN) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 49e1f38a7b3..89d2a010e6c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -994,6 +994,7 @@ "omp-path-contract.test.ts": "clients", "omp-yaml-source-inline-comments.test.ts": "clients", "openai-api-virtual-models.test.ts": "adapters/openai", + "openai-chat-bounded-tool-names.test.ts": "adapters/openai", "openai-chat-dangling-toolcalls.test.ts": "adapters/openai", "openai-chat-eof.test.ts": "adapters/openai", "openai-chat-hardening.test.ts": "adapters/openai", diff --git a/src/adapters/codebuddy/adapter.ts b/src/adapters/codebuddy/adapter.ts index 234e06907e3..a769ac37dae 100644 --- a/src/adapters/codebuddy/adapter.ts +++ b/src/adapters/codebuddy/adapter.ts @@ -4,6 +4,7 @@ import { mapReasoningEffort } from "../../reasoning-effort"; import { buildSystemPrompt } from "../coding-agent/protocol"; import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps, type SpawnFn } from "../coding-agent/turn"; import { CODEBUDDY_PROFILES, type CodeBuddyProfile } from "./profiles"; +import { guardCodeBuddyScaffolding } from "./scaffold-guard"; export type { SpawnFn } from "../coding-agent/turn"; export type CodeBuddyAdapterDeps = CodingAgentDeps; @@ -75,7 +76,7 @@ export function createCodeBuddyAdapter(provider: OcxProviderConfig, deps: CodeBu provider, parsed, incoming, - emit, + emit: guardCodeBuddyScaffolding(emit), buildArgs: (resolved, req, prov) => buildArgs(resolved as CodeBuddyProfile, req, prov), buildEnv: (resolved, apiKey) => buildChildEnv(resolved as CodeBuddyProfile, apiKey), deps, diff --git a/src/adapters/codebuddy/scaffold-guard.ts b/src/adapters/codebuddy/scaffold-guard.ts new file mode 100644 index 00000000000..6f8c80aed57 --- /dev/null +++ b/src/adapters/codebuddy/scaffold-guard.ts @@ -0,0 +1,248 @@ +import type { AdapterEvent } from "../../types"; + +/** Error code for a CodeBuddy turn whose output contains vendor agent scaffolding. */ +export const CODEBUDDY_SCAFFOLD_ERROR_CODE = "vendor_scaffold_detected"; + +// The observed control protocol uses FULLWIDTH VERTICAL LINE (U+FF5C). Detection stays +// deliberately narrower than the marker spelling: a calls control line must be followed by an +// invoke line for a functions.* tool. That distinguishes an agent scaffold from prose quoting or +// discussing one tag. +const DSML_CALLS_LINE = "<||dsml|| calls>"; +const DSML_INVOKE_PREFIX = "<||dsml|| invoke name=\"functions."; + +export interface CodeBuddyScaffoldFilterResult { + /** Bytes released from a suffix withheld by an earlier event on this channel. */ + releasedPending: string; + /** Safe bytes belonging to the event currently being processed. */ + text: string; + /** The earlier pending event still owns the extended candidate. */ + pendingContinues: boolean; + fail: boolean; +} + +interface ScanResult { + safe: string; + held: string; + fail: boolean; + fence: "`" | "~" | null; + lineStart: boolean; +} + +function prefixAtEnd(text: string, at: number, expected: string): boolean { + const rest = text.slice(at).toLowerCase(); + return rest.length < expected.length && expected.startsWith(rest); +} + +/** + * Scan complete bytes and retain only a bounded suffix that can still become a control sequence. + * + * Control tags are recognized only at column zero and outside fenced Markdown. Inline code, + * quoted strings, blockquotes, indented source, and prose all add syntax before the tag and are + * therefore forwarded unchanged. A calls line alone is harmless; refusal requires the observed + * two-line calls-plus-functions-invoke grammar. + */ +function scan( + text: string, + initialFence: "`" | "~" | null, + initialLineStart: boolean, +): ScanResult { + let fence = initialFence; + let lineStart = initialLineStart; + let index = 0; + + while (index < text.length) { + if (lineStart) { + const fenceMarkers = fence ? [fence.repeat(3)] : ["```", "~~~"]; + const completeFence = fenceMarkers.find(marker => text.startsWith(marker, index)); + if (completeFence) { + fence = fence ? null : (completeFence[0] as "`" | "~"); + index += completeFence.length; + lineStart = false; + continue; + } + if (fenceMarkers.some(marker => prefixAtEnd(text, index, marker))) { + return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart }; + } + + if (!fence) { + const lowered = text.slice(index).toLowerCase(); + if (lowered.startsWith(DSML_CALLS_LINE)) { + const afterCalls = index + DSML_CALLS_LINE.length; + let invokeAt = -1; + if (text[afterCalls] === "\n") invokeAt = afterCalls + 1; + else if (text[afterCalls] === "\r" && text[afterCalls + 1] === "\n") invokeAt = afterCalls + 2; + else if (afterCalls === text.length || (text[afterCalls] === "\r" && afterCalls + 1 === text.length)) { + return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart }; + } + + if (invokeAt >= 0) { + const invokeRest = text.slice(invokeAt).toLowerCase(); + if (invokeRest.startsWith(DSML_INVOKE_PREFIX)) { + return { safe: text.slice(0, index), held: "", fail: true, fence, lineStart }; + } + if (invokeRest.length === 0 || DSML_INVOKE_PREFIX.startsWith(invokeRest)) { + return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart }; + } + } + } else if (prefixAtEnd(text, index, DSML_CALLS_LINE)) { + return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart }; + } + } + } + + const char = text[index]!; + index += 1; + lineStart = char === "\n"; + } + + return { safe: text, held: "", fail: false, fence, lineStart }; +} + +/** Streaming DSML control-sequence filter for one text or reasoning channel. */ +export class CodeBuddyScaffoldFilter { + private pending = ""; + private failed = false; + private fence: "`" | "~" | null = null; + private lineStart = true; + + /** True while an earlier event owns an unresolved marker or fence prefix. */ + hasPending(): boolean { + return this.pending.length > 0; + } + + push(chunk: string): CodeBuddyScaffoldFilterResult { + if (this.failed) { + return { releasedPending: "", text: "", pendingContinues: false, fail: false }; + } + if (!chunk) { + return { + releasedPending: "", + text: "", + pendingContinues: this.hasPending(), + fail: false, + }; + } + + const priorPending = this.pending; + const result = scan(priorPending + chunk, this.fence, this.lineStart); + this.pending = result.held; + this.fence = result.fence; + this.lineStart = result.lineStart; + this.failed = result.fail; + + const releasedLength = Math.min(priorPending.length, result.safe.length); + return { + releasedPending: result.safe.slice(0, releasedLength), + text: result.safe.slice(releasedLength), + pendingContinues: priorPending.length > 0 && result.safe.length === 0 && result.held.length > 0, + fail: result.fail, + }; + } + + /** Release a suffix that never completed the two-line control grammar. */ + flush(): CodeBuddyScaffoldFilterResult { + if (this.failed) { + return { releasedPending: "", text: "", pendingContinues: false, fail: false }; + } + const text = this.pending; + this.pending = ""; + return { releasedPending: text, text: "", pendingContinues: false, fail: false }; + } +} + +function codeBuddyScaffoldErrorMessage(): string { + return "CodeBuddy CLI emitted vendor tool-call markup in an assistant output channel. This route" + + " runs the CLI with its own tools and MCP servers disabled and Codex owns tool control, so" + + " the turn was refused rather than forwarding or executing vendor agent scaffolding."; +} + +/** Guard both streamed channels while preserving event order around withheld marker prefixes. */ +export function guardCodeBuddyScaffolding(emit: (event: AdapterEvent) => void): (event: AdapterEvent) => void { + const textFilter = new CodeBuddyScaffoldFilter(); + const thinkingFilter = new CodeBuddyScaffoldFilter(); + type PendingChannel = "text" | "thinking"; + type EventSlot = { resolved: boolean; event?: AdapterEvent }; + const eventQueue: EventSlot[] = []; + const pendingSlots = new Map(); + let closed = false; + + const channelEvent = (channel: PendingChannel, text: string): AdapterEvent => channel === "text" + ? { type: "text_delta", text } + : { type: "thinking_delta", thinking: text }; + + const drainResolved = (): void => { + while (eventQueue[0]?.resolved) { + const slot = eventQueue.shift()!; + if (slot.event) emit(slot.event); + } + }; + + const enqueueResolved = (event: AdapterEvent): void => { + eventQueue.push({ resolved: true, event }); + drainResolved(); + }; + + const resolvePendingSlot = (channel: PendingChannel, text: string): void => { + const slot = pendingSlots.get(channel); + if (!slot) return; + slot.resolved = true; + if (text) slot.event = channelEvent(channel, text); + pendingSlots.delete(channel); + drainResolved(); + }; + + const enqueuePendingSlot = (channel: PendingChannel): void => { + const slot: EventSlot = { resolved: false }; + eventQueue.push(slot); + pendingSlots.set(channel, slot); + }; + + const flushAllPending = (): void => { + for (const channel of ["text", "thinking"] as const) { + if (!pendingSlots.has(channel)) continue; + const filter = channel === "text" ? textFilter : thinkingFilter; + resolvePendingSlot(channel, filter.flush().releasedPending); + } + drainResolved(); + }; + + const refuse = (): void => { + if (closed) return; + flushAllPending(); + closed = true; + emit({ + type: "error", + message: codeBuddyScaffoldErrorMessage(), + status: 502, + errorType: "upstream_error", + code: CODEBUDDY_SCAFFOLD_ERROR_CODE, + retryable: false, + }); + }; + + return (event: AdapterEvent): void => { + if (closed) return; + if (event.type === "text_delta" || event.type === "thinking_delta") { + const channel: PendingChannel = event.type === "text_delta" ? "text" : "thinking"; + const filter = channel === "text" ? textFilter : thinkingFilter; + const hadPending = filter.hasPending(); + const cleaned = filter.push(event.type === "text_delta" ? event.text : event.thinking); + if (hadPending && !cleaned.pendingContinues) resolvePendingSlot(channel, cleaned.releasedPending); + if (cleaned.text) { + enqueueResolved(event.type === "text_delta" + ? { ...event, text: cleaned.text } + : { ...event, thinking: cleaned.text }); + } + if (filter.hasPending() && !cleaned.pendingContinues) enqueuePendingSlot(channel); + if (cleaned.fail) refuse(); + return; + } + if (event.type === "done" || event.type === "error" || event.type === "incomplete") { + flushAllPending(); + closed = true; + emit(event); + return; + } + enqueueResolved(event); + }; +} diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 8503210e465..d74640a3e23 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -6,7 +6,6 @@ import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort"; import { debugProviderDiagnostic } from "../lib/debug"; import { sseFieldValue } from "../lib/sse-decoder"; import { isDebugEnabled } from "../lib/debug-settings"; -import { frameAgentRouterMessages } from "./agentrouter"; import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing"; import { resolveVercelGatewayRouting, vercelGatewayProviderPayload } from "../providers/vercel-gateway-routing"; import { fastPolicyForModel } from "../providers/service-tier"; @@ -39,6 +38,7 @@ import { upstreamErrorEvent, } from "./openai-chat/errors"; import { messagesToChatFormat } from "./openai-chat/messages"; +import { withOpenAIChatToolNames } from "./openai-chat/tool-name-registry"; import { isNativeOpenAIChatTarget, openAIChatTransport, stripBracketedModelSuffix } from "./openai-chat/wire"; import { toolChoiceToChatFormat, toolsToChatFormatForProvider } from "./openai-chat/tool-schema"; @@ -88,7 +88,7 @@ function canSerializeOpenAIChatServiceTier( export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAdapter { let lastRequestedModelId: string | undefined; - return { + return withOpenAIChatToolNames(toolNames => ({ name: "openai-chat", formatErrorBody: formatOpenAIChatErrorBody, @@ -96,10 +96,10 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd buildRequest(parsed: OcxParsedRequest, incoming?: IncomingMeta) { lastRequestedModelId = parsed.modelId; const { url, headers, hasCredential } = openAIChatTransport(provider); - const messages = frameAgentRouterMessages(provider.baseUrl, messagesToChatFormat(parsed, provider)); + const messages = toolNames.messages(parsed, provider.baseUrl, messagesToChatFormat(parsed, provider)); const finish = (): AdapterRequest => { - const tools = toolsToChatFormatForProvider(parsed, provider); - const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider); + const tools = toolsToChatFormatForProvider(parsed, provider, toolNames.registry()); + const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider, toolNames.registry()); const body: Record = { model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(parsed.modelId) : parsed.modelId, @@ -365,7 +365,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd return "terminate"; } if (!call.id) call.id = `call_${++toolCallSeq}`; - yield { type: "tool_call_start", id: call.id, name: call.name }; + yield { type: "tool_call_start", id: call.id, name: toolNames.restore(call.name) }; if (call.args.length > 0) yield { type: "tool_call_delta", arguments: call.args }; yield { type: "tool_call_end" }; } @@ -801,7 +801,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd logInvalidToolCalls("response", rawToolCalls); return [invalidToolCallsEvent(rawToolCalls, "response", usage)]; } - events.push({ type: "tool_call_start", id, name }); + events.push({ type: "tool_call_start", id, name: toolNames.restore(name) }); events.push({ type: "tool_call_delta", arguments: args }); events.push({ type: "tool_call_end" }); } @@ -818,5 +818,5 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd budget.releaseRetained(responseBytes, { kind: "retained_collectors" }); } }, - }; + })); } diff --git a/src/adapters/openai-chat/tool-name-registry.ts b/src/adapters/openai-chat/tool-name-registry.ts new file mode 100644 index 00000000000..c9c8e1fa5ff --- /dev/null +++ b/src/adapters/openai-chat/tool-name-registry.ts @@ -0,0 +1,166 @@ +import { createHash } from "node:crypto"; +import { namespacedToolName, type OcxParsedRequest, type OcxTool } from "../../types"; +import { frameAgentRouterMessages } from "../agentrouter"; + +const MAX_CHAT_TOOL_NAME_LENGTH = 64; +const ALIAS_HINT_CHARS = 16; +const RESERVED_ALIAS_PATTERN = /^ocx_[a-zA-Z0-9_-]{16}_[a-zA-Z0-9_-]{43}$/; +type ToolIdentity = Readonly>; + +export interface OpenAIChatToolNameRegistry { + alias(tool: ToolIdentity): string; + aliasWireName(wireName: string): string; + restore(wireName: string): string; +} + +interface OpenAIChatToolNameScope { + messages(parsed: OcxParsedRequest, baseUrl: string, messages: readonly unknown[]): unknown; + registry(): OpenAIChatToolNameRegistry; + restore(wireName: string): string; +} + +function identityKey(tool: ToolIdentity): string { + return JSON.stringify([tool.namespace ?? null, tool.name]); +} + +function boundedAlias(tool: ToolIdentity, wireName: string): string { + const hint = wireName + .replace(/[^a-zA-Z0-9_-]/g, "_") + .slice(-ALIAS_HINT_CHARS) + .padStart(ALIAS_HINT_CHARS, "_"); + const key = identityKey(tool); + const digest = createHash("sha256") + .update(key) + .digest("base64url"); + return `ocx_${hint}_${digest}`; +} + +/** Catalog declarations plus structured calls retained in replay history. */ +export function openAIChatToolNameIdentities(parsed: OcxParsedRequest): ToolIdentity[] { + const identities: ToolIdentity[] = [...(parsed.context.tools ?? [])]; + for (const message of parsed.context.messages) { + if (message.role !== "assistant" || !Array.isArray(message.content)) continue; + for (const part of message.content) { + if (part.type !== "toolCall") continue; + identities.push({ + name: part.name, + ...(part.namespace === undefined ? {} : { namespace: part.namespace }), + }); + } + } + return identities; +} + +/** + * One collision domain for a translated Chat Completions request. + * + * Namespaced names whose flattened spelling exceeds Chat Completions' 64-character function-name + * bound are rewritten. Ordinary names and bare names pass through byte-for-byte unless they occupy + * the reserved alias spelling; those are re-aliased so no declaration can shadow another identity's + * deterministic alias. Distinct identities sharing one flattened spelling each keep an identity + * alias, while replay rewriting leaves that ambiguous spelling untouched. Echoed aliases restore to + * the original flattened name consumed by the Responses bridge's existing namespace map. + */ +export function createOpenAIChatToolNameRegistry( + tools: readonly ToolIdentity[] | undefined, +): OpenAIChatToolNameRegistry { + const identities = new Map(); + for (const tool of tools ?? []) identities.set(identityKey(tool), tool); + const sortedIdentities = [...identities.entries()] + .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0); + + const aliasesByIdentity = new Map(); + const aliasesByWireName = new Map(); + const originalsByAlias = new Map(); + const wireOwners = new Map(); + for (const [key, tool] of sortedIdentities) { + const wireName = namespacedToolName(tool.namespace, tool.name); + const owner = wireOwners.get(wireName); + if (owner === undefined) wireOwners.set(wireName, key); + else if (owner !== key) wireOwners.set(wireName, null); + } + + const aliasOwners = new Map(); + const wireClaims = new Map(); + for (const [key, tool] of sortedIdentities) { + const wireName = namespacedToolName(tool.namespace, tool.name); + const candidate = (tool.namespace !== undefined && wireName.length > MAX_CHAT_TOOL_NAME_LENGTH) + || RESERVED_ALIAS_PATTERN.test(wireName) + || wireOwners.get(wireName) === null + ? boundedAlias(tool, wireName) + : wireName; + // A full SHA-256 collision is not safely attributable. Keep the later identity's native + // spelling instead of failing the request or stealing the first identity's restore entry. + const alias = aliasOwners.has(candidate) ? wireName : candidate; + aliasesByIdentity.set(key, alias); + if (!aliasOwners.has(alias)) aliasOwners.set(alias, key); + if (alias !== wireName) originalsByAlias.set(alias, wireName); + + const claim = wireClaims.get(wireName); + if (claim === undefined) wireClaims.set(wireName, { key, alias }); + else if (claim !== null && claim.key !== key) wireClaims.set(wireName, null); + } + for (const [wireName, claim] of wireClaims) { + if (claim !== null) aliasesByWireName.set(wireName, claim.alias); + } + + return { + alias(tool: ToolIdentity): string { + const key = identityKey(tool); + const known = aliasesByIdentity.get(key); + if (known !== undefined) return known; + return namespacedToolName(tool.namespace, tool.name); + }, + aliasWireName(wireName: string): string { + return aliasesByWireName.get(wireName) ?? wireName; + }, + restore(wireName: string): string { + return originalsByAlias.get(wireName) ?? wireName; + }, + }; +} + +export function restoreOpenAIChatToolName( + registry: OpenAIChatToolNameRegistry, + wireName: string, +): string { + return registry.restore(wireName); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Rewrite replayed assistant tool calls after the ordinary message converter has flattened them. */ +export function aliasOpenAIChatMessageToolNames( + messages: readonly unknown[], + registry: OpenAIChatToolNameRegistry, +): unknown[] { + return messages.map(message => { + if (!isRecord(message) || !Array.isArray(message.tool_calls)) return message; + let changed = false; + const toolCalls = message.tool_calls.map(toolCall => { + if (!isRecord(toolCall) || !isRecord(toolCall.function) + || typeof toolCall.function.name !== "string") return toolCall; + const name = registry.aliasWireName(toolCall.function.name); + if (name === toolCall.function.name) return toolCall; + changed = true; + return { ...toolCall, function: { ...toolCall.function, name } }; + }); + return changed ? { ...message, tool_calls: toolCalls } : message; + }); +} + +export function withOpenAIChatToolNames( + build: (scope: OpenAIChatToolNameScope) => T, +): T { + let registry = createOpenAIChatToolNameRegistry(undefined); + return build({ + messages(parsed, baseUrl, messages): unknown { + registry = createOpenAIChatToolNameRegistry(openAIChatToolNameIdentities(parsed)); + return frameAgentRouterMessages(baseUrl, aliasOpenAIChatMessageToolNames(messages, registry)); + }, + registry: () => registry, + restore: wireName => restoreOpenAIChatToolName(registry, wireName), + }); +} diff --git a/src/adapters/openai-chat/tool-schema.ts b/src/adapters/openai-chat/tool-schema.ts index c056a6a043a..23f77121bd5 100644 --- a/src/adapters/openai-chat/tool-schema.ts +++ b/src/adapters/openai-chat/tool-schema.ts @@ -1,7 +1,8 @@ import { isNativeOpenAIChatTarget } from "./wire"; +import { createOpenAIChatToolNameRegistry, type OpenAIChatToolNameRegistry } from "./tool-name-registry"; import { isXaiSchemaTarget, lookupLocalJsonPointer, normalizeXaiToolParameters } from "../xai-tool-schema"; import { stripResponsesOnlyEncryptedMarker, stripUnicodePropertyPatterns } from "../responses-tool-schema"; -import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../../types"; +import { isAllowedToolChoice, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../../types"; import type { OcxParsedRequest, OcxProviderConfig } from "../../types"; const ZEN_SCHEMA_MAP_KEYS = new Set(["properties", "$defs", "definitions"]); @@ -409,7 +410,11 @@ function normalizeMoonshotToolParameters(parameters: unknown): Record 0 ? formatted : undefined; } -export function toolsToChatFormatForProvider(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined { - const base = toolsToChatFormat(parsed, provider); +export function toolsToChatFormatForProvider( + parsed: OcxParsedRequest, + provider: OcxProviderConfig, + registry: OpenAIChatToolNameRegistry = createOpenAIChatToolNameRegistry(parsed.context.tools), +): unknown[] | undefined { + const base = toolsToChatFormat(parsed, provider, registry); const azureChat = isAzureOpenAiChatTarget(provider); const zenChat = shouldSanitizeZenToolParameters(provider); if (!base || (!zenChat && !azureChat)) return base; @@ -463,15 +472,24 @@ export function toolChoiceToChatFormat( tc: OcxParsedRequest["options"]["toolChoice"], tools: OcxParsedRequest["context"]["tools"], provider: OcxProviderConfig, + registry: OpenAIChatToolNameRegistry = createOpenAIChatToolNameRegistry(tools), ): unknown { if (!tc) return undefined; if (isAllowedToolChoice(tc)) { if (tc.mode === "required" && tc.allowedTools.length === 1 && isNativeOpenAIChatTarget(provider)) { - return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.allowedTools[0]) } }; + return { + type: "function", + function: { name: registry.aliasWireName(resolveToolChoiceWireName(tools, tc.allowedTools[0])) }, + }; } return tc.mode === "required" ? "required" : "auto"; } if (tc === "auto" || tc === "none" || tc === "required") return tc; - if ("name" in tc) return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.name) } }; + if ("name" in tc) { + return { + type: "function", + function: { name: registry.aliasWireName(resolveToolChoiceWireName(tools, tc.name)) }, + }; + } return undefined; } diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 05ebcc5e680..381276d04b4 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -62,10 +62,20 @@ request-local alias. Raw API-key continuations deliberately preserve ids because continuation may reference a call stored upstream under its original id; proxy-expanded API-key replays are explicit and receive the same repair. -Separately, Meta Muse Responses (`src/responses/muse-tool-name-alias.ts`) aliases function *tool -names* that exceed 64 characters or contain characters outside `[a-zA-Z0-9_-]` on `api.meta.ai` -only. That map is not the call-id repair: it covers tools, `additional_tools`, history calls, and -`tool_choice`, then restores original names inbound. +Tool-name normalization stays adapter-scoped. The translated Chat Completions path uses a +request-scoped registry in `src/adapters/openai-chat/`: only flattened namespaced names over 64 +characters receive a deterministic, charset-safe alias. Catalog declarations, replayed calls and +`tool_choice` share that registry, and streamed or buffered echoes restore to the original flattened +name before the Responses bridge restores `{namespace, name}`. Names at or below the bound and bare +names pass through unchanged, except declarations matching the reserved alias shape; those are +re-aliased so they cannot shadow an identity-derived alias. + +The 64-character bound is a Chat Completions and strict-gateway compatibility concern: Command Code +rejects a 66-character function name (#4679). Upstream Codex raised its own MCP ceiling to 128 bytes +in `openai/codex#39594` because native Responses accepts 128, so that Responses limit does not govern +this translated wire. Kiro (`src/adapters/kiro-tools.ts`), Google (its wire compiler), and Meta Muse +Responses (`src/responses/muse-tool-name-alias.ts`, gated to `api.meta.ai`) each retain their own +equivalent normalization and restoration. These compatibility guards are covered by focused tests and should stay close to the adapters that need them. @@ -344,7 +354,11 @@ The shared coding-agent projection (CodeBuddy, Qoder) carries tool-result images real image blocks rather than flattening them to the text `[image]`, and orders image blocks chronologically — history before current — so attachment order matches the prose the model reads beside them. Vendor tool execution stays disabled on both -adapters, and Qoder's explicit refusal of original images is unchanged. +adapters. CodeBuddy refuses an unquoted, line-oriented full-width-bar DSML `calls` +container followed by a `functions.*` invoke control line in either output channel; it +preserves preceding answer text, never promotes vendor prose into execution authority, +and leaves discussed or quoted literals and code examples untouched. Qoder's explicit +refusal of original images is unchanged. Canonical Responses identity sanitation and narrowly scoped pre-output combo recovery follow [request-local target compatibility](../runtime.md#request-local-target-compatibility); other adapter contracts remain unchanged. diff --git a/tests/adapters/openai/openai-chat-bounded-tool-names.test.ts b/tests/adapters/openai/openai-chat-bounded-tool-names.test.ts new file mode 100644 index 00000000000..f7cdc756a39 --- /dev/null +++ b/tests/adapters/openai/openai-chat-bounded-tool-names.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; +import { createOpenAIChatToolNameRegistry } from "../../../src/adapters/openai-chat/tool-name-registry"; +import { compileGoogleWireBody } from "../../../src/adapters/google-wire-compiler"; +import { kiroToolName } from "../../../src/adapters/kiro-wire"; +import { buildResponseJSON } from "../../../src/bridge"; +import { parseRequest } from "../../../src/responses/parser"; +import { buildToolBridgeMaps } from "../../../src/server/responses"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxTool } from "../../../src/types"; +import { namespacedToolName } from "../../../src/types/tools"; +import { createTestTranslatorBudget } from "../../helpers/translator-budget"; + +const LONG_NAMESPACE = "mcp__codex_apps__codex_document_control"; +const REPORTED_NAME = "execute_document_command"; +const OTHER_LONG_NAME = "get_document_tool_schemas"; + +function provider(): OcxProviderConfig { + return { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + apiKey: "sk-test", + authMode: "key", + }; +} + +function tool(namespace: string | undefined, name: string): OcxTool { + return { namespace, name, description: "Test tool", parameters: { type: "object" } }; +} + +function parsedWith( + tools: OcxTool[], + options: OcxParsedRequest["options"] = {}, + messages: OcxParsedRequest["context"]["messages"] = [{ role: "user", content: "Use the tool", timestamp: 0 }], +): OcxParsedRequest { + return { modelId: "test-model", stream: false, options, context: { tools, messages } }; +} + +describe("bounded OpenAI Chat tool wire names (#4679)", () => { + test("bounds and restores the exact reported identity across request, replay, tool_choice, and response", async () => { + const declared = tool(LONG_NAMESPACE, REPORTED_NAME); + const originalWireName = namespacedToolName(LONG_NAMESPACE, REPORTED_NAME); + const replayMessages: OcxParsedRequest["context"]["messages"] = [ + { + role: "assistant", + content: [{ + type: "toolCall", + id: "call_replay", + namespace: LONG_NAMESPACE, + name: REPORTED_NAME, + arguments: {}, + }], + timestamp: 0, + }, + { role: "toolResult", toolCallId: "call_replay", toolName: REPORTED_NAME, content: "ok", timestamp: 1 }, + { role: "user", content: "Run it again", timestamp: 2 }, + ]; + const parsed = parsedWith([declared], { toolChoice: { name: REPORTED_NAME } }, replayMessages); + const adapter = createOpenAIChatAdapter(provider()); + const request = adapter.buildRequest(parsed, { + headers: new Headers(), + translatorBudget: createTestTranslatorBudget(), + }); + if (request instanceof Promise) throw new Error("OpenAI Chat request unexpectedly became async"); + const body = JSON.parse(request.body) as { + tools: Array<{ function: { name: string } }>; + messages: Array<{ tool_calls?: Array<{ function: { name: string } }> }>; + tool_choice: { function: { name: string } }; + }; + const alias = body.tools[0].function.name; + + expect(new TextEncoder().encode(originalWireName).byteLength).toBeGreaterThan(64); + expect(alias).not.toBe(originalWireName); + expect(alias).toMatch(/^[a-zA-Z0-9_-]{1,64}$/); + expect(new TextEncoder().encode(alias).byteLength).toBeLessThanOrEqual(64); + expect(body.messages.find(message => message.tool_calls)?.tool_calls?.[0].function.name).toBe(alias); + expect(body.tool_choice.function.name).toBe(alias); + + const events = await adapter.parseResponse!(new Response(JSON.stringify({ + choices: [{ + message: { tool_calls: [{ id: "call_echo", function: { name: alias, arguments: "{}" } }] }, + finish_reason: "tool_calls", + }], + })), createTestTranslatorBudget()); + expect(events.find(event => event.type === "tool_call_start")).toMatchObject({ + type: "tool_call_start", + id: "call_echo", + name: originalWireName, + }); + + const streamed: AdapterEvent[] = []; + const streamBody = `data: ${JSON.stringify({ + choices: [{ + delta: { tool_calls: [{ index: 0, id: "call_stream", function: { name: alias, arguments: "{}" } }] }, + finish_reason: "tool_calls", + }], + })}\n\ndata: [DONE]\n\n`; + for await (const event of adapter.parseStream( + new Response(streamBody), + createTestTranslatorBudget(), + )) streamed.push(event); + expect(streamed.find(event => event.type === "tool_call_start")).toMatchObject({ + type: "tool_call_start", + id: "call_stream", + name: originalWireName, + }); + + const responseRequest = parseRequest({ + model: "test-model", + input: "Use the tool", + tools: [{ + type: "namespace", + name: LONG_NAMESPACE, + tools: [{ type: "function", name: REPORTED_NAME, parameters: { type: "object" } }], + }], + }); + const maps = buildToolBridgeMaps(responseRequest); + const bridged = buildResponseJSON(events, "test-model", maps); + const call = (bridged.output as Record[])[0]; + if (!call) throw new Error("Expected a bridged function call"); + expect(call).toMatchObject({ + type: "function_call", + namespace: LONG_NAMESPACE, + name: REPORTED_NAME, + }); + + const replayed = parseRequest({ + model: "test-model", + tools: [{ + type: "namespace", + name: LONG_NAMESPACE, + tools: [{ type: "function", name: REPORTED_NAME, parameters: { type: "object" } }], + }], + input: [call], + }); + const replayedCall = replayed.context.messages + .flatMap(message => Array.isArray(message.content) ? message.content : []) + .find(part => part.type === "toolCall"); + expect(replayedCall).toMatchObject({ namespace: LONG_NAMESPACE, name: REPORTED_NAME }); + }); + + test("leaves names at or under 64 characters and ordinary bare names byte-identical", () => { + const exactly64 = tool("n".repeat(30), "x".repeat(32)); + const ordinary = tool("mcp__short", "read"); + const longBare = tool(undefined, "b".repeat(200)); + const registry = createOpenAIChatToolNameRegistry([exactly64, ordinary, longBare]); + + expect(new TextEncoder().encode(namespacedToolName(exactly64.namespace, exactly64.name)).byteLength).toBe(64); + expect(registry.alias(exactly64)).toBe(namespacedToolName(exactly64.namespace, exactly64.name)); + expect(registry.alias(ordinary)).toBe(namespacedToolName(ordinary.namespace, ordinary.name)); + expect(registry.alias(longBare)).toBe(longBare.name); + expect(namespacedToolName(undefined, longBare.name)).toBe(longBare.name); + + const adapter = createOpenAIChatAdapter(provider()); + const request = adapter.buildRequest(parsedWith([exactly64, ordinary, longBare]), { + headers: new Headers(), + translatorBudget: createTestTranslatorBudget(), + }); + if (request instanceof Promise) throw new Error("OpenAI Chat request unexpectedly became async"); + const body = JSON.parse(request.body) as { tools: Array<{ function: { name: string } }> }; + expect(body.tools.map(entry => entry.function.name)).toEqual([ + namespacedToolName(exactly64.namespace, exactly64.name), + namespacedToolName(ordinary.namespace, ordinary.name), + longBare.name, + ]); + }); + + test("bounds and restores a replay-only historical call absent from the current catalog", async () => { + const originalWireName = namespacedToolName(LONG_NAMESPACE, REPORTED_NAME); + const replayed = parseRequest({ + model: "test-model", + tools: [], + input: [ + { + type: "function_call", + call_id: "call_historical", + namespace: LONG_NAMESPACE, + name: REPORTED_NAME, + arguments: "{}", + }, + { type: "function_call_output", call_id: "call_historical", output: "ok" }, + { role: "user", content: "Continue" }, + ], + }); + expect(replayed.context.tools ?? []).toHaveLength(0); + + const adapter = createOpenAIChatAdapter(provider()); + const request = adapter.buildRequest(replayed, { + headers: new Headers(), + translatorBudget: createTestTranslatorBudget(), + }); + if (request instanceof Promise) throw new Error("OpenAI Chat request unexpectedly became async"); + const body = JSON.parse(request.body) as { + tools?: unknown; + messages: Array<{ tool_calls?: Array<{ function: { name: string } }> }>; + }; + const alias = body.messages.find(message => message.tool_calls)?.tool_calls?.[0].function.name; + if (!alias) throw new Error("Expected the historical tool call on replay"); + + expect(body.tools).toBeUndefined(); + expect(alias).not.toBe(originalWireName); + expect(alias).toMatch(/^[a-zA-Z0-9_-]{1,64}$/); + + const events = await adapter.parseResponse!(new Response(JSON.stringify({ + choices: [{ + message: { tool_calls: [{ id: "call_echo", function: { name: alias, arguments: "{}" } }] }, + finish_reason: "tool_calls", + }], + })), createTestTranslatorBudget()); + expect(events.find(event => event.type === "tool_call_start")).toMatchObject({ + type: "tool_call_start", + name: originalWireName, + }); + }); + + test("derives deterministic distinct aliases independent of catalog order", () => { + const catalog = [ + tool(LONG_NAMESPACE, REPORTED_NAME), + tool(LONG_NAMESPACE, OTHER_LONG_NAME), + tool(`${LONG_NAMESPACE}_other`, REPORTED_NAME), + ]; + const forward = createOpenAIChatToolNameRegistry(catalog); + const reverse = createOpenAIChatToolNameRegistry([...catalog].reverse()); + const forwardAliases = catalog.map(entry => forward.alias(entry)); + + expect(catalog.map(entry => reverse.alias(entry))).toEqual(forwardAliases); + expect(new Set(forwardAliases).size).toBe(catalog.length); + for (const alias of forwardAliases) expect(alias).toMatch(/^[a-zA-Z0-9_-]{1,64}$/); + + const longTool = catalog[0]!; + const identityAlias = createOpenAIChatToolNameRegistry([longTool]).alias(longTool); + const aliasShapedBareTool = tool(undefined, identityAlias); + const collisionCatalog = [longTool, aliasShapedBareTool]; + const collisionRegistry = createOpenAIChatToolNameRegistry(collisionCatalog); + const reversedCollisionRegistry = createOpenAIChatToolNameRegistry([...collisionCatalog].reverse()); + const reservedNameAlias = collisionRegistry.alias(aliasShapedBareTool); + expect(collisionRegistry.alias(longTool)).toBe(identityAlias); + expect(reversedCollisionRegistry.alias(longTool)).toBe(identityAlias); + expect(reservedNameAlias).not.toBe(identityAlias); + expect(reservedNameAlias).toMatch(/^ocx_[a-zA-Z0-9_-]{16}_[a-zA-Z0-9_-]{43}$/); + expect(collisionRegistry.restore(reservedNameAlias)).toBe(identityAlias); + }); + + test("aliases colliding identities but leaves their ambiguous replay spelling unchanged", () => { + const first = tool("a__b", "c"); + const second = tool("a", "b__c"); + const flattened = namespacedToolName(first.namespace, first.name); + expect(namespacedToolName(second.namespace, second.name)).toBe(flattened); + + const registry = createOpenAIChatToolNameRegistry([first, second]); + const reversed = createOpenAIChatToolNameRegistry([second, first]); + const firstAlias = registry.alias(first); + const secondAlias = registry.alias(second); + expect(firstAlias).not.toBe(flattened); + expect(secondAlias).not.toBe(flattened); + expect(firstAlias).not.toBe(secondAlias); + expect(reversed.alias(first)).toBe(firstAlias); + expect(reversed.alias(second)).toBe(secondAlias); + expect(registry.aliasWireName(flattened)).toBe(flattened); + + const adapter = createOpenAIChatAdapter(provider()); + const request = adapter.buildRequest(parsedWith([first, second], {}, [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_ambiguous", namespace: first.namespace, name: first.name, arguments: {} }], + timestamp: 0, + }, + { role: "user", content: "Continue", timestamp: 1 }, + ]), { + headers: new Headers(), + translatorBudget: createTestTranslatorBudget(), + }); + if (request instanceof Promise) throw new Error("OpenAI Chat request unexpectedly became async"); + const body = JSON.parse(request.body) as { + tools: Array<{ function: { name: string } }>; + messages: Array<{ tool_calls?: Array<{ function: { name: string } }> }>; + }; + expect(body.tools.map(entry => entry.function.name)).toEqual([firstAlias, secondAlias]); + expect(body.messages.find(message => message.tool_calls)?.tool_calls?.[0].function.name).toBe(flattened); + }); + + test("keeps shared naming untouched so Kiro and Google retain adapter-owned normalization", () => { + const original = namespacedToolName(LONG_NAMESPACE, REPORTED_NAME); + expect(original).toBe(`${LONG_NAMESPACE}__${REPORTED_NAME}`); + expect(new TextEncoder().encode(original).byteLength).toBeGreaterThan(64); + + const kiro = kiroToolName(original); + expect(kiro).not.toBe(original); + expect(kiro).toMatch(/_[0-9a-f]{8}$/); + expect(kiro.length).toBeLessThanOrEqual(64); + + const google = compileGoogleWireBody({ + tools: [{ functionDeclarations: [{ name: original, parameters: { type: "object" } }] }], + }); + const googleName = (google.body.tools as Array<{ + functionDeclarations: Array<{ name: string }>; + }>)[0].functionDeclarations[0].name; + expect(googleName).not.toBe(original); + expect(googleName).toMatch(/^[A-Za-z_][A-Za-z0-9_-]{0,63}$/); + expect(google.restoreToolName(googleName)).toBe(original); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 37b70134740..52d44eb91a0 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -822,6 +822,7 @@ "omp-path-contract.test.ts": "clients", "omp-yaml-source-inline-comments.test.ts": "clients", "openai-api-virtual-models.test.ts": "adapters/openai", + "openai-chat-bounded-tool-names.test.ts": "adapters/openai", "openai-chat-dangling-toolcalls.test.ts": "adapters/openai", "openai-chat-eof.test.ts": "adapters/openai", "openai-chat-hardening.test.ts": "adapters/openai", diff --git a/tests/providers/codebuddy-adapter.test.ts b/tests/providers/codebuddy-adapter.test.ts index 0da898ea829..76caabfae99 100644 --- a/tests/providers/codebuddy-adapter.test.ts +++ b/tests/providers/codebuddy-adapter.test.ts @@ -3,6 +3,7 @@ import { EventEmitter } from "node:events"; import { Readable, Writable } from "node:stream"; import type { ChildProcess } from "node:child_process"; import { buildArgs, buildChildEnv, createCodeBuddyAdapter, type SpawnFn } from "../../src/adapters/codebuddy/adapter"; +import { guardCodeBuddyScaffolding } from "../../src/adapters/codebuddy/scaffold-guard"; import { CODEBUDDY_CN_PROFILE, CODEBUDDY_GLOBAL_PROFILE, clearCodeBuddyBinaryCache } from "../../src/adapters/codebuddy/profiles"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; import { createTestTranslatorBudget } from "../helpers/translator-budget"; @@ -221,6 +222,236 @@ describe("codebuddy runTurn streams a headless turn", () => { expect(child.written.join("")).toContain('"text":"hello"'); }); + test("refuses a full-message DSML calls-and-invoke scaffold", async () => { + const leaked = "I'll inspect it.\n<||DSML|| calls>\n" + + "<||DSML|| invoke name=\"functions.exec\">\nsecret-command"; + const stdout = [ + enc.encode(`${JSON.stringify({ + type: "assistant", + message: { role: "assistant", content: [{ type: "text", text: leaked }] }, + })}\n`), + enc.encode('{"type":"result","subtype":"success","is_error":false}\n'), + ]; + const adapter = createCodeBuddyAdapter(provider(), { + spawn: () => fakeChild(stdout) as unknown as ChildProcess, + which: () => "/usr/bin/codebuddy", + killGraceMs: 20, + }); + + const events = await run(adapter, parsed()); + expect(events.filter(event => event.type === "text_delta")) + .toEqual([{ type: "text_delta", text: "I'll inspect it.\n" }]); + expect(events.some(event => event.type === "done")).toBe(false); + expect(events.at(-1)).toMatchObject({ + type: "error", + code: "vendor_scaffold_detected", + retryable: false, + status: 502, + }); + expect(JSON.stringify(events)).not.toContain("secret-command"); + }); + + test("detects a DSML control sequence split across streamed text deltas", async () => { + const frame = (text: string) => `${JSON.stringify({ + type: "stream_event", + event: { type: "content_block_delta", delta: { type: "text_delta", text } }, + })}\n`; + const stdout = [ + enc.encode(frame("Safe prefix.\n<||DS")), + enc.encode(frame("ML|| calls>\n<||DSML|| invoke name=\"funct")), + enc.encode(frame("ions.exec\">private-body")), + enc.encode('{"type":"result","subtype":"success","is_error":false}\n'), + ]; + const adapter = createCodeBuddyAdapter(provider(), { + spawn: () => fakeChild(stdout) as unknown as ChildProcess, + which: () => "/usr/bin/codebuddy", + killGraceMs: 20, + }); + + const events = await run(adapter, parsed()); + expect(events.filter(event => event.type === "text_delta")) + .toEqual([{ type: "text_delta", text: "Safe prefix.\n" }]); + expect(events.at(-1)).toMatchObject({ type: "error", code: "vendor_scaffold_detected" }); + expect(events.some(event => event.type === "done")).toBe(false); + expect(JSON.stringify(events)).not.toContain("private-body"); + }); + + test("refuses DSML calls-and-invoke scaffolding from reasoning independently", async () => { + const stdout = [ + enc.encode(`${JSON.stringify({ + type: "stream_event", + event: { + type: "content_block_delta", + delta: { + type: "thinking_delta", + thinking: "Safe thought.\n<||DSML|| calls>\n" + + "<||DSML|| invoke name=\"functions.exec\">private-body", + }, + }, + })}\n`), + enc.encode('{"type":"result","subtype":"success","is_error":false}\n'), + ]; + const adapter = createCodeBuddyAdapter(provider(), { + spawn: () => fakeChild(stdout) as unknown as ChildProcess, + which: () => "/usr/bin/codebuddy", + killGraceMs: 20, + }); + + const events = await run(adapter, parsed()); + expect(events.filter(event => event.type === "thinking_delta")) + .toEqual([{ type: "thinking_delta", thinking: "Safe thought.\n" }]); + expect(events.at(-1)).toMatchObject({ + type: "error", + code: "vendor_scaffold_detected", + retryable: false, + }); + expect(events.some(event => event.type === "done")).toBe(false); + expect(JSON.stringify(events)).not.toContain("private-body"); + }); + + test("delivers a lone discussed DSML calls tag unchanged", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + const answer = "The string <||DSML|| calls> names the calls container."; + + guarded({ type: "text_delta", text: answer }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "text_delta", text: answer }, + { type: "done", stopReason: "stop" }, + ]); + }); + + test("delivers quoted and inline-code DSML literals unchanged", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + const answer = "\"<||DSML|| calls>\"\n" + + "\"<||DSML|| invoke name=\\\"functions.exec\\\">\"\n" + + "Use `<||DSML|| calls>` when discussing the literal.\n" + + "> <||DSML|| calls>\n> <||DSML|| invoke name=\"functions.exec\">"; + + guarded({ type: "text_delta", text: answer }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "text_delta", text: answer }, + { type: "done", stopReason: "stop" }, + ]); + }); + + test("delivers a fenced DSML source example unchanged across deltas", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + const first = "```text\n<||DSML|| calls>\n"; + const second = "<||DSML|| invoke name=\"functions.exec\">\n```"; + + guarded({ type: "text_delta", text: first }); + guarded({ type: "text_delta", text: second }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "text_delta", text: first }, + { type: "text_delta", text: second }, + { type: "done", stopReason: "stop" }, + ]); + }); + + test("delivers source strings containing both DSML literals unchanged", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + const answer = "const calls = '<||DSML|| calls>';\n" + + "const invoke = '<||DSML|| invoke name=\"functions.exec\">';"; + + guarded({ type: "text_delta", text: answer }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "text_delta", text: answer }, + { type: "done", stopReason: "stop" }, + ]); + }); + + test("delivers an unquoted invoke line when no calls container precedes it", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + const answer = "<||DSML|| invoke name=\"functions.exec\">"; + + guarded({ type: "text_delta", text: answer }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "text_delta", text: answer }, + { type: "done", stopReason: "stop" }, + ]); + }); + + test("releases a lone control-line candidate at the terminal", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + + guarded({ type: "text_delta", text: "<||DSML|| calls>" }); + expect(events).toEqual([]); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "text_delta", text: "<||DSML|| calls>" }, + { type: "done", stopReason: "stop" }, + ]); + }); + + test("queues later events behind an unresolved marker prefix", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + + guarded({ type: "thinking_delta", thinking: "<" }); + guarded({ type: "text_delta", text: "Hello" }); + guarded({ type: "tool_call_start", id: "call_1", name: "exec" }); + expect(events).toEqual([]); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "thinking_delta", thinking: "<" }, + { type: "text_delta", text: "Hello" }, + { type: "tool_call_start", id: "call_1", name: "exec" }, + { type: "done", stopReason: "stop" }, + ]); + }); + + test("keeps an existing pending slot when its channel receives an empty delta", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + + guarded({ type: "thinking_delta", thinking: "<" }); + guarded({ type: "text_delta", text: "Hello" }); + guarded({ type: "thinking_delta", thinking: "" }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "thinking_delta", thinking: "<" }, + { type: "text_delta", text: "Hello" }, + { type: "done", stopReason: "stop" }, + ]); + }); + + test("moves a replaced pending marker prefix to its new arrival position", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + + guarded({ type: "thinking_delta", thinking: "<" }); + guarded({ type: "text_delta", text: "<" }); + guarded({ type: "thinking_delta", thinking: "not marker\n<" }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "thinking_delta", thinking: "<" }, + { type: "text_delta", text: "<" }, + { type: "thinking_delta", thinking: "not marker\n" }, + { type: "thinking_delta", thinking: "<" }, + { type: "done", stopReason: "stop" }, + ]); + }); + test("region isolation: the global adapter never spawns with the CN environment", async () => { let seenEnv: NodeJS.ProcessEnv | undefined; const spawn: SpawnFn = (_cmd, _args, opts) => { seenEnv = opts.env as NodeJS.ProcessEnv; return fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]) as unknown as ChildProcess; };