diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index a0c7e5d9cd7..e570db59446 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1392,7 +1392,7 @@ OpenCodex provides official adapter support for Qoder through the `qoder` (Globa - **Token usage:** Qoder CLI reports zero token counts unless its usage switch is on, so each preset turns it on in its scoped CLI child environment: `QODER_EXPOSE_TOKEN_USAGE=true` for Global and `QODERCN_EXPOSE_TOKEN_USAGE=true` for CN, which reads only its own prefix. - **Tool Ownership:** The CLI runs single-turn `stream-json` with `--tools ""`, `--strict-mcp-config`, setting sources disabled, and session persistence disabled, so Codex keeps exclusive tool ownership. Client-declared tools are exposed through an isolated request-scoped MCP server. - **Output guard:** If the CLI echoes OpenCodex tool-call/result replay envelopes into assistant text, the turn fails with `vendor_scaffold_detected` (502) instead of returning the envelope as a successful answer, and neither reasoning nor tool calls following the envelope are forwarded. Reasoning starts a fresh replay-text block, so earlier prose or code fences cannot hide an envelope in later text. If the CLI's turn already ended with its own error, that error is reported instead, still without the envelope. The proxy does not retry that failure automatically. Ordinary prose and closed fenced examples follow the shared envelope recognizer; an unverified overflowing fenced example is refused. Assistant text preserves Unicode characters split across CLI frames; unpaired surrogates are replaced with U+FFFD. -- **Image Input:** Qoder Global accepts image input through the Qoder CLI. OpenCodex forwards supported image inputs that reach the Global adapter to the CLI without maintaining a per-model vision capability allowlist there. Image compatibility and any model-specific preprocessing or routing are handled by Qoder. OpenCodex does not use the static discovery fallback roster to classify per-model vision capability. Qoder CN keeps its existing sidecar classification, and any residual raw-image request that reaches the CN adapter is rejected as `unsupported_input_modality`. OpenCodex serializes history before the current turn, but Qoder does not persist sessions, so prior images are replayed on later turns. In observed multi-turn image conversations, replayed images can become unreliably associated or effectively ordered relative to the historical text they originally accompanied. +- **Image Input:** Global continues forwarding supported images through the shared projection without a per-model vision allowlist. CN retains its vision-sidecar defaults. Its raw-image route admits only one canonical base64 PNG of at most 1 MiB decoded in a single user turn with `DeepSeek-Flash` or `Qwen3.8-Flash`. Use `qoderclicn 1.1.70`: [issue #7516](https://github.com/lidge-jun/opencodex/issues/7516) reports Windows 11 x64 live success for those models on both `--attachment` and inline `--input-format stream-json --output-format stream-json` stdin; OpenCodex uses only stdin. The adapter validates base64 length/padding, decoded PNG signature and basic IHDR fields before CLI discovery; it does not decompress pixels or validate every PNG chunk/CRC. The 1 MiB ceiling is an OpenCodex resource policy, not a verified vendor maximum: the reported live evidence covers a 96×96 PNG, whose RGBA raster is only 36 KiB. Larger inputs require separate verification. Other CLI versions have not been verified, and the adapter does not probe the installed version. CN refuses raw URL images, other image formats/models, multiple images, tool-result images and image-bearing history/multi-turn replay before starting the CLI with `unsupported_input_modality`; use the vision sidecar for those inputs. Injected CLI tests cover serialization and cancellation without claiming live upstream support. Qoder's supported projection keeps image blocks in conversation order and marks unsupported references as `[image omitted: unsupported reference]`. Global stateless replay can still associate historical images unreliably with their original text; CN does not enable that unverified replay surface. - **PAT recovery:** For both Qoder presets, a multi-key API-key pool can switch to another PAT on a supported authentication rejection or account-level quota exhaustion, but only before response content or an event that makes replay unsafe. Recovery requires Qoder-specific classification and respects credential-hop and send budgets; an HTTP 401/429 or quota message alone is not permission to replay. Single-key configurations do not rotate. Sharing the CLI runner does not enable this API-key recovery for CodeBuddy or self-authenticated Claude CLI. - **Quota:** No public quota API is used, so totals and reset times are unavailable. Codes 110 (account daily limit) and 118 (personal Credits exhaustion) report `insufficient_quota` and permit consideration of PAT recovery. Other supported quota codes also report `insufficient_quota` but do not authorize it. Numeric error codes take precedence: an unrecognized numeric code does not fall back to quota or authentication text. Without a numeric code, only the supported account-daily message beginning “You've reached your daily usage limit for Chat”, with no team/model/shared qualifier, grants quota recovery; generic credit-limit text does not. Only quota failures eligible for PAT recovery enter key cooldown handling. When such a failure has no reset time, the failed key uses the existing default cooldown; nonrecoverable quota classifications do not create a cooldown through this path, and no next-day reset is inferred. - **Operators:** Qoder Global is operated by BRIGHT ZENITH PRIVATE LIMITED under the [product service terms](https://qoder.com/product-service); Qoder CN by 通义云启(杭州)信息技术有限公司 with Alibaba Cloud. Verify `ocx provider test qoder` (or `qoder-cn`) after configuring. diff --git a/scripts/test-layout/layout-adapters.json b/scripts/test-layout/layout-adapters.json index af26b1e3d69..49d42e432b1 100644 --- a/scripts/test-layout/layout-adapters.json +++ b/scripts/test-layout/layout-adapters.json @@ -1,4 +1,7 @@ { + "qoder-image-input.test.ts": "providers", + "qoder-cn-image-validation.test.ts": "providers", + "coding-agent-history-projection.test.ts": "adapters", "cursor-native-grep-globs.test.ts": "providers/cursor", "tool-identity-hash.test.ts": "adapters", "eventstream-fragmentation.test.ts": "responses", diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts index 69686301b19..f280844d4b7 100644 --- a/src/adapters/coding-agent/protocol.ts +++ b/src/adapters/coding-agent/protocol.ts @@ -731,11 +731,16 @@ function imagePart(imageUrl: string): WireContentPart | undefined { return undefined; } -export function formatMessageForHistory(message: OcxMessage, vendorNameByWire?: ReadonlyMap): string { +/** Render replay prose; only Qoder opts into unsupported-reference history markers. */ +export function formatMessageForHistory(message: OcxMessage, vendorNameByWire?: ReadonlyMap, markUnsupportedImages = false): string { if (message.role === "user") { const text = typeof message.content === "string" ? message.content - : message.content.map(p => (p.type === "text" || p.type === "document" ? p.text : `[${p.type}]`)).join("\n"); + : message.content.map(p => { + if (p.type === "text" || p.type === "document") return p.text; + if (markUnsupportedImages && p.type === "image" && !imagePart(p.imageUrl)) return "[image omitted: unsupported reference]"; + return `[${p.type}]`; + }).join("\n"); return `USER:\n${text}`; } if (message.role === "assistant") { @@ -758,7 +763,11 @@ export function formatMessageForHistory(message: OcxMessage, vendorNameByWire?: if (message.role === "toolResult") { const text = typeof message.content === "string" ? message.content - : message.content.map(p => (p.type === "text" || p.type === "document" ? p.text : "[image]")).join(""); + : message.content.map(p => { + if (p.type === "text" || p.type === "document") return p.text; + if (markUnsupportedImages && p.type === "image" && !imagePart(p.imageUrl)) return "[image omitted: unsupported reference]"; + return "[image]"; + }).join(""); const status = message.isError ? " (error)" : ""; return `TOOL RESULT (call_id: ${message.toolCallId})${status}:\n${text}`; } @@ -774,7 +783,7 @@ export function formatMessageForHistory(message: OcxMessage, vendorNameByWire?: */ export function buildInputLines( message: OcxMessage, - options: { vendorNameByWire?: ReadonlyMap } = {}, + options: { vendorNameByWire?: ReadonlyMap; markUnsupportedImages?: boolean } = {}, ): string[] { if (message.role === "developer") return []; @@ -788,6 +797,7 @@ export function buildInputLines( else if (part.type === "image") { const image = imagePart(part.imageUrl); if (image) content.push(image); + else if (options.markUnsupportedImages) content.push(textPart("[image omitted: unsupported reference]")); } else if (part.type === "document") { content.push(textPart(part.text)); } else { @@ -796,7 +806,7 @@ export function buildInputLines( } } } else { - const formatted = formatMessageForHistory(message, options.vendorNameByWire); + const formatted = formatMessageForHistory(message, options.vendorNameByWire, options.markUnsupportedImages); if (formatted) content.push(textPart(formatted)); } @@ -832,7 +842,7 @@ export function buildSystemPrompt(parsed: OcxParsedRequest): string | undefined */ export function buildConversationInput( parsed: OcxParsedRequest, - options: { maxHistoryChars?: number; vendorNameByWire?: ReadonlyMap } = {}, + options: { maxHistoryChars?: number; vendorNameByWire?: ReadonlyMap; markUnsupportedImages?: boolean } = {}, ): string[] { const nonDev = parsed.context.messages.filter(m => m.role !== "developer"); if (nonDev.length === 0) { @@ -840,7 +850,7 @@ export function buildConversationInput( } if (nonDev.length === 1 && nonDev[0]!.role === "user") { - return buildInputLines(nonDev[0]!, { vendorNameByWire: options.vendorNameByWire }); + return buildInputLines(nonDev[0]!, options); } // Multi-turn conversation or history with tool results: @@ -911,14 +921,14 @@ export function buildConversationInput( const status = currentMessage.isError ? " (error)" : ""; currentRequestText = `TOOL RESULT (call_id: ${currentMessage.toolCallId})${status}:\n${text}\n\nPlease proceed based on the above tool result.`; } else { - currentRequestText = formatMessageForHistory(currentMessage, options.vendorNameByWire); + currentRequestText = formatMessageForHistory(currentMessage, options.vendorNameByWire, options.markUnsupportedImages); } const imageBlocks: WireContentPart[] = [...historyImageBlocks, ...currentImageBlocks]; const maxHistoryChars = options.maxHistoryChars ?? MAX_PROJECTED_HISTORY_CHARS; let historyText = historyMessages - .map(msg => formatMessageForHistory(msg, options.vendorNameByWire)) + .map(msg => formatMessageForHistory(msg, options.vendorNameByWire, options.markUnsupportedImages)) .filter(Boolean) .join("\n\n"); if (historyText.length > maxHistoryChars) { diff --git a/src/adapters/qoder/adapter.ts b/src/adapters/qoder/adapter.ts index 188c29dfb2b..3da6e0407cd 100644 --- a/src/adapters/qoder/adapter.ts +++ b/src/adapters/qoder/adapter.ts @@ -8,6 +8,7 @@ import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps, type CodingAge import { QODER_PROFILES, resolveQoderProfile, type QoderProfile } from "./profiles"; import { QoderScaffoldFilter, QODER_SCAFFOLD_ERROR_CODE, qoderScaffoldErrorMessage } from "./scaffold-guard"; import { ToolEnvelopeEchoFilter } from "../../lib/tool-envelope-echo-filter"; +import { buildQoderConversationInput, qoderCnImageInputSupported } from "./image-input"; export type QoderAdapterDeps = CodingAgentDeps; @@ -275,21 +276,13 @@ export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapt yield { type: "error", message: "Qoder adapter uses runTurn; the fetch/parseStream path is disabled." }; }, async runTurn(parsed, incoming, emit): Promise { - if (resolveQoderProfile(provider.baseUrl)?.region === "cn") { - const hasImage = parsed.context.messages.some(message => - Array.isArray(message.content) && message.content.some(part => part.type === "image"), - ); - if (hasImage) { - emit({ - type: "error", - message: "Qoder image input is not enabled because the CLI provider route has no verified multimodal contract.", - status: 400, - errorType: "invalid_request_error", - code: "unsupported_input_modality", - retryable: false, - }); - return; - } + if (resolveQoderProfile(provider.baseUrl)?.region === "cn" && !qoderCnImageInputSupported(parsed)) { + emit({ + type: "error", status: 400, errorType: "invalid_request_error", + code: "unsupported_input_modality", retryable: false, + message: "Qoder CN raw-image routing is verified only with qoderclicn 1.1.70, DeepSeek-Flash or Qwen3.8-Flash, and one base64 PNG in a single user turn on stream-json stdin. URL, multiple-image, tool-result and history replay surfaces remain unverified; use the vision sidecar for those inputs.", + }); + return; } let catalog: ReturnType; try { @@ -325,6 +318,7 @@ export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapt const system = buildSystemPrompt(parsed); await runCodingAgentTurn({ profiles: QODER_PROFILES, + conversationInput: buildQoderConversationInput, classifyFailure: classifyQoderFailure, provider, parsed, diff --git a/src/adapters/qoder/image-input.ts b/src/adapters/qoder/image-input.ts new file mode 100644 index 00000000000..ea67a032e28 --- /dev/null +++ b/src/adapters/qoder/image-input.ts @@ -0,0 +1,52 @@ +import type { OcxParsedRequest } from "../../types"; +import { buildConversationInput } from "../coding-agent/protocol"; + +// Local resource policy: #7516 verifies a 96x96 PNG, not a vendor size maximum. +// 1 MiB leaves ample room over its 36 KiB RGBA raster while bounding stdin copies. +const MAX_CN_PNG_BYTES = 1024 * 1024; +const PNG_DATA_PREFIX = "data:image/png;base64,"; +const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); +const PNG_DEPTHS: Readonly> = { + 0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16], +}; + +function boundedPngDataUrl(imageUrl: string): boolean { + // Bound encoded input before slicing, scanning, decoding or making stdin copies. + if (!imageUrl.startsWith(PNG_DATA_PREFIX) + || imageUrl.length > PNG_DATA_PREFIX.length + 4 * Math.ceil(MAX_CN_PNG_BYTES / 3)) return false; + const data = imageUrl.slice(PNG_DATA_PREFIX.length); + if (!data.length || data.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) return false; + const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0; + if (data.length / 4 * 3 - padding > MAX_CN_PNG_BYTES) return false; + const bytes = Buffer.from(data, "base64"); + // Bun's decoder is permissive; round-trip also rejects nonzero padding bits. + if (bytes.toString("base64") !== data || bytes.length < 33 + || !bytes.subarray(0, 8).equals(PNG_SIGNATURE) + || bytes.readUInt32BE(8) !== 13 || bytes.readUInt32BE(12) !== 0x49484452) return false; + const width = bytes.readUInt32BE(16); + const height = bytes.readUInt32BE(20); + // Minimal IHDR validation only; this does not decompress or validate all chunks. + return width > 0 && width <= 0x7fffffff && height > 0 && height <= 0x7fffffff + && (PNG_DEPTHS[bytes[25]!] ?? []).includes(bytes[24]!) + && bytes[26] === 0 && bytes[27] === 0 && (bytes[28] === 0 || bytes[28] === 1); +} + +/** #7516 live evidence: qoderclicn 1.1.70, one user PNG on stream-json stdin. */ +export function qoderCnImageInputSupported(parsed: OcxParsedRequest): boolean { + const messages = parsed.context.messages; + const imageMessages = messages.filter(message => Array.isArray(message.content) + && message.content.some(part => part.type === "image")); + if (imageMessages.length === 0) return true; + const nonDev = messages.filter(message => message.role !== "developer"); + if (imageMessages.length !== 1 || nonDev.length !== 1 || imageMessages[0] !== nonDev[0] + || nonDev[0]!.role !== "user" + || !["DeepSeek-Flash", "Qwen3.8-Flash"].includes(parsed.modelId)) return false; + const content = nonDev[0]!.content; + if (!Array.isArray(content)) return false; + const images = content.filter(part => part.type === "image"); + return images.length === 1 && boundedPngDataUrl(images[0]!.imageUrl); +} + +/** Keep Qoder omission markers out of CodeBuddy's and Claude's replay formats. */ +export const buildQoderConversationInput: typeof buildConversationInput = (parsed, options = {}) => + buildConversationInput(parsed, { ...options, markUnsupportedImages: true }); diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 09fd2a3a145..44031798d7b 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -9,7 +9,7 @@ Combo target `metered` metadata and configured-key opt-in follow the [runtime co Devin combines only [consecutive same-ID tool results](adapters/registry.md#devin-consecutive-tool-results), using linear accumulation while preserving intervening message slots, image parts and error markers without mutating the parsed request. Claude CLI replay, client tool capture and usage admission follow [Claude CLI replay](providers/claude-cli-replay.md). Anthropic account pause, model routes, quota labels, and the separate `anthropic2` pool (its helpers, quota, reset grants and management surfaces) follow the [Anthropic account-pool contract](providers/anthropic-account-pool.md). Devin Messages follows the [per-turn output ordering contract](clients/claude-desktop.md#devin-messages-output-ordering), preserving late signatures before text/tools without changing Responses or Chat ordering. The DeepSeek Harness account provider follows the [DSH account contract](providers/dsh-account.md). WorkBuddy AI subscription authentication, catalog, display-only credit balances and inference follow the [WorkBuddy contract](providers/workbuddy.md). -API-key credit-balance refusals follow the [Anthropic billing recovery contract](providers/anthropic-key-billing.md). Managed native Anthropic serving UUID and observed CLI header continuity follow [native Messages](data-planes/protocol-paths.md#managed-native-messages); generated Responses retain the adapter's compatibility fingerprint. +API-key credit-balance refusals follow the [Anthropic billing recovery contract](providers/anthropic-key-billing.md). Managed native Anthropic serving UUID and observed CLI header continuity follow [native Messages](data-planes/protocol-paths.md#managed-native-messages); generated Responses retain the adapter's compatibility fingerprint. Qoder CN image admission and Qoder-only omission markers follow the [coding-agent image contract](providers/chat-compat.md#unmapped-modalities-are-recorded-not-dropped). Opus 5.5 commentary follows the [signed progress-update contract](providers/anthropic-progress.md). Per-account usage thresholds follow the [Anthropic account thresholds contract](providers/anthropic-account-thresholds.md). Routed Anthropic compaction preserves the client tool/system prefix and guards summary output under the [compaction contract](transports/responses-failover.md#compaction-routing-overrides); image normalization and HTTP 413 recovery retain their existing policy. A shared-quota Anthropic 429 or classified pre-output account 403 records the served account's cooldown even when the request has used its allowed retry sends. That final account remains excluded on the next request; combo target cooling for 429 is skipped only after the matching account cooldown is present. Exact revoked-token 401s persist generation-fenced reauthentication under [the Anthropic recovery contract](providers/anthropic-account-pool.md#revoked-oauth-access-token-recovery). diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index d9ed5799cae..0e1319b9c86 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -566,9 +566,9 @@ Chat-to-Responses projection still rejects audio/file parts, except user-content transport or URL fetch is added, and no client filename, payload, URL or metadata enters errors. Admission charges supported audio by decoded size at the conservative 256-bytes-per-token proxy, not an upstream token formula. -The shared coding-agent projection in `src/adapters/coding-agent/` (CodeBuddy, Qoder) carries supported tool-result image references as -real image blocks. Current tool-result prose marks supported images as `[image attached below]` and unsupported references as `[image omitted: unsupported reference]`; historical tool-result prose retains `[image]` while supported references are also emitted as image blocks. In multi-turn projection, image blocks follow conversation order — history before current. Vendor tool execution stays disabled on both -adapters. CodeBuddy refuses an unquoted, line-oriented full-width-bar DSML `calls` container followed by a named bare or namespaced 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 Global accepts image input through the Qoder CLI. OpenCodex forwards supported image inputs that reach the Global adapter in `src/adapters/qoder/` through the shared coding-agent projection without maintaining a per-model vision capability allowlist there. Image compatibility and any model-specific preprocessing or routing are handled by Qoder. OpenCodex does not use the static discovery fallback roster to classify per-model vision capability. Qoder CN keeps its sidecar classification and rejects residual raw-image requests that reach the adapter. OpenCodex serializes image blocks in conversation order, with history before the current turn. Because Qoder runs with `--no-session-persistence`, prior images are re-projected on later turns; in observed multi-turn image conversations, replayed images can become unreliably associated or effectively ordered relative to the historical text they originally accompanied, a known limitation of the current stateless Qoder projection path. +The shared coding-agent projection in `src/adapters/coding-agent/` carries supported tool-result images as real image blocks in conversation order, history before current. Current tool-result prose retains `[image attached below]` and `[image omitted: unsupported reference]`. Qoder alone opts into omission markers for single-user input and historical user/tool-result prose through `src/adapters/qoder/image-input.ts`; CodeBuddy retains its previous bytes (historical `[image]` and single-user unsupported-reference omission), and Claude keeps its stable replay builder. Vendor tool execution stays disabled. +CodeBuddy refuses an unquoted, line-oriented full-width-bar DSML `calls` container followed by a named bare or namespaced 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 Global continues forwarding supported images without a per-model vision allowlist. CN retains its sidecar classification. Its raw-image route admits only one canonical base64 PNG of at most 1 MiB decoded in one non-developer user message, on `DeepSeek-Flash` or `Qwen3.8-Flash`; developer images, URL references, other media types/models, multiple images, tool-result images and image-bearing multi-turn replay return a fixed non-retryable 400 `unsupported_input_modality` before CLI discovery/spawn. Before CLI discovery, admission bounds encoded length, validates base64 length/padding and round-trip decoding, then checks the PNG signature and basic IHDR fields (13-byte first chunk, positive PNG-range dimensions, legal depth/colour pair, compression/filter/interlace). It does not decompress pixels or validate all chunks/CRCs. Invalid or oversized images receive the same fixed error event; text-only CN turns remain unrestricted by this gate. +Issue #7516 reports live verification on Windows 11 x64 with `qoderclicn 1.1.70` for those two models and a single PNG, through both `--attachment` and inline `--input-format stream-json --output-format stream-json` stdin. OpenCodex uses only stdin; attachment flags are not added. The 1 MiB decoded ceiling is local resource policy, not a vendor maximum: the reported 96×96 PNG has a 36 KiB RGBA raster; larger upstream inputs remain unverified. This is reported evidence, not a fresh live test or a guarantee for other CLI versions; operators must use the verified version (no runtime version probe is added). Injected CLI tests prove serialization, omission markers, refusals and cancellation, not upstream image understanding. Global still replays historical images with `--no-session-persistence`, whose text/image association remains unverified across turns. The scaffold/replay output guards ([ADR-7517](../clients/qoder.md#qoder-assistant-text-replay-guard)) and selector ownership ([ADR-7512](../clients/qoder.md#qoder-settings-ownership)) remain independent. 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. Upstream API-key usage follows the [physical-attempt account attribution contract](../dashboard-and-usage.md#upstream-key-account-attribution), independently of subscription quota observations. diff --git a/structure/providers/claude-cli-replay.md b/structure/providers/claude-cli-replay.md index b316299c0e3..825029f3432 100644 --- a/structure/providers/claude-cli-replay.md +++ b/structure/providers/claude-cli-replay.md @@ -1,6 +1,6 @@ # Claude CLI Replay -`src/adapters/claude-cli/stable-replay.ts` supplies a Claude-only replay builder to the shared `src/adapters/coding-agent/turn.ts` runner through its `conversationInput` slot; CodeBuddy and Qoder keep the default projection. It emits one legal user frame with an immutable text block per message, using identical formatting for a current message and its later history form. History is trimmed at whole-message checkpoints. Developer messages remain in the system prompt; the cut skips orphan historical results and retains a current result's originating call. Checkpoints are at most 20k characters apart; the cut can also discard the boundary message and any leading orphan tool-result blocks. The soft cap counts history characters, excluding the current input and truncation notice; retaining the current tool group's originating call can exceed it. +`src/adapters/claude-cli/stable-replay.ts` supplies a Claude-only replay builder to the shared `src/adapters/coding-agent/turn.ts` runner through its `conversationInput` slot; CodeBuddy keeps the default projection; Qoder opts into its image-omission markers through the same slot. It emits one legal user frame with an immutable text block per message, using identical formatting for a current message and its later history form. History is trimmed at whole-message checkpoints. Developer messages remain in the system prompt; the cut skips orphan historical results and retains a current result's originating call. Checkpoints are at most 20k characters apart; the cut can also discard the boundary message and any leading orphan tool-result blocks. The soft cap counts history characters, excluding the current input and truncation notice; retaining the current tool group's originating call can exceed it. Qoder CN image admission and Qoder-only omission markers follow the [coding-agent image contract](../providers/chat-compat.md#unmapped-modalities-are-recorded-not-dropped). Canonicalization accepts only plain JSON, preserves arrays/literal values, and sorts object keys, including schema object keys and historical tool arguments. Instruction changes intentionally invalidate that prefix. Tools and schema object keys serialize deterministically, preserving catalog membership, MCP aliases, tool choice, and array order. Object-key sorting intentionally changes model-visible property order while preserving JSON values; integer-like keys follow JavaScript's numeric-key ordering. Invalid non-JSON history supplied by an internal caller fails at the runner's stdin projection as a 502, after launch, and the owned child is terminated. Ordinary HTTP JSON requests cannot carry cycles, accessors or undefined properties. diff --git a/tests/adapters/coding-agent-history-projection.test.ts b/tests/adapters/coding-agent-history-projection.test.ts new file mode 100644 index 00000000000..2e1a36f0d61 --- /dev/null +++ b/tests/adapters/coding-agent-history-projection.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test"; +import { buildConversationInput, buildInputLines, formatMessageForHistory } from "../../src/adapters/coding-agent/protocol"; +import type { OcxMessage, OcxParsedRequest } from "../../src/types"; + +const unsupported = { type: "image" as const, imageUrl: "ftp://example.test/image.png" }; +const user: OcxMessage = { role: "user", content: [{ type: "text", text: "inspect" }, unsupported], timestamp: 0 }; +const result: OcxMessage = { role: "toolResult", toolCallId: "call_1", content: [{ type: "text", text: "result" }, unsupported], timestamp: 1 }; + +test("non-Qoder single-message bytes retain the dev omission behavior", () => { + expect(buildInputLines(user)).toEqual(['{"type":"user","message":{"role":"user","content":[{"type":"text","text":"inspect"}]}}']); + expect(buildInputLines({ ...user, content: [unsupported] })).toEqual([]); +}); + +test("non-Qoder history bytes retain dev image markers", () => { + expect(formatMessageForHistory(user)).toBe("USER:\ninspect\n[image]"); + expect(formatMessageForHistory(result)).toBe("TOOL RESULT (call_id: call_1):\nresult[image]"); + const parsed: OcxParsedRequest = { modelId: "model", stream: false, options: {}, context: { messages: [user, result, { role: "user", content: "next", timestamp: 2 }] } }; + const text = "Prior conversation context:\n\nUSER:\ninspect\n[image]\n\nTOOL RESULT (call_id: call_1):\nresult[image]\n\nCurrent user request:\n\nnext"; + expect(buildConversationInput(parsed)).toEqual([JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } })]); +}); + +test("ordinary user, document, assistant, tool and supported-image history stays byte-identical", () => { + expect(formatMessageForHistory({ role: "user", content: [{ type: "document", text: "document", mediaType: "text/plain", data: "ZG9jdW1lbnQ=" }, { type: "image", imageUrl: "data:image/png;base64,AA==" }], timestamp: 0 })).toBe("USER:\ndocument\n[image]"); + expect(formatMessageForHistory({ role: "assistant", content: [{ type: "text", text: " response " }, { type: "thinking", thinking: " reason " }, { type: "toolCall", id: "c", name: "echo", arguments: { x: 1 } }], timestamp: 0 })).toBe('ASSISTANT:\nresponse\n[Thinking: reason]\n[Tool call: echo (call_id: c) with args: {"x":1}]'); + expect(buildInputLines({ role: "user", content: [{ type: "text", text: "inspect" }, { type: "image", imageUrl: "https://example.test/image.png" }], timestamp: 0 })).toEqual(['{"type":"user","message":{"role":"user","content":[{"type":"text","text":"inspect"},{"type":"image","source":{"type":"url","url":"https://example.test/image.png"}}]}}']); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index be96544b8b6..22e9af310a6 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -2,6 +2,9 @@ "claude-intercept-acl-barrier.test.ts": "claude-integration", "codex-catalog-heal-reanchor.test.ts": "codex-integration", "tool-identity-hash.test.ts": "adapters", + "qoder-image-input.test.ts": "providers", + "qoder-cn-image-validation.test.ts": "providers", + "coding-agent-history-projection.test.ts": "adapters", "claude-tool-loading-destinations.test.ts": "claude-integration", "server-combo-required-tool-contract.test.ts": "server", "responses-anthropic-compaction-prefix.test.ts": "responses", diff --git a/tests/providers/qoder-adapter.test.ts b/tests/providers/qoder-adapter.test.ts index a4dc66639e2..eca5cf4b1a1 100644 --- a/tests/providers/qoder-adapter.test.ts +++ b/tests/providers/qoder-adapter.test.ts @@ -605,27 +605,6 @@ describe("qoder adapter", () => { expect(isModelVisionSidecarConsumer(global, "unknown-live-id")).toBe(false); }); - test("keeps residual Qoder CN image input rejected before spawn", async () => { - let spawned = 0; - const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.cn" }), { - which: () => "/bin/qodercn", - spawn: () => { - spawned++; - return fakeChild([]); - }, - }); - const request = parsed({ context: { messages: [{ role: "user", content: [{ type: "image", imageUrl: "data:image/png;base64,AA==" }], timestamp: 0 }] } }); - const events: AdapterEvent[] = []; - await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); - expect(spawned).toBe(0); - expect(events[0]).toMatchObject({ - type: "error", - status: 400, - code: "unsupported_input_modality", - retryable: false, - }); - }); - test("forwards Qoder Global raw image input through the shared coding-agent projection", async () => { let spawned = 0; let stdin = ""; diff --git a/tests/providers/qoder-cn-image-validation.test.ts b/tests/providers/qoder-cn-image-validation.test.ts new file mode 100644 index 00000000000..57d12a9d126 --- /dev/null +++ b/tests/providers/qoder-cn-image-validation.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { createQoderAdapter } from "../../src/adapters/qoder/adapter"; +import { qoderCnImageInputSupported } from "../../src/adapters/qoder/image-input"; +import { clearQoderBinaryCache } from "../../src/adapters/qoder/profiles"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +// A complete 1x1 PNG, including IDAT and IEND, rather than a MIME-only fixture. +const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=", "base64"); +const ceiling = 1024 * 1024; // OpenCodex policy, not a verified vendor maximum. +const dataUrl = (bytes: Uint8Array) => `data:image/png;base64,${Buffer.from(bytes).toString("base64")}`; +const request = (imageUrl: string): OcxParsedRequest => ({ + modelId: "DeepSeek-Flash", stream: true, options: {}, + context: { messages: [{ role: "user", content: [{ type: "image", imageUrl }], timestamp: 0 }] }, +}); + +function changedHeader(offset: number, value: number): Buffer { + const bytes = Buffer.from(png); + bytes[offset] = value; + return bytes; +} + +// Insert a valid ancillary tEXt chunk before IEND to exercise exact decoded sizes. +function sizedPng(size: number): Buffer { + const chunk = Buffer.alloc(size - png.length, 0x20); + chunk.writeUInt32BE(chunk.length - 12, 0); + chunk.write("tEXt", 4, "ascii"); + chunk.write("Note\0", 8, "ascii"); + let crc = 0xffffffff; + for (const byte of chunk.subarray(4, -4)) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + chunk.writeUInt32BE((crc ^ 0xffffffff) >>> 0, chunk.length - 4); + return Buffer.concat([png.subarray(0, -12), chunk, png.subarray(-12)]); +} + +beforeEach(() => clearQoderBinaryCache()); + +describe("Qoder CN decoded PNG admission", () => { + test.each([ + ["incomplete quartet", "data:image/png;base64,A"], + ["excess padding", "data:image/png;base64,AAAA=="], + ["missing padding", dataUrl(png).replace(/=+$/, "")], + ["nonzero padding bits", dataUrl(png).replace(/I=$/, "J=")], + ["interior padding", "data:image/png;base64,AA==AAAA"], + ["invalid alphabet", "data:image/png;base64,AA-_"], + ["whitespace", `${dataUrl(png)}\n`], + ["encoded text", dataUrl(Buffer.from("not a PNG"))], + ["JPEG labelled PNG", dataUrl(Uint8Array.from([0xff, 0xd8, 0xff, 0xe0, 0, 16, 0x4a, 0x46, 0x49, 0x46]))], + ["truncated signature", dataUrl(png.subarray(0, 7))], + ["truncated IHDR", dataUrl(png.subarray(0, 32))], + ["wrong first chunk", dataUrl(changedHeader(12, 0x4a))], + ["non-ASCII IHDR alias", dataUrl(changedHeader(12, 0xc9))], + ["wrong IHDR length", dataUrl(changedHeader(11, 12))], + ["zero width", dataUrl(changedHeader(19, 0))], + ["zero height", dataUrl(changedHeader(23, 0))], + ["width beyond PNG range", dataUrl(changedHeader(16, 0x80))], + ["height beyond PNG range", dataUrl(changedHeader(20, 0x80))], + ["invalid bit depth", dataUrl(changedHeader(24, 3))], + ["invalid colour type", dataUrl(changedHeader(25, 1))], + ["invalid compression", dataUrl(changedHeader(26, 1))], + ["invalid filter", dataUrl(changedHeader(27, 1))], + ["invalid interlace", dataUrl(changedHeader(28, 2))], + ["one byte above ceiling", dataUrl(sizedPng(ceiling + 1))], + ["review's 21 MiB payload", dataUrl(sizedPng(21 * 1024 * 1024))], + ])("rejects %s with the fixed event before discovery and stdin", async (_name, imageUrl) => { + let discoveries = 0; + let spawns = 0; + const adapter = createQoderAdapter({ adapter: "qoder", baseUrl: "https://qoder.cn", apiKey: "test-pat" } as OcxProviderConfig, { + which: () => { discoveries++; return "/bin/qoderclicn"; }, + spawn: () => { spawns++; throw new Error("invalid image reached spawn"); }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request(imageUrl), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(discoveries).toBe(0); + expect(spawns).toBe(0); + expect(events).toEqual([{ + type: "error", status: 400, errorType: "invalid_request_error", code: "unsupported_input_modality", retryable: false, + message: "Qoder CN raw-image routing is verified only with qoderclicn 1.1.70, DeepSeek-Flash or Qwen3.8-Flash, and one base64 PNG in a single user turn on stream-json stdin. URL, multiple-image, tool-result and history replay surfaces remain unverified; use the vision sidecar for those inputs.", + }]); + }); + + test("admits a real tiny PNG and the exact local decoded-size ceiling", () => { + expect(qoderCnImageInputSupported(request(dataUrl(png)))).toBe(true); + expect(qoderCnImageInputSupported(request(dataUrl(sizedPng(ceiling))))).toBe(true); + expect(qoderCnImageInputSupported(request(dataUrl(sizedPng(ceiling - 1))))).toBe(true); + }); +}); diff --git a/tests/providers/qoder-image-input.test.ts b/tests/providers/qoder-image-input.test.ts new file mode 100644 index 00000000000..1b683682e93 --- /dev/null +++ b/tests/providers/qoder-image-input.test.ts @@ -0,0 +1,374 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { PassThrough, Readable, Writable } from "node:stream"; +import type { ChildProcess } from "node:child_process"; +import { createQoderAdapter } from "../../src/adapters/qoder/adapter"; +import { buildQoderConversationInput as buildConversationInput } from "../../src/adapters/qoder/image-input"; +import { clearQoderBinaryCache } from "../../src/adapters/qoder/profiles"; +import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +const enc = new TextEncoder(); +const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="; +beforeEach(() => clearQoderBinaryCache()); + +function provider(overrides: Partial = {}): OcxProviderConfig { + return { adapter: "qoder", baseUrl: "https://qoder.com", apiKey: "qoder-pat", reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], ...overrides } as OcxProviderConfig; +} + +function parsed(overrides: Partial = {}): OcxParsedRequest { + return { modelId: "Qwen3.8-Max", stream: true, options: {}, context: { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, ...overrides } as OcxParsedRequest; +} + +function fakeChild(frames: string[], options: { parked?: boolean; exitCode?: number; stderr?: string } = {}): ChildProcess { + const child = new EventEmitter() as ChildProcess & { killed: boolean; exitCode: number | null }; + const stdout = new PassThrough(); + for (const frame of frames) stdout.write(enc.encode(frame)); + if (!options.parked) stdout.end(); + child.stdout = stdout; + child.stderr = Readable.from(options.stderr ? [options.stderr] : []); + child.stdin = new Writable({ write(_chunk, _encoding, callback) { callback(); } }); + child.killed = false; + child.exitCode = null; + child.kill = () => { + child.killed = true; + child.exitCode = 0; + queueMicrotask(() => { + stdout.destroy(); + child.emit("close", 0); + }); + return true; + }; + if (!options.parked) { + setTimeout(() => { child.exitCode = options.exitCode ?? 0; child.emit("close", child.exitCode); }, 2); + } + return child; +} + +describe("Qoder image input", () => { + const png = { type: "image" as const, imageUrl: `data:image/png;base64,${tinyPng}` }; + const user = (content: Extract["content"]): OcxMessage => ({ role: "user", content, timestamp: 0 }); + const assistant = { role: "assistant" as const, content: [{ type: "text" as const, text: "response" }], timestamp: 1 }; + + test.each([ + ["URL", "DeepSeek-Flash", [user([{ type: "image", imageUrl: "https://example.test/a.png" }])]], + ["unsupported scheme", "DeepSeek-Flash", [user([{ type: "image", imageUrl: "ftp://example.test/a.png" }])]], + ["other media type", "DeepSeek-Flash", [user([{ type: "image", imageUrl: "data:image/jpeg;base64,AA==" }])]], + ["empty base64", "DeepSeek-Flash", [user([{ type: "image", imageUrl: "data:image/png;base64," }])]], + ["multiple images", "DeepSeek-Flash", [user([png, png])]], + ["unverified model", "Qwen3.8-Max", [user([png])]], + ["history image replay", "DeepSeek-Flash", [user([png]), assistant, user("next")]], + ["multi-turn image", "DeepSeek-Flash", [user("first"), assistant, user([png])]], + ["tool-result image", "DeepSeek-Flash", [user("inspect"), assistant, { role: "toolResult", toolCallId: "call_1", content: [png], timestamp: 2 }]], + ["developer image", "DeepSeek-Flash", [{ role: "developer", content: [png], timestamp: 0 }, user("inspect")]], + ] as const)("CN refuses unverified %s before CLI discovery or spawn", async (_surface, modelId, messages) => { + let discovered = 0; + let spawned = 0; + const events: AdapterEvent[] = []; + const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.cn" }), { + which: () => { discovered++; return "/bin/qoderclicn"; }, + spawn: () => { spawned++; return fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); }, + }); + await adapter.runTurn!(parsed({ modelId, context: { messages: [...messages] } }), + { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", status: 400, code: "unsupported_input_modality", retryable: false }); + expect(events[0]).toMatchObject({ message: expect.stringContaining("qoderclicn 1.1.70") }); + expect(JSON.stringify(events)).not.toContain("example.test"); + expect(discovered).toBe(0); + expect(spawned).toBe(0); + }); + + test.each(["DeepSeek-Flash", "Qwen3.8-Flash"])("CN forwards the verified single-PNG contract for %s", async modelId => { + let stdin = ""; + const events: AdapterEvent[] = []; + const child = fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); + child.stdin = new Writable({ write(chunk, _encoding, callback) { stdin += chunk.toString(); callback(); } }); + const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.cn" }), { which: () => "/bin/qoderclicn", spawn: () => child }); + await adapter.runTurn!(parsed({ modelId, context: { messages: [user([{ type: "text", text: "colour?" }, png])] } }), + { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(JSON.parse(stdin.trim()).message.content).toEqual([ + { type: "text", text: "colour?" }, { type: "image", source: { type: "base64", media_type: "image/png", data: tinyPng } }, + ]); + expect(events.at(-1)).toMatchObject({ type: "done" }); + }); + + test("cancellation prevents a verified CN image from spawning", async () => { + let spawned = 0; + const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.cn" }), { + which: () => "/bin/qoderclicn", spawn: () => { spawned++; return fakeChild([]); }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(parsed({ modelId: "DeepSeek-Flash", context: { messages: [user([png])] } }), + { headers: new Headers(), abortSignal: AbortSignal.abort(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(spawned).toBe(0); + expect(events.at(-1)).toMatchObject({ type: "error", message: expect.stringContaining("aborted") }); + }); + + test("forwards Qoder CN raw image input through the shared coding-agent projection", async () => { + let spawned = 0; + let stdin = ""; + const child = fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); + child.stdin = new Writable({ + write(chunk, _encoding, callback) { + stdin += chunk.toString(); + callback(); + }, + }); + const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.cn" }), { + which: () => "/bin/qodercn", + spawn: () => { + spawned++; + return child; + }, + }); + const request = parsed({ modelId: "DeepSeek-Flash", context: { messages: [user([png])] } }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(spawned).toBe(1); + const wire = JSON.parse(stdin.trim()); + expect(wire.message.content).toContainEqual({ + type: "image", + source: { type: "base64", media_type: "image/png", data: tinyPng }, + }); + expect(events.at(-1)).toMatchObject({ type: "done" }); + }); + + test("shared projection carries multiple images and preserves history ordering", async () => { + let spawned = 0; + let stdin = ""; + const child = fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); + child.stdin = new Writable({ + write(chunk, _encoding, callback) { + stdin += chunk.toString(); + callback(); + }, + }); + const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.com" }), { + which: () => "/bin/qoder", + spawn: () => { + spawned++; + return child; + }, + }); + const request = parsed({ + context: { + messages: [ + { role: "user", content: [{ type: "image", imageUrl: "data:image/png;base64,HIST==" }], timestamp: 0 }, + { role: "assistant", content: [{ type: "text", text: "I see the first image." }], timestamp: 1 }, + { + role: "user", + content: [ + { type: "text", text: "Look at these two:" }, + { type: "image", imageUrl: "data:image/jpeg;base64,CURR1==" }, + { type: "image", imageUrl: "data:image/png;base64,CURR2==" }, + ], + timestamp: 2, + }, + ], + }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(spawned).toBe(1); + const wire = JSON.parse(stdin.trim()); + const images = wire.message.content.filter((part: { type: string }) => part.type === "image"); + expect(images).toHaveLength(3); + expect(wire.message.content[0].text).toContain("ASSISTANT:\nI see the first image."); + expect(images[0]).toEqual({ type: "image", source: { type: "base64", media_type: "image/png", data: "HIST==" } }); + expect(images[1]).toEqual({ type: "image", source: { type: "base64", media_type: "image/jpeg", data: "CURR1==" } }); + expect(images[2]).toEqual({ type: "image", source: { type: "base64", media_type: "image/png", data: "CURR2==" } }); + expect(events.at(-1)).toMatchObject({ type: "done" }); + }); + + test("shared projection carries toolResult images through shared projection", async () => { + let spawned = 0; + let stdin = ""; + const child = fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); + child.stdin = new Writable({ + write(chunk, _encoding, callback) { + stdin += chunk.toString(); + callback(); + }, + }); + const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.com" }), { + which: () => "/bin/qoder", + spawn: () => { + spawned++; + return child; + }, + }); + const request = parsed({ + context: { + messages: [ + { role: "user", content: "take screenshot", timestamp: 0 }, + { role: "assistant", content: [{ type: "toolCall", id: "call_1", name: "screenshot", arguments: {} }], timestamp: 1 }, + { + role: "toolResult", + toolCallId: "call_1", + content: [ + { type: "text", text: "captured screenshot:" }, + { type: "image", imageUrl: "data:image/png;base64,TOOL_IMG==" }, + ], + timestamp: 2, + }, + ], + }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(spawned).toBe(1); + const wire = JSON.parse(stdin.trim()); + expect(wire.message.content).toContainEqual({ + type: "image", + source: { type: "base64", media_type: "image/png", data: "TOOL_IMG==" }, + }); + const textPart = wire.message.content.find((part: { type: string }) => part.type === "text"); + expect(textPart?.text).toContain("[image attached below]"); + expect(events.at(-1)).toMatchObject({ type: "done" }); + }); + + test("omits unsupported image reference schemes cleanly without crashing", async () => { + let spawned = 0; + let stdin = ""; + const child = fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); + child.stdin = new Writable({ + write(chunk, _encoding, callback) { + stdin += chunk.toString(); + callback(); + }, + }); + const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.com" }), { + which: () => "/bin/qoder", + spawn: () => { + spawned++; + return child; + }, + }); + const request = parsed({ + context: { + messages: [ + { role: "user", content: "first turn", timestamp: 0 }, + { role: "assistant", content: [{ type: "text", text: "response" }], timestamp: 1 }, + { + role: "user", + content: [ + { type: "text", text: "inspect link" }, + { type: "image", imageUrl: "ftp://invalid-scheme.test/image.png" }, + ], + timestamp: 2, + }, + ], + }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(spawned).toBe(1); + const wire = JSON.parse(stdin.trim()); + const images = wire.message.content.filter((part: { type: string }) => part.type === "image"); + expect(images).toHaveLength(0); + const textPart = wire.message.content.find((part: { type: string }) => part.type === "text"); + expect(textPart?.text).toContain("[image omitted: unsupported reference]"); + expect(textPart?.text).toContain("ASSISTANT:\nresponse"); + expect(events.at(-1)).toMatchObject({ type: "done" }); + }); + + test("marks unsupported image references in a single-turn user message", () => { + const lines = buildConversationInput(parsed({ + context: { + messages: [{ + role: "user", + content: [ + { type: "text", text: "inspect link" }, + { type: "image", imageUrl: "ftp://invalid-scheme.test/image.png" }, + ], + timestamp: 0, + }], + }, + })); + + expect(lines).toHaveLength(1); + const frame = JSON.parse(lines[0]!); + expect(frame.message.content).toContainEqual({ type: "text", text: "inspect link" }); + expect(frame.message.content).toContainEqual({ type: "text", text: "[image omitted: unsupported reference]" }); + }); + + test("keeps a single-turn frame for an unsupported image-only message", () => { + const lines = buildConversationInput(parsed({ + context: { + messages: [{ + role: "user", + content: [{ type: "image", imageUrl: "ftp://invalid-scheme.test/image.png" }], + timestamp: 0, + }], + }, + })); + + expect(lines).toHaveLength(1); + const frame = JSON.parse(lines[0]!); + expect(frame.message.content).toContainEqual({ type: "text", text: "[image omitted: unsupported reference]" }); + }); + + test("marks unsupported images in prior user and tool-result history as omitted", () => { + const lines = buildConversationInput(parsed({ + context: { + messages: [ + { + role: "user", + content: [{ type: "image", imageUrl: "ftp://invalid-scheme.test/user.png" }], + timestamp: 0, + }, + { role: "assistant", content: [{ type: "text", text: "I saw the image." }], timestamp: 1 }, + { + role: "toolResult", + toolCallId: "call_1", + content: [{ type: "image", imageUrl: "ftp://invalid-scheme.test/tool.png" }], + timestamp: 2, + }, + { role: "user", content: "What was attached?", timestamp: 3 }, + ], + }, + })); + + expect(lines).toHaveLength(1); + const frame = JSON.parse(lines[0]!); + const text = frame.message.content.find((part: { type: string }) => part.type === "text")?.text; + expect(text).toContain("USER:\n[image omitted: unsupported reference]"); + expect(text).toContain("TOOL RESULT (call_id: call_1):\n[image omitted: unsupported reference]"); + expect(frame.message.content.filter((part: { type: string }) => part.type === "image")).toHaveLength(0); + }); + + test("leaves plain text-only requests completely unaffected", async () => { + let spawned = 0; + let stdin = ""; + const child = fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); + child.stdin = new Writable({ + write(chunk, _encoding, callback) { + stdin += chunk.toString(); + callback(); + }, + }); + const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.com" }), { + which: () => "/bin/qoder", + spawn: () => { + spawned++; + return child; + }, + }); + const request = parsed({ + context: { + messages: [{ role: "user", content: "Hello, plain text only", timestamp: 0 }], + }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(spawned).toBe(1); + const wire = JSON.parse(stdin.trim()); + const images = wire.message.content.filter((part: { type: string }) => part.type === "image"); + expect(images).toHaveLength(0); + const textPart = wire.message.content.find((part: { type: string }) => part.type === "text"); + expect(textPart?.text).toContain("Hello, plain text only"); + expect(events.at(-1)).toMatchObject({ type: "done" }); + }); + +});