From 48a713e035a81baece79ef82f20bbfacc963994d Mon Sep 17 00:00:00 2001 From: Sayo Date: Sun, 27 Sep 2026 22:13:44 +0530 Subject: [PATCH 01/20] fix(devin): carry reasoning signatures across turns SWE-2 streams its #10 delta_signature and #21 delta_signature_type after the visible answer, so the Responses layer stores them as a signature-only reasoning item behind the thinking item. The replay mapping kept only thinking blocks with text, so the signature never went back, and GPT and Gemini rows, whose reasoning is signature-only, replayed nothing at all. - Decode #21 with #10 and carry the type inside the stored signature. - Replay one unsigned thinking block plus exactly one signature-only block as a single signed prompt (#11, #12, #18), and replay signature-only turns instead of dropping them. Existing rules stay: a signed block wins over a stray signature-only block, and ambiguous mixes go unsigned. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit c483502fb6e4b5585d444da4807f89f532cc3bd4) --- scripts/test-layout/layout.json | 1 + src/adapters/devin.ts | 42 +-------- src/adapters/devin/cloud-direct/chat.ts | 9 +- src/adapters/devin/reasoning-signature.ts | 70 ++++++++++++++ structure/providers-and-adapters.md | 2 +- tests/fixtures/test-layout-expected.json | 1 + .../devin-reasoning-continuation.test.ts | 93 +++++++++++++++++++ 7 files changed, 178 insertions(+), 40 deletions(-) create mode 100644 src/adapters/devin/reasoning-signature.ts create mode 100644 tests/providers/devin-reasoning-continuation.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index e49fac447b3..eb9b15e7bd9 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -759,6 +759,7 @@ "devin-provider-merge-migration.test.ts": "providers", "devin-stated-reset-hardening.test.ts": "providers", "devin-stated-reset-retry.test.ts": "providers", + "devin-reasoning-continuation.test.ts": "providers", "devin-stream-deadline.test.ts": "providers", "digitalocean-scaleway-provider.test.ts": "providers", "docs-429-failover-claims.test.ts": "ci-workflows", diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index 362cafd4c33..fb4d518c409 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -15,7 +15,7 @@ import { getCachedCatalog, type CacheEntry, type ModelCatalogEntry } from "./dev import { collapseDevinModelUid, devinFamiliesOf, devinFamilyBaseId, selectDevinFamilyMember, type DevinVariantRequest } from "./devin/live-models"; import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; import { DEVIN_DEFAULT_API_SERVER, resolveDevinApiServer } from "../oauth/devin"; -import { isProviderIssuedThinkingSignature } from "../responses/reasoning-envelope"; +import { devinAssistantReasoning, encodeDevinSignature } from "./devin/reasoning-signature"; import { SendBudgetExhaustedError } from "../lib/upstream-retry"; import { devinContextOverflowEvent, isDevinHistoryOverflow } from "./devin/context-overflow"; @@ -452,44 +452,12 @@ function assistantText(message: OcxAssistantMessage): string { // Thinking stays out of the replayed TEXT: folding chain-of-thought into // assistant text sends it back as visible prior output, which the model // then treats as something it said to the user. It is replayed in its own - // field instead — see assistantThinking below. + // field instead — see devinAssistantReasoning. .map((part) => (part.type === "text" ? part.text : "")) .filter(Boolean) .join("\n"); } -/** - * The assistant turn's own reasoning, for replay in ChatMessagePrompt #11. - * - * This adapter previously asserted that Cognition has no reasoning-replay - * field and dropped the thinking outright, so a reasoning model restarted its - * chain on every turn of a tool loop. The field exists: two independent - * clients of the same service write #11 thinking with #12 signature and #18 - * signature_type on the assistant prompt. - * - * Field #12 attests the exact text at #11, and the wire has room for one pair. - * Every block that carries text is replayed, so the chain stays intact; the - * signature rides along only when the text being replayed IS the text it - * attests, which is exactly the single-block case. Several independently signed - * blocks send an unsigned prompt rather than pairing one block's attestation - * with another block's words. A signature-only block attests encrypted thinking - * that is not being replayed at all, so it is not one of these blocks and - * cannot contribute the pair. - */ -function assistantThinking( - message: OcxAssistantMessage, -): { thinking?: string; signature?: string } { - const blocks = message.content.filter( - (part): part is Extract => part.type === "thinking", - ).filter(part => Boolean(part.thinking)); - if (blocks.length === 0) return {}; - const signature = blocks.length === 1 ? blocks[0]!.signature : undefined; - return { - thinking: blocks.map(part => part.thinking).join("\n"), - ...(isProviderIssuedThinkingSignature(signature) ? { signature } : {}), - }; -} - export function mapOcxMessagesToDevin(parsed: OcxParsedRequest): ChatHistoryItem[] { const items: ChatHistoryItem[] = []; // Cognition is not an OpenAI host, and this adapter does advertise a real @@ -527,10 +495,10 @@ function mapOneMessage(message: OcxMessage): ChatHistoryItem | undefined { if (message.role === "assistant") { const toolCalls = assistantToolCalls(message); const text = assistantText(message); - const reasoning = assistantThinking(message); + const reasoning = devinAssistantReasoning(message); // A turn that produced only reasoning is still worth replaying: dropping it // is what makes the next turn re-derive the same chain. - if (!text && toolCalls.length === 0 && !reasoning.thinking) return undefined; + if (!text && toolCalls.length === 0 && !reasoning.thinking && !reasoning.signature) return undefined; return { role: "assistant", content: text || "", @@ -792,7 +760,7 @@ export function createDevinAdapter( if (event.kind === "reasoning_signature") { // Carried back out so the next turn can replay it in the prompt's // signature field; an unsigned replay is what the service ignores. - emit({ type: "thinking_signature", signature: event.signature }); + emit({ type: "thinking_signature", signature: encodeDevinSignature(event.signature, event.signatureType) }); continue; } if (event.kind === "tool_call_start") { diff --git a/src/adapters/devin/cloud-direct/chat.ts b/src/adapters/devin/cloud-direct/chat.ts index bff84b113ec..61a07b329ae 100644 --- a/src/adapters/devin/cloud-direct/chat.ts +++ b/src/adapters/devin/cloud-direct/chat.ts @@ -494,7 +494,7 @@ export type CloudChatEvent = * turn produced. Without decoding it there is nothing to put in the prompt's * #12 on the next turn, so the replay would always be unsigned. */ - | { kind: 'reasoning_signature'; signature: string } + | { kind: 'reasoning_signature'; signature: string; signatureType?: string } | { kind: 'tool_call_start'; id: string; name: string } | { kind: 'tool_call_args'; @@ -817,6 +817,10 @@ export function* decodeChatFrame(proto: Buffer): Generator { } } if (authoritativeUsage) yield authoritativeUsage; + let signatureType: string | undefined; + for (const f of iterFields(proto)) { + if (f.num === 21 && f.wire === 2 && Buffer.isBuffer(f.value)) signatureType = (f.value as Buffer).toString('utf8') || undefined; + } for (const f of iterFields(proto)) { if (f.num === 3 && f.wire === 2 && Buffer.isBuffer(f.value)) { // Visible delta_text — what the user should SEE in the chat. @@ -842,7 +846,8 @@ export function* decodeChatFrame(proto: Buffer): Generator { if (s) yield { kind: 'reasoning', text: s }; } else if (f.num === 10 && f.wire === 2 && Buffer.isBuffer(f.value)) { const s = (f.value as Buffer).toString('utf8'); - if (s) yield { kind: 'reasoning_signature', signature: s }; + // #21 delta_signature_type arrives in the same frame; the prompt replays it as #18. + if (s) yield { kind: 'reasoning_signature', signature: s, ...(signatureType ? { signatureType } : {}) }; } else if (f.num === 6 && f.wire === 2 && Buffer.isBuffer(f.value)) { let id: string | undefined; let name: string | undefined; diff --git a/src/adapters/devin/reasoning-signature.ts b/src/adapters/devin/reasoning-signature.ts new file mode 100644 index 00000000000..9a7ae9d3f81 --- /dev/null +++ b/src/adapters/devin/reasoning-signature.ts @@ -0,0 +1,70 @@ +/** + * Devin reasoning signatures across turns. + * + * GetChatMessage returns the turn's reasoning attestation as `delta_signature` + * (#10) together with `delta_signature_type` (#21) in the same frame, and the + * native client replays both on the assistant prompt as #12 and #18. Measured + * live on swe-2-high the pair arrives AFTER the visible answer (reasoning, text, + * then signature), so the Responses layer stores it as its own signature-only + * reasoning item behind the thinking-text item. GPT and Gemini rows stream no + * thinking text at all, only the signature. + * + * The type is carried inside the stored signature because the reasoning + * envelope that round-trips through the client keeps a single signature string. + * A signature stored before this prefix existed replays without a type. + */ +import type { OcxAssistantMessage } from "../../types"; +import { isProviderIssuedThinkingSignature } from "../../responses/reasoning-envelope"; + +const TYPED_SIGNATURE_PREFIX = "devin-sig1:"; + +export function encodeDevinSignature(signature: string, signatureType: string | undefined): string { + return signatureType && !signatureType.includes(":") + ? `${TYPED_SIGNATURE_PREFIX}${signatureType}:${signature}` + : signature; +} + +export function decodeDevinSignature(stored: string): { signature: string; signatureType?: string } { + if (!stored.startsWith(TYPED_SIGNATURE_PREFIX)) return { signature: stored }; + const rest = stored.slice(TYPED_SIGNATURE_PREFIX.length); + const colon = rest.indexOf(":"); + if (colon <= 0) return { signature: stored }; + return { signature: rest.slice(colon + 1), signatureType: rest.slice(0, colon) }; +} + +/** + * The assistant turn's reasoning for ChatMessagePrompt #11/#12/#18. + * + * All of the turn's thinking text is replayed. A signature rides along only + * when it covers exactly that text: + * - one thinking block carrying its own issued signature (a stray + * signature-only block does not displace it); + * - at most one unsigned thinking block plus exactly one signature-only block, + * which is how a single Devin turn arrives once its late #10 frame has been + * split into its own reasoning item. With no thinking block at all this is a + * GPT or Gemini row, where the signature is the only reasoning there is. + * Any other mix (two signed blocks, a signed block beside unsigned text) has no + * single attestation for the joined text, so the turn is replayed unsigned. + */ +export function devinAssistantReasoning( + message: OcxAssistantMessage, +): { thinking?: string; signature?: string; signature_type?: string } { + const blocks = message.content.filter( + (part): part is Extract => part.type === "thinking", + ); + const textBlocks = blocks.filter(part => Boolean(part.thinking)); + const signatureOnly = blocks.filter(part => !part.thinking && isProviderIssuedThinkingSignature(part.signature)); + const text = textBlocks.map(part => part.thinking).join("\n"); + let stored: string | undefined; + if (textBlocks.length === 1 && isProviderIssuedThinkingSignature(textBlocks[0]!.signature)) { + stored = textBlocks[0]!.signature; + } else if (textBlocks.length <= 1 && signatureOnly.length === 1) { + stored = signatureOnly[0]!.signature; + } + const decoded = stored ? decodeDevinSignature(stored) : undefined; + return { + ...(text ? { thinking: text } : {}), + ...(decoded ? { signature: decoded.signature } : {}), + ...(decoded?.signatureType ? { signature_type: decoded.signatureType } : {}), + }; +} diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 0bbccdca906..2d24ad8fceb 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -138,7 +138,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge. | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | -| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort anchors on the family's default member and selects the nearest enabled rung, rounding up first; an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays split into per-type `anyOf` branches in tool parameters while preserving outer enum/const, boolean constraints (including `not`, `oneOf`, and `allOf`) on null, and existing null-branch restrictions. A pre-output `invalid_argument` on a history whose word-piece estimate, using the sanitized and truncated tool descriptions actually sent on the wire, reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. | +| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort selects the family's default member, an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays split into per-type `anyOf` branches in tool parameters while preserving outer enum/const and existing null-branch restrictions. A pre-output `invalid_argument` on a history whose word-piece estimate reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. | | `src/adapters/command-code.ts`, `src/adapters/command-code-tool-text.ts`, `src/adapters/command-code-restored-schema.ts` | Command Code OAuth NDJSON translation. For every `xiaomi/mimo-` model, text, native calls, reasoning, and terminal decisions share one byte-bounded queue with linear queue visits. Markup is deduplicated against matching native calls; text-only restoration requires one contiguous bare text block, a clean finish, a declared tool, and arguments validated against supported schema constraints. A parameter-free (freeform) block may omit `` but must end with ``; parameter blocks keep the canonical close. Markup appended after prose, including a marker in a later delta of the same text block, is held as a tail that can never mint a call: possible trailing marker prefixes stay in the same byte-accounted probe across deltas and release as text on a mismatch, boundary or end; a same-content native call strips a completed envelope as an echo, and anything else releases as presentation text so quoted examples stay inert. A tail waits only while a native input it could echo is open, counting the first later input of its own tool, so it is released as soon as those close without a match. Native, reasoning, and other intervening events interrupt a still-probing block but leave a held block held in arrival order, and the queued byte bound still flushes an unresolved envelope as text. An envelope the strict parser rejects but that opens with ``, closes with ``, and names a declared function is dropped when a native call for that same function arrives and on a clean finish; markup that parses but fits no supported schema is still released as text. Regex patterns, other unsupported constraints, and abnormal finishes fail closed. `tests/providers/command-code-tool-text-prose-split.test.ts` covers the prose boundary, the interleaved-event hold, and both drop paths. | diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 2c2bab19d4c..a859b8c5c61 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -764,6 +764,7 @@ "devin-provider-merge-migration.test.ts": "providers", "devin-stated-reset-hardening.test.ts": "providers", "devin-stated-reset-retry.test.ts": "providers", + "devin-reasoning-continuation.test.ts": "providers", "devin-stream-deadline.test.ts": "providers", "digitalocean-scaleway-provider.test.ts": "providers", "docs-429-failover-claims.test.ts": "ci-workflows", diff --git a/tests/providers/devin-reasoning-continuation.test.ts b/tests/providers/devin-reasoning-continuation.test.ts new file mode 100644 index 00000000000..3692ddf4d02 --- /dev/null +++ b/tests/providers/devin-reasoning-continuation.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import { mapOcxMessagesToDevin } from "../../src/adapters/devin"; +import { buildGetChatMessageRequestForTests, decodeChatFrame } from "../../src/adapters/devin/cloud-direct/chat"; +import { encodeString, iterFields } from "../../src/adapters/devin/cloud-direct/wire"; +import { decodeDevinSignature, encodeDevinSignature } from "../../src/adapters/devin/reasoning-signature"; +import { encodeReasoningEnvelope } from "../../src/responses/reasoning-envelope"; +import { parseRequest } from "../../src/responses/parser"; + +// Shapes measured live on GetChatMessage: swe-2-high streams reasoning, then the +// visible answer, then one frame carrying #10 delta_signature and #21 +// delta_signature_type ("sealed"); gpt-6-sol streams no thinking text and a +// signature of type "openai". +const SEALED = "sealed.v1.opaque-attestation"; + +function assistantPrompt(history: ReturnType): Map { + const request = buildGetChatMessageRequestForTests({ + apiKey: "devin-session-token$x", modelUid: "swe-2-high", messages: history, cascadeId: "c", + } as never); + const prompts = [...iterFields(request)].filter(f => f.num === 3).map(f => f.value as Buffer); + const assistant = prompts.find(p => [...iterFields(p)].some(f => f.num === 2 && f.value === 2n))!; + return new Map([...iterFields(assistant)].filter(f => f.wire === 2).map(f => [f.num, (f.value as Buffer).toString("utf8")])); +} + +describe("Devin reasoning continuation across turns", () => { + test("the signature frame yields its type", () => { + const frame = Buffer.concat([encodeString(10, SEALED), encodeString(21, "sealed")]); + expect([...decodeChatFrame(frame)]).toContainEqual({ kind: "reasoning_signature", signature: SEALED, signatureType: "sealed" }); + expect([...decodeChatFrame(encodeString(10, SEALED))]).toContainEqual({ kind: "reasoning_signature", signature: SEALED }); + }); + + test("the stored signature carries its type and an older stored signature still replays", () => { + const stored = encodeDevinSignature(SEALED, "sealed"); + expect(decodeDevinSignature(stored)).toEqual({ signature: SEALED, signatureType: "sealed" }); + expect(decodeDevinSignature(SEALED)).toEqual({ signature: SEALED }); + expect(encodeDevinSignature(SEALED, undefined)).toBe(SEALED); + }); + + test("a SWE-2 turn split into a thinking item and a late signature item replays as one signed prompt", () => { + // What the client sends back: the thinking summary item (no envelope) and the + // signature-only item the late #10 frame became, then the tool loop. + const history = mapOcxMessagesToDevin(parseRequest({ + model: "devin/swe-2", + input: [ + { role: "user", content: [{ type: "input_text", text: "go" }] }, + { type: "reasoning", id: "rs_text", summary: [{ type: "summary_text", text: "pick 482916, then call the tool" }] }, + { type: "reasoning", id: "rs_sig", summary: [], encrypted_content: encodeReasoningEnvelope({ sig: encodeDevinSignature(SEALED, "sealed") }) }, + { type: "function_call", call_id: "call_1", name: "get_time", arguments: "{}" }, + { type: "function_call_output", call_id: "call_1", output: "12:00" }, + ], + })); + const assistant = history.find(m => m.role === "assistant"); + expect(assistant?.thinking).toBe("pick 482916, then call the tool"); + expect(assistant?.signature).toBe(SEALED); + expect(assistant?.signature_type).toBe("sealed"); + const wire = assistantPrompt(history); + expect(wire.get(11)).toBe("pick 482916, then call the tool"); + expect(wire.get(12)).toBe(SEALED); + expect(wire.get(18)).toBe("sealed"); + }); + + test("a signature-only turn is replayed instead of dropped", () => { + const openaiSig = '[{"id":"rs_1","encrypted_content":"opaque"}]'; + const history = mapOcxMessagesToDevin(parseRequest({ + model: "devin/gpt-6-sol", + input: [ + { role: "user", content: [{ type: "input_text", text: "go" }] }, + { type: "reasoning", id: "rs_sig", summary: [], encrypted_content: encodeReasoningEnvelope({ sig: encodeDevinSignature(openaiSig, "openai") }) }, + { type: "function_call", call_id: "call_1", name: "get_time", arguments: "{}" }, + { type: "function_call_output", call_id: "call_1", output: "12:00" }, + ], + })); + const assistant = history.find(m => m.role === "assistant"); + expect(assistant?.thinking).toBeUndefined(); + expect(assistant?.signature).toBe(openaiSig); + expect(assistant?.signature_type).toBe("openai"); + }); + + test("two late signatures beside one thinking block cannot be paired", () => { + const history = mapOcxMessagesToDevin(parseRequest({ + model: "devin/swe-2", + input: [ + { role: "user", content: [{ type: "input_text", text: "go" }] }, + { type: "reasoning", id: "rs_text", summary: [{ type: "summary_text", text: "thought" }] }, + { type: "reasoning", id: "rs_a", summary: [], encrypted_content: encodeReasoningEnvelope({ sig: "sealed.v1.a" }) }, + { type: "reasoning", id: "rs_b", summary: [], encrypted_content: encodeReasoningEnvelope({ sig: "sealed.v1.b" }) }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "answer" }] }, + ], + })); + const assistant = history.find(m => m.role === "assistant"); + expect(assistant?.thinking).toBe("thought"); + expect(assistant?.signature).toBeUndefined(); + }); +}); From 987d3c0189c9f874b5bec66bcc9d39fbe49bd800 Mon Sep 17 00:00:00 2001 From: Sayo Date: Sun, 27 Sep 2026 22:54:03 +0530 Subject: [PATCH 02/20] fix(devin): never replay a Claude signature, and pin the signature-only wire Cognition streams Claude's thinking as a summary while the signature covers the original, so replaying the pair fails validation. Live on claude-opus-5-5 the next turn of a tool loop was refused with invalid_argument in 5 of 6 signed replays and 0 of 3 text-only ones, and dev already failed the same way intermittently (3 of 6) because it paired a single signed block. An Anthropic signature is now dropped and the thinking text replayed alone; a signature stored before its type was recorded falls back to the model being called. Through the proxy the failing tool loop then completed 6 of 6. Tests now check the signature-only turn's wire (#12 and #18, no #11) and that a signature-only turn with no text and no tool call is kept. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit e61c7c3cafaadd4d967ae62329debe9c6b01ac1d) --- src/adapters/devin.ts | 6 +-- src/adapters/devin/reasoning-signature.ts | 11 +++++- .../devin-reasoning-continuation.test.ts | 39 +++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index fb4d518c409..a12de2e6e3f 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -477,13 +477,13 @@ export function mapOcxMessagesToDevin(parsed: OcxParsedRequest): ChatHistoryItem if (system) items.push({ role: "system", content: system }); for (const message of parsed.context.messages) { - const mapped = mapOneMessage(message); + const mapped = mapOneMessage(message, parsed.modelId); if (mapped) items.push(mapped); } return items; } -function mapOneMessage(message: OcxMessage): ChatHistoryItem | undefined { +function mapOneMessage(message: OcxMessage, modelId: string): ChatHistoryItem | undefined { if (message.role === "user" || message.role === "developer") { const content = mapOcxContentToWire(message.content); // An image with no caption text is a complete user message on its own. @@ -495,7 +495,7 @@ function mapOneMessage(message: OcxMessage): ChatHistoryItem | undefined { if (message.role === "assistant") { const toolCalls = assistantToolCalls(message); const text = assistantText(message); - const reasoning = devinAssistantReasoning(message); + const reasoning = devinAssistantReasoning(message, modelId); // A turn that produced only reasoning is still worth replaying: dropping it // is what makes the next turn re-derive the same chain. if (!text && toolCalls.length === 0 && !reasoning.thinking && !reasoning.signature) return undefined; diff --git a/src/adapters/devin/reasoning-signature.ts b/src/adapters/devin/reasoning-signature.ts index 9a7ae9d3f81..6aa4118b81e 100644 --- a/src/adapters/devin/reasoning-signature.ts +++ b/src/adapters/devin/reasoning-signature.ts @@ -45,9 +45,17 @@ export function decodeDevinSignature(stored: string): { signature: string; signa * GPT or Gemini row, where the signature is the only reasoning there is. * Any other mix (two signed blocks, a signed block beside unsigned text) has no * single attestation for the joined text, so the turn is replayed unsigned. + * + * An Anthropic signature is never replayed. Cognition streams Claude's thinking + * as a summary while the signature covers the original, so the pair fails + * validation: live on claude-opus-5-5 the next turn was refused with + * `invalid_argument` in 5 of 6 signed replays and 0 of 3 text-only ones. The + * text still goes back. A stored signature from before the type was recorded + * falls back to the model being called. */ export function devinAssistantReasoning( message: OcxAssistantMessage, + modelId = "", ): { thinking?: string; signature?: string; signature_type?: string } { const blocks = message.content.filter( (part): part is Extract => part.type === "thinking", @@ -61,7 +69,8 @@ export function devinAssistantReasoning( } else if (textBlocks.length <= 1 && signatureOnly.length === 1) { stored = signatureOnly[0]!.signature; } - const decoded = stored ? decodeDevinSignature(stored) : undefined; + let decoded = stored ? decodeDevinSignature(stored) : undefined; + if (decoded && (decoded.signatureType ?? (/claude/i.test(modelId) ? "anthropic" : undefined)) === "anthropic") decoded = undefined; return { ...(text ? { thinking: text } : {}), ...(decoded ? { signature: decoded.signature } : {}), diff --git a/tests/providers/devin-reasoning-continuation.test.ts b/tests/providers/devin-reasoning-continuation.test.ts index 3692ddf4d02..da16cdc66b1 100644 --- a/tests/providers/devin-reasoning-continuation.test.ts +++ b/tests/providers/devin-reasoning-continuation.test.ts @@ -73,6 +73,24 @@ describe("Devin reasoning continuation across turns", () => { expect(assistant?.thinking).toBeUndefined(); expect(assistant?.signature).toBe(openaiSig); expect(assistant?.signature_type).toBe("openai"); + const wire = assistantPrompt(history); + expect(wire.has(11)).toBe(false); + expect(wire.get(12)).toBe(openaiSig); + expect(wire.get(18)).toBe("openai"); + + // No text, no tool call: the signature alone still keeps the assistant turn. + const bare = mapOcxMessagesToDevin(parseRequest({ + model: "devin/gemini-3-8-flash", + input: [ + { role: "user", content: [{ type: "input_text", text: "go" }] }, + { type: "reasoning", id: "rs_sig", summary: [], encrypted_content: encodeReasoningEnvelope({ sig: encodeDevinSignature("AY89gemini", "gemini") }) }, + { role: "user", content: [{ type: "input_text", text: "and then?" }] }, + ], + })); + const kept = bare.find(m => m.role === "assistant"); + expect(kept?.signature).toBe("AY89gemini"); + expect(kept?.signature_type).toBe("gemini"); + expect(assistantPrompt(bare).get(12)).toBe("AY89gemini"); }); test("two late signatures beside one thinking block cannot be paired", () => { @@ -90,4 +108,25 @@ describe("Devin reasoning continuation across turns", () => { expect(assistant?.thinking).toBe("thought"); expect(assistant?.signature).toBeUndefined(); }); + + test("an Anthropic signature is never replayed, only the thinking text", () => { + // Cognition streams Claude's thinking as a summary while the signature covers the + // original; live, a signed replay was refused with invalid_argument 5 of 6 times. + const turn = (sig: string, model: string) => mapOcxMessagesToDevin(parseRequest({ + model, + input: [ + { role: "user", content: [{ type: "input_text", text: "go" }] }, + { type: "reasoning", id: "rs", summary: [], encrypted_content: encodeReasoningEnvelope({ txt: "summarised thought", sig }) }, + { type: "function_call", call_id: "call_1", name: "get_time", arguments: "{}" }, + { type: "function_call_output", call_id: "call_1", output: "12:00" }, + ], + })).find(m => m.role === "assistant"); + const typed = turn(encodeDevinSignature("EpcBClaude", "anthropic"), "devin/claude-opus-5-5"); + expect(typed?.thinking).toBe("summarised thought"); + expect(typed?.signature).toBeUndefined(); + expect(typed?.signature_type).toBeUndefined(); + // A signature stored before its type was recorded falls back to the model being called. + expect(turn("EpcBClaude", "devin/claude-opus-5-5")?.signature).toBeUndefined(); + expect(turn(SEALED, "devin/swe-2")?.signature).toBe(SEALED); + }); }); From 7dc8087341d48d570375b6d8eb4583a25173feb9 Mon Sep 17 00:00:00 2001 From: Sayo Date: Sun, 27 Sep 2026 22:54:13 +0530 Subject: [PATCH 03/20] docs(structure): note the withheld Anthropic signature in Devin replay Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit e0b81e55527ce05e0a65863ef3802d54334bb032) --- structure/providers-and-adapters.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 2d24ad8fceb..b50708887ff 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -138,7 +138,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge. | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | -| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort selects the family's default member, an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays split into per-type `anyOf` branches in tool parameters while preserving outer enum/const and existing null-branch restrictions. A pre-output `invalid_argument` on a history whose word-piece estimate reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. | +| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is withheld and only the thinking text replayed, because the streamed thinking is a summary the signature does not cover and the pair is refused with `invalid_argument`. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort selects the family's default member, an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays split into per-type `anyOf` branches in tool parameters while preserving outer enum/const and existing null-branch restrictions. A pre-output `invalid_argument` on a history whose word-piece estimate reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. | | `src/adapters/command-code.ts`, `src/adapters/command-code-tool-text.ts`, `src/adapters/command-code-restored-schema.ts` | Command Code OAuth NDJSON translation. For every `xiaomi/mimo-` model, text, native calls, reasoning, and terminal decisions share one byte-bounded queue with linear queue visits. Markup is deduplicated against matching native calls; text-only restoration requires one contiguous bare text block, a clean finish, a declared tool, and arguments validated against supported schema constraints. A parameter-free (freeform) block may omit `` but must end with ``; parameter blocks keep the canonical close. Markup appended after prose, including a marker in a later delta of the same text block, is held as a tail that can never mint a call: possible trailing marker prefixes stay in the same byte-accounted probe across deltas and release as text on a mismatch, boundary or end; a same-content native call strips a completed envelope as an echo, and anything else releases as presentation text so quoted examples stay inert. A tail waits only while a native input it could echo is open, counting the first later input of its own tool, so it is released as soon as those close without a match. Native, reasoning, and other intervening events interrupt a still-probing block but leave a held block held in arrival order, and the queued byte bound still flushes an unresolved envelope as text. An envelope the strict parser rejects but that opens with ``, closes with ``, and names a declared function is dropped when a native call for that same function arrives and on a clean finish; markup that parses but fits no supported schema is still released as text. Regex patterns, other unsupported constraints, and abnormal finishes fail closed. `tests/providers/command-code-tool-text-prose-split.test.ts` covers the prose boundary, the interleaved-event hold, and both drop paths. | From 122d987d38ceacd0848cf1b61ac4c1db36badb6d Mon Sep 17 00:00:00 2001 From: Sayo Date: Sun, 27 Sep 2026 23:33:26 +0530 Subject: [PATCH 04/20] fix(devin): replay Claude signatures and retry a refused turn without them Withholding every Anthropic signature stopped the invalid_argument refusals but also stopped Claude recalling its earlier reasoning: in the live benchmark claude-opus-5-5 matched 2 of 6 on dev and 0 of 6 with the signature withheld. The signature is sent again, and a turn Cognition refuses with invalid_argument before any output is retried once with the Anthropic signatures withheld and the thinking text kept. Other signature types and refusals after output are not retried. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit ecd9eaabbc1e65f8d29feddbaa741e05368c42af) --- scripts/test-layout/layout.json | 1 + src/adapters/devin.ts | 42 +++++-- src/adapters/devin/reasoning-signature.ts | 29 +++-- structure/providers-and-adapters.md | 2 +- tests/fixtures/test-layout-expected.json | 1 + ...devin-anthropic-signature-fallback.test.ts | 116 ++++++++++++++++++ .../devin-reasoning-continuation.test.ts | 28 +++-- 7 files changed, 192 insertions(+), 27 deletions(-) create mode 100644 tests/providers/devin-anthropic-signature-fallback.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index eb9b15e7bd9..ba17cb6dc6a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -759,6 +759,7 @@ "devin-provider-merge-migration.test.ts": "providers", "devin-stated-reset-hardening.test.ts": "providers", "devin-stated-reset-retry.test.ts": "providers", + "devin-anthropic-signature-fallback.test.ts": "providers", "devin-reasoning-continuation.test.ts": "providers", "devin-stream-deadline.test.ts": "providers", "digitalocean-scaleway-provider.test.ts": "providers", diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index a12de2e6e3f..39d61ae977c 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -15,7 +15,7 @@ import { getCachedCatalog, type CacheEntry, type ModelCatalogEntry } from "./dev import { collapseDevinModelUid, devinFamiliesOf, devinFamilyBaseId, selectDevinFamilyMember, type DevinVariantRequest } from "./devin/live-models"; import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; import { DEVIN_DEFAULT_API_SERVER, resolveDevinApiServer } from "../oauth/devin"; -import { devinAssistantReasoning, encodeDevinSignature } from "./devin/reasoning-signature"; +import { devinAssistantReasoning, encodeDevinSignature, hasAnthropicSignature } from "./devin/reasoning-signature"; import { SendBudgetExhaustedError } from "../lib/upstream-retry"; import { devinContextOverflowEvent, isDevinHistoryOverflow } from "./devin/context-overflow"; @@ -458,7 +458,10 @@ function assistantText(message: OcxAssistantMessage): string { .join("\n"); } -export function mapOcxMessagesToDevin(parsed: OcxParsedRequest): ChatHistoryItem[] { +export function mapOcxMessagesToDevin( + parsed: OcxParsedRequest, + options: { withholdAnthropicSignatures?: boolean } = {}, +): ChatHistoryItem[] { const items: ChatHistoryItem[] = []; // Cognition is not an OpenAI host, and this adapter does advertise a real // client tool catalog (proto #10 via `mapOcxToolsToDevin`), so the same @@ -477,13 +480,17 @@ export function mapOcxMessagesToDevin(parsed: OcxParsedRequest): ChatHistoryItem if (system) items.push({ role: "system", content: system }); for (const message of parsed.context.messages) { - const mapped = mapOneMessage(message, parsed.modelId); + const mapped = mapOneMessage(message, parsed.modelId, options); if (mapped) items.push(mapped); } return items; } -function mapOneMessage(message: OcxMessage, modelId: string): ChatHistoryItem | undefined { +function mapOneMessage( + message: OcxMessage, + modelId: string, + options: { withholdAnthropicSignatures?: boolean }, +): ChatHistoryItem | undefined { if (message.role === "user" || message.role === "developer") { const content = mapOcxContentToWire(message.content); // An image with no caption text is a complete user message on its own. @@ -495,7 +502,7 @@ function mapOneMessage(message: OcxMessage, modelId: string): ChatHistoryItem | if (message.role === "assistant") { const toolCalls = assistantToolCalls(message); const text = assistantText(message); - const reasoning = devinAssistantReasoning(message, modelId); + const reasoning = devinAssistantReasoning(message, modelId, options.withholdAnthropicSignatures === true); // A turn that produced only reasoning is still worth replaying: dropping it // is what makes the next turn re-derive the same chain. if (!text && toolCalls.length === 0 && !reasoning.thinking && !reasoning.signature) return undefined; @@ -714,7 +721,15 @@ export function createDevinAdapter( // An admitted HTTP turn owns globally shared capacity until this call // emits. Without an explicit wait allowance, preserve the typed reset // delay in generated diagnostic wording and return immediately. - for await (const event of streamChatEventsWithResetRetry({ + const signedMessages = mapOcxMessagesToDevin(parsed); + // A Claude signature is replayed because it is what carries the reasoning into this + // turn, but Cognition streams Claude's thinking as a summary the signature does not + // cover, and some replays are refused with invalid_argument before any output. That + // refusal is retried once with the Anthropic signatures withheld and the text kept. + const unsignedMessages = hasAnthropicSignature(signedMessages, parsed.modelId) + ? mapOcxMessagesToDevin(parsed, { withholdAnthropicSignatures: true }) + : undefined; + const request = (messages: ChatHistoryItem[]) => streamChatEventsWithResetRetry({ apiKey, apiServerUrl: host, modelUid, @@ -738,7 +753,20 @@ export function createDevinAdapter( onPhysicalSend: incoming.onPhysicalSend, onRecoveryWithheld: incoming.onRecoveryWithheld, }, - })) { + }); + async function* withSignatureFallback() { + let produced = false; + try { + for await (const event of request(signedMessages)) { + produced ||= event.kind === "text" || event.kind === "reasoning" || event.kind === "tool_call_start" || event.kind === "tool_call_args"; + yield event; + } + } catch (error) { + if (!unsignedMessages || produced || !(error instanceof CloudChatError && error.code === "invalid_argument")) throw error; + yield* request(unsignedMessages); + } + } + for await (const event of withSignatureFallback()) { if (incoming.abortSignal?.aborted) { // Emitting nothing here left the bridge to synthesize adapter_eof. // Say what happened instead, the way the other runTurn-only adapter diff --git a/src/adapters/devin/reasoning-signature.ts b/src/adapters/devin/reasoning-signature.ts index 6aa4118b81e..1071c87e71c 100644 --- a/src/adapters/devin/reasoning-signature.ts +++ b/src/adapters/devin/reasoning-signature.ts @@ -46,16 +46,13 @@ export function decodeDevinSignature(stored: string): { signature: string; signa * Any other mix (two signed blocks, a signed block beside unsigned text) has no * single attestation for the joined text, so the turn is replayed unsigned. * - * An Anthropic signature is never replayed. Cognition streams Claude's thinking - * as a summary while the signature covers the original, so the pair fails - * validation: live on claude-opus-5-5 the next turn was refused with - * `invalid_argument` in 5 of 6 signed replays and 0 of 3 text-only ones. The - * text still goes back. A stored signature from before the type was recorded - * falls back to the model being called. + * `withholdAnthropic` drops an Anthropic signature and keeps the text: the + * fallback for a Claude turn Cognition refused (see hasAnthropicSignature). */ export function devinAssistantReasoning( message: OcxAssistantMessage, modelId = "", + withholdAnthropic = false, ): { thinking?: string; signature?: string; signature_type?: string } { const blocks = message.content.filter( (part): part is Extract => part.type === "thinking", @@ -70,10 +67,28 @@ export function devinAssistantReasoning( stored = signatureOnly[0]!.signature; } let decoded = stored ? decodeDevinSignature(stored) : undefined; - if (decoded && (decoded.signatureType ?? (/claude/i.test(modelId) ? "anthropic" : undefined)) === "anthropic") decoded = undefined; + if (decoded && withholdAnthropic && signatureTypeFor(decoded, modelId) === "anthropic") decoded = undefined; return { ...(text ? { thinking: text } : {}), ...(decoded ? { signature: decoded.signature } : {}), ...(decoded?.signatureType ? { signature_type: decoded.signatureType } : {}), }; } + +/** A stored signature from before its type was recorded falls back to the model being called. */ +function signatureTypeFor(decoded: { signatureType?: string }, modelId: string): string | undefined { + return decoded.signatureType ?? (/claude/i.test(modelId) ? "anthropic" : undefined); +} + +/** + * True when the mapped history replays a Claude signature. Cognition streams + * Claude's thinking as a summary while the signature covers the original, so + * the pair can fail validation: live on claude-opus-5-5 a signed replay of a + * visible-thinking turn was refused with `invalid_argument` in 5 of 6 tries and + * a text-only one in none, while a signed replay that is accepted is what lets + * the model recall its earlier reasoning. The adapter therefore sends the + * signature and retries a refusal once without it. + */ +export function hasAnthropicSignature(items: ReadonlyArray<{ signature?: string; signature_type?: string }>, modelId: string): boolean { + return items.some(item => Boolean(item.signature) && signatureTypeFor({ signatureType: item.signature_type }, modelId) === "anthropic"); +} diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index b50708887ff..5437cc6f0f0 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -138,7 +138,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge. | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | -| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is withheld and only the thinking text replayed, because the streamed thinking is a summary the signature does not cover and the pair is refused with `invalid_argument`. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort selects the family's default member, an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays split into per-type `anyOf` branches in tool parameters while preserving outer enum/const and existing null-branch restrictions. A pre-output `invalid_argument` on a history whose word-piece estimate reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. | +| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any output is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort selects the family's default member, an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays split into per-type `anyOf` branches in tool parameters while preserving outer enum/const and existing null-branch restrictions. A pre-output `invalid_argument` on a history whose word-piece estimate reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. | | `src/adapters/command-code.ts`, `src/adapters/command-code-tool-text.ts`, `src/adapters/command-code-restored-schema.ts` | Command Code OAuth NDJSON translation. For every `xiaomi/mimo-` model, text, native calls, reasoning, and terminal decisions share one byte-bounded queue with linear queue visits. Markup is deduplicated against matching native calls; text-only restoration requires one contiguous bare text block, a clean finish, a declared tool, and arguments validated against supported schema constraints. A parameter-free (freeform) block may omit `` but must end with ``; parameter blocks keep the canonical close. Markup appended after prose, including a marker in a later delta of the same text block, is held as a tail that can never mint a call: possible trailing marker prefixes stay in the same byte-accounted probe across deltas and release as text on a mismatch, boundary or end; a same-content native call strips a completed envelope as an echo, and anything else releases as presentation text so quoted examples stay inert. A tail waits only while a native input it could echo is open, counting the first later input of its own tool, so it is released as soon as those close without a match. Native, reasoning, and other intervening events interrupt a still-probing block but leave a held block held in arrival order, and the queued byte bound still flushes an unresolved envelope as text. An envelope the strict parser rejects but that opens with ``, closes with ``, and names a declared function is dropped when a native call for that same function arrives and on a clean finish; markup that parses but fits no supported schema is still released as text. Regex patterns, other unsupported constraints, and abnormal finishes fail closed. `tests/providers/command-code-tool-text-prose-split.test.ts` covers the prose boundary, the interleaved-event hold, and both drop paths. | diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a859b8c5c61..8fca36d074b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -764,6 +764,7 @@ "devin-provider-merge-migration.test.ts": "providers", "devin-stated-reset-hardening.test.ts": "providers", "devin-stated-reset-retry.test.ts": "providers", + "devin-anthropic-signature-fallback.test.ts": "providers", "devin-reasoning-continuation.test.ts": "providers", "devin-stream-deadline.test.ts": "providers", "digitalocean-scaleway-provider.test.ts": "providers", diff --git a/tests/providers/devin-anthropic-signature-fallback.test.ts b/tests/providers/devin-anthropic-signature-fallback.test.ts new file mode 100644 index 00000000000..89bf9324ef0 --- /dev/null +++ b/tests/providers/devin-anthropic-signature-fallback.test.ts @@ -0,0 +1,116 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createDevinAdapter } from "../../src/adapters/devin"; +import { setCachedCatalogForTests } from "../../src/adapters/devin/cloud-direct/catalog"; +import { devinCacheIdentity, invalidateSessionIdentity } from "../../src/adapters/devin/cloud-direct/chat"; +import { encodeString, encodeVarintField, iterFields } from "../../src/adapters/devin/cloud-direct/wire"; +import { encodeDevinSignature } from "../../src/adapters/devin/reasoning-signature"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import { encodeReasoningEnvelope } from "../../src/responses/reasoning-envelope"; +import { parseRequest } from "../../src/responses/parser"; +import type { AdapterEvent } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// Cognition streams Claude's thinking as a summary while the signature covers the +// original, so a signed replay can be refused with an opaque invalid_argument before +// any output. The adapter sends the signature (it is what carries the reasoning) and +// retries a refusal once without it. +describe("Devin Anthropic signature fallback", () => { + const apiKey = "ocx-devin-signature-fixture"; + const host = "https://server.codeium.com"; + const previousHome = process.env.OPENCODEX_HOME; + const previousFetch = globalThis.fetch; + let home = ""; + let requests: Buffer[] = []; + let responses: Array<"refuse" | "ok" | "text-then-refuse"> = []; + + const frame = (body: Buffer, flags = 0) => { + const header = Buffer.alloc(5); + header[0] = flags; + header.writeUInt32BE(body.length, 1); + return Buffer.concat([header, body]); + }; + const refusal = frame(Buffer.from(JSON.stringify({ error: { code: "invalid_argument", message: "an internal error occurred" } })), 2); + const ok = Buffer.concat([frame(Buffer.concat([encodeString(3, "ok"), encodeVarintField(5, 2)])), frame(Buffer.from("{}"), 2)]); + + function assistantSignature(request: Buffer): { thinking?: string; signature?: string } { + const prompts = [...iterFields(request)].filter(f => f.num === 3).map(f => f.value as Buffer); + const assistant = prompts.find(p => [...iterFields(p)].some(f => f.num === 2 && f.value === 2n))!; + const byNum = new Map([...iterFields(assistant)].filter(f => f.wire === 2).map(f => [f.num, (f.value as Buffer).toString("utf8")])); + return { thinking: byNum.get(11), signature: byNum.get(12) }; + } + + async function run(signature: string, modelId: string): Promise { + const parsed = parseRequest({ + model: `devin/${modelId}`, + input: [ + { role: "user", content: [{ type: "input_text", text: "go" }] }, + { type: "reasoning", id: "rs", summary: [], encrypted_content: encodeReasoningEnvelope({ txt: "summarised thought", sig: signature }) }, + { type: "function_call", call_id: "call_1", name: "get_time", arguments: "{}" }, + { type: "function_call_output", call_id: "call_1", output: "12:00" }, + ], + }); + parsed.modelId = modelId; + const adapter = createDevinAdapter({ adapter: "devin", apiKey, baseUrl: host }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(parsed, { headers: new Headers(), translatorBudget: createTranslatorBudget() }, event => { events.push(event); }); + return events; + } + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-devin-sigfallback-")); + process.env.OPENCODEX_HOME = home; + requests = []; + responses = []; + setCachedCatalogForTests(null); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (!String(input).endsWith("/GetChatMessage")) return new Response("unavailable", { status: 503 }); + requests.push(Buffer.from(await (init!.body as Blob).arrayBuffer()).subarray(5)); + const next = responses.shift() ?? "ok"; + const body = next === "refuse" ? refusal + : next === "text-then-refuse" ? Buffer.concat([frame(encodeString(3, "partial")), refusal]) + : ok; + return new Response(body, { headers: { "content-type": "application/connect+proto" } }); + }) as typeof fetch; + }); + afterEach(() => { + globalThis.fetch = previousFetch; + setCachedCatalogForTests(null); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + invalidateSessionIdentity(devinCacheIdentity(apiKey, host)); + removeTreeWithRetry(home); + }); + + test("a refused signed Claude turn is retried once with the signature withheld", async () => { + responses = ["refuse", "ok"]; + const events = await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium"); + expect(requests).toHaveLength(2); + expect(assistantSignature(requests[0]!)).toEqual({ thinking: "summarised thought", signature: "EpcBClaude" }); + expect(assistantSignature(requests[1]!)).toEqual({ thinking: "summarised thought", signature: undefined }); + expect(events.some(e => e.type === "error")).toBe(false); + expect(events).toContainEqual({ type: "text_delta", text: "ok" }); + }); + + test("an accepted signed Claude turn is sent once, signature included", async () => { + responses = ["ok"]; + await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium"); + expect(requests).toHaveLength(1); + expect(assistantSignature(requests[0]!).signature).toBe("EpcBClaude"); + }); + + test("a refusal is not retried for a non-Anthropic signature or after output", async () => { + responses = ["refuse", "ok"]; + const sealed = await run(encodeDevinSignature("sealed.v1.x", "sealed"), "swe-2-high"); + expect(requests).toHaveLength(1); + expect(sealed.some(e => e.type === "error")).toBe(true); + + requests = []; + responses = ["text-then-refuse", "ok"]; + const partial = await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium"); + expect(requests).toHaveLength(1); + expect(partial.some(e => e.type === "error")).toBe(true); + }); +}); diff --git a/tests/providers/devin-reasoning-continuation.test.ts b/tests/providers/devin-reasoning-continuation.test.ts index da16cdc66b1..58a76d9411b 100644 --- a/tests/providers/devin-reasoning-continuation.test.ts +++ b/tests/providers/devin-reasoning-continuation.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { mapOcxMessagesToDevin } from "../../src/adapters/devin"; import { buildGetChatMessageRequestForTests, decodeChatFrame } from "../../src/adapters/devin/cloud-direct/chat"; import { encodeString, iterFields } from "../../src/adapters/devin/cloud-direct/wire"; -import { decodeDevinSignature, encodeDevinSignature } from "../../src/adapters/devin/reasoning-signature"; +import { decodeDevinSignature, encodeDevinSignature, hasAnthropicSignature } from "../../src/adapters/devin/reasoning-signature"; import { encodeReasoningEnvelope } from "../../src/responses/reasoning-envelope"; import { parseRequest } from "../../src/responses/parser"; @@ -109,10 +109,8 @@ describe("Devin reasoning continuation across turns", () => { expect(assistant?.signature).toBeUndefined(); }); - test("an Anthropic signature is never replayed, only the thinking text", () => { - // Cognition streams Claude's thinking as a summary while the signature covers the - // original; live, a signed replay was refused with invalid_argument 5 of 6 times. - const turn = (sig: string, model: string) => mapOcxMessagesToDevin(parseRequest({ + test("an Anthropic signature is replayed by default and withheld only for the fallback", () => { + const parsed = (sig: string, model: string) => parseRequest({ model, input: [ { role: "user", content: [{ type: "input_text", text: "go" }] }, @@ -120,13 +118,19 @@ describe("Devin reasoning continuation across turns", () => { { type: "function_call", call_id: "call_1", name: "get_time", arguments: "{}" }, { type: "function_call_output", call_id: "call_1", output: "12:00" }, ], - })).find(m => m.role === "assistant"); - const typed = turn(encodeDevinSignature("EpcBClaude", "anthropic"), "devin/claude-opus-5-5"); - expect(typed?.thinking).toBe("summarised thought"); - expect(typed?.signature).toBeUndefined(); - expect(typed?.signature_type).toBeUndefined(); + }); + const typed = parsed(encodeDevinSignature("EpcBClaude", "anthropic"), "devin/claude-opus-5-5"); + const signed = mapOcxMessagesToDevin(typed); + expect(signed.find(m => m.role === "assistant")?.signature).toBe("EpcBClaude"); + expect(hasAnthropicSignature(signed, typed.modelId)).toBe(true); + const unsigned = mapOcxMessagesToDevin(typed, { withholdAnthropicSignatures: true }).find(m => m.role === "assistant"); + expect(unsigned?.thinking).toBe("summarised thought"); + expect(unsigned?.signature).toBeUndefined(); // A signature stored before its type was recorded falls back to the model being called. - expect(turn("EpcBClaude", "devin/claude-opus-5-5")?.signature).toBeUndefined(); - expect(turn(SEALED, "devin/swe-2")?.signature).toBe(SEALED); + const legacy = parsed("EpcBClaude", "devin/claude-opus-5-5"); + expect(hasAnthropicSignature(mapOcxMessagesToDevin(legacy), legacy.modelId)).toBe(true); + const sealed = parsed(SEALED, "devin/swe-2"); + expect(hasAnthropicSignature(mapOcxMessagesToDevin(sealed), sealed.modelId)).toBe(false); + expect(mapOcxMessagesToDevin(sealed, { withholdAnthropicSignatures: true }).find(m => m.role === "assistant")?.signature).toBe(SEALED); }); }); From bd59ea52d866308199268fff1580d75f32f5ac9c Mon Sep 17 00:00:00 2001 From: Sayo Date: Sun, 27 Sep 2026 23:46:36 +0530 Subject: [PATCH 05/20] fix(devin): retry a refused Claude turn even after it streamed reasoning Live, Cognition's refusal of a signed Claude replay usually arrives after the model has streamed its reasoning, its signature and a finish frame, and nothing visible. Only visible output (text or tool calls) now blocks the retry, so those turns are retried without the signature instead of failing. Through the proxy the stress case completed 10 of 10 (dev failed 3 of 6) and recalled the hidden number 7 of 10. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit bfc56f5168366f765f66d64a676ac919532fcc84) --- src/adapters/devin.ts | 4 +++- .../devin-anthropic-signature-fallback.test.ts | 13 ++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index 39d61ae977c..5ead906f409 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -758,7 +758,9 @@ export function createDevinAdapter( let produced = false; try { for await (const event of request(signedMessages)) { - produced ||= event.kind === "text" || event.kind === "reasoning" || event.kind === "tool_call_start" || event.kind === "tool_call_args"; + // Only visible output makes a retry unsafe. Live, the refusal often lands after the + // model has streamed its reasoning, its signature and a finish frame, and nothing else. + produced ||= event.kind === "text" || event.kind === "tool_call_start" || event.kind === "tool_call_args"; yield event; } } catch (error) { diff --git a/tests/providers/devin-anthropic-signature-fallback.test.ts b/tests/providers/devin-anthropic-signature-fallback.test.ts index 89bf9324ef0..3ddf1864a35 100644 --- a/tests/providers/devin-anthropic-signature-fallback.test.ts +++ b/tests/providers/devin-anthropic-signature-fallback.test.ts @@ -24,7 +24,7 @@ describe("Devin Anthropic signature fallback", () => { const previousFetch = globalThis.fetch; let home = ""; let requests: Buffer[] = []; - let responses: Array<"refuse" | "ok" | "text-then-refuse"> = []; + let responses: Array<"refuse" | "ok" | "text-then-refuse" | "reasoning-then-refuse"> = []; const frame = (body: Buffer, flags = 0) => { const header = Buffer.alloc(5); @@ -71,6 +71,8 @@ describe("Devin Anthropic signature fallback", () => { const next = responses.shift() ?? "ok"; const body = next === "refuse" ? refusal : next === "text-then-refuse" ? Buffer.concat([frame(encodeString(3, "partial")), refusal]) + // The live shape: reasoning, its signature and a finish frame, then the refusal trailer. + : next === "reasoning-then-refuse" ? Buffer.concat([frame(Buffer.concat([encodeString(9, "thinking"), encodeString(10, "EpcBNew"), encodeString(21, "anthropic"), encodeVarintField(5, 2)])), refusal]) : ok; return new Response(body, { headers: { "content-type": "application/connect+proto" } }); }) as typeof fetch; @@ -94,6 +96,15 @@ describe("Devin Anthropic signature fallback", () => { expect(events).toContainEqual({ type: "text_delta", text: "ok" }); }); + test("a refusal after reasoning alone is still retried", async () => { + responses = ["reasoning-then-refuse", "ok"]; + const events = await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium"); + expect(requests).toHaveLength(2); + expect(assistantSignature(requests[1]!).signature).toBeUndefined(); + expect(events.some(e => e.type === "error")).toBe(false); + expect(events).toContainEqual({ type: "text_delta", text: "ok" }); + }); + test("an accepted signed Claude turn is sent once, signature included", async () => { responses = ["ok"]; await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium"); From eeaafa1ff4a83f46d1b712d8ba9fdb88335b8091 Mon Sep 17 00:00:00 2001 From: Sayo Date: Sun, 27 Sep 2026 23:47:31 +0530 Subject: [PATCH 06/20] docs(structure): the Claude signature retry ignores reasoning-only output Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit 073ef93af0c0170d99e519a9fb7932aded556cb9) --- structure/providers-and-adapters.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 5437cc6f0f0..ffcad7aa05b 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -138,7 +138,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge. | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | -| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any output is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort selects the family's default member, an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays split into per-type `anyOf` branches in tool parameters while preserving outer enum/const and existing null-branch restrictions. A pre-output `invalid_argument` on a history whose word-piece estimate reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. | +| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort selects the family's default member, an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays split into per-type `anyOf` branches in tool parameters while preserving outer enum/const and existing null-branch restrictions. A pre-output `invalid_argument` on a history whose word-piece estimate reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. | | `src/adapters/command-code.ts`, `src/adapters/command-code-tool-text.ts`, `src/adapters/command-code-restored-schema.ts` | Command Code OAuth NDJSON translation. For every `xiaomi/mimo-` model, text, native calls, reasoning, and terminal decisions share one byte-bounded queue with linear queue visits. Markup is deduplicated against matching native calls; text-only restoration requires one contiguous bare text block, a clean finish, a declared tool, and arguments validated against supported schema constraints. A parameter-free (freeform) block may omit `` but must end with ``; parameter blocks keep the canonical close. Markup appended after prose, including a marker in a later delta of the same text block, is held as a tail that can never mint a call: possible trailing marker prefixes stay in the same byte-accounted probe across deltas and release as text on a mismatch, boundary or end; a same-content native call strips a completed envelope as an echo, and anything else releases as presentation text so quoted examples stay inert. A tail waits only while a native input it could echo is open, counting the first later input of its own tool, so it is released as soon as those close without a match. Native, reasoning, and other intervening events interrupt a still-probing block but leave a held block held in arrival order, and the queued byte bound still flushes an unresolved envelope as text. An envelope the strict parser rejects but that opens with ``, closes with ``, and names a declared function is dropped when a native call for that same function arrives and on a clean finish; markup that parses but fits no supported schema is still released as text. Regex patterns, other unsupported constraints, and abnormal finishes fail closed. `tests/providers/command-code-tool-text-prose-split.test.ts` covers the prose boundary, the interleaved-event hold, and both drop paths. | From 661019b3d729b1c732f6da9ce52e83fab5d287bb Mon Sep 17 00:00:00 2001 From: Sayo Date: Mon, 28 Sep 2026 00:11:25 +0530 Subject: [PATCH 07/20] fix(devin): hold the signed attempt's reasoning until its outcome is known The fallback yielded the signed attempt's reasoning and signature before it knew whether Cognition would refuse the turn, so a successful unsigned retry left the client holding the refused attempt's signature, which the next turn would replay against the retry's thinking. When a fallback is possible, the signed attempt's events are now held until its first visible output or a clean finish, and discarded when the retry starts. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit 796b07fe58e04fa247da049c116ee17783a3a2d3) --- src/adapters/devin.ts | 28 +++++++++++++++---- ...devin-anthropic-signature-fallback.test.ts | 18 ++++++++++-- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index 5ead906f409..0603609788b 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -10,7 +10,7 @@ import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, Ocx import { namespacedToolName } from "../types"; import type { IncomingMeta, ProviderAdapter } from "./base"; import { streamChatEventsWithResetRetry, devinStatedResetWaitMs, allocateCascadeId, CloudChatError, type ChatHistoryItem, type ToolDef } from "./devin/cloud-direct"; -import type { ContentPart } from "./devin/cloud-direct/chat"; +import type { CloudChatEvent, ContentPart } from "./devin/cloud-direct/chat"; import { getCachedCatalog, type CacheEntry, type ModelCatalogEntry } from "./devin/cloud-direct/catalog"; import { collapseDevinModelUid, devinFamiliesOf, devinFamilyBaseId, selectDevinFamilyMember, type DevinVariantRequest } from "./devin/live-models"; import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; @@ -755,18 +755,36 @@ export function createDevinAdapter( }, }); async function* withSignatureFallback() { - let produced = false; + if (!unsignedMessages) { + yield* request(signedMessages); + return; + } + // Events from the signed attempt are held until its outcome is known: a refusal + // after reasoning would otherwise leave the client with the refused attempt's + // reasoning and signature, and the next turn would replay that signature against + // the retry's thinking. + const held: CloudChatEvent[] = []; + let visible = false; try { for await (const event of request(signedMessages)) { // Only visible output makes a retry unsafe. Live, the refusal often lands after the // model has streamed its reasoning, its signature and a finish frame, and nothing else. - produced ||= event.kind === "text" || event.kind === "tool_call_start" || event.kind === "tool_call_args"; - yield event; + if (!visible && (event.kind === "text" || event.kind === "tool_call_start" || event.kind === "tool_call_args")) { + visible = true; + yield* held.splice(0); + } + if (visible) yield event; + else held.push(event); } } catch (error) { - if (!unsignedMessages || produced || !(error instanceof CloudChatError && error.code === "invalid_argument")) throw error; + if (visible || !(error instanceof CloudChatError && error.code === "invalid_argument")) { + yield* held.splice(0); + throw error; + } yield* request(unsignedMessages); + return; } + yield* held.splice(0); } for await (const event of withSignatureFallback()) { if (incoming.abortSignal?.aborted) { diff --git a/tests/providers/devin-anthropic-signature-fallback.test.ts b/tests/providers/devin-anthropic-signature-fallback.test.ts index 3ddf1864a35..52e8833781e 100644 --- a/tests/providers/devin-anthropic-signature-fallback.test.ts +++ b/tests/providers/devin-anthropic-signature-fallback.test.ts @@ -24,7 +24,7 @@ describe("Devin Anthropic signature fallback", () => { const previousFetch = globalThis.fetch; let home = ""; let requests: Buffer[] = []; - let responses: Array<"refuse" | "ok" | "text-then-refuse" | "reasoning-then-refuse"> = []; + let responses: Array<"refuse" | "ok" | "text-then-refuse" | "reasoning-then-refuse" | "reasoning-then-ok"> = []; const frame = (body: Buffer, flags = 0) => { const header = Buffer.alloc(5); @@ -73,6 +73,7 @@ describe("Devin Anthropic signature fallback", () => { : next === "text-then-refuse" ? Buffer.concat([frame(encodeString(3, "partial")), refusal]) // The live shape: reasoning, its signature and a finish frame, then the refusal trailer. : next === "reasoning-then-refuse" ? Buffer.concat([frame(Buffer.concat([encodeString(9, "thinking"), encodeString(10, "EpcBNew"), encodeString(21, "anthropic"), encodeVarintField(5, 2)])), refusal]) + : next === "reasoning-then-ok" ? Buffer.concat([frame(Buffer.concat([encodeString(9, "thinking"), encodeString(10, "EpcBNew"), encodeString(21, "anthropic")])), ok]) : ok; return new Response(body, { headers: { "content-type": "application/connect+proto" } }); }) as typeof fetch; @@ -96,13 +97,26 @@ describe("Devin Anthropic signature fallback", () => { expect(events).toContainEqual({ type: "text_delta", text: "ok" }); }); - test("a refusal after reasoning alone is still retried", async () => { + test("a refusal after reasoning alone is still retried, and the refused attempt's reasoning never reaches the client", async () => { responses = ["reasoning-then-refuse", "ok"]; const events = await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium"); expect(requests).toHaveLength(2); expect(assistantSignature(requests[1]!).signature).toBeUndefined(); expect(events.some(e => e.type === "error")).toBe(false); expect(events).toContainEqual({ type: "text_delta", text: "ok" }); + // The refused attempt streamed "thinking" and signature EpcBNew; neither may leak into the turn. + expect(events.some(e => e.type === "thinking_delta")).toBe(false); + expect(events.some(e => e.type === "thinking_signature")).toBe(false); + }); + + test("an accepted signed turn still delivers its held reasoning", async () => { + responses = ["reasoning-then-ok"]; + const events = await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium"); + expect(requests).toHaveLength(1); + const kinds = events.map(e => e.type); + expect(kinds).toContain("thinking_delta"); + expect(events).toContainEqual({ type: "thinking_signature", signature: encodeDevinSignature("EpcBNew", "anthropic") }); + expect(kinds.indexOf("thinking_delta")).toBeLessThan(kinds.indexOf("text_delta")); }); test("an accepted signed Claude turn is sent once, signature included", async () => { From 3e2c84791732158df4b11974270bfd5e2c26d248 Mon Sep 17 00:00:00 2001 From: Sayo Date: Mon, 28 Sep 2026 00:20:01 +0530 Subject: [PATCH 08/20] fix(devin): heartbeat while signed reasoning is held, and keep the refused attempt's usage - While the signed attempt's events are held, a plain heartbeat goes out at most every 15 seconds, so a long reasoning phase does not trip the bridge's upstream stall deadline. The held reasoning and signature stay held until the retry decision. - The refused attempt was processed, so its final usage is added to every usage frame of the unsigned retry (frames are cumulative per request) instead of being dropped with its reasoning. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit 37848f43daddb98f5230fe8500968440f4a3e7a1) --- src/adapters/devin.ts | 37 +++++++++++++++++-- ...devin-anthropic-signature-fallback.test.ts | 34 +++++++++++++++-- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index 0603609788b..28cefe2e761 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -55,6 +55,22 @@ export function mergeDevinUsage(previous: OcxUsage, next: OcxUsage): OcxUsage { */ const DEVIN_CLIENT_CLOSED_MESSAGE = "client closed request"; +/** Below the bridge's upstream stall deadline, so held reasoning never reads as a stall. */ +const HELD_REASONING_HEARTBEAT_MS = 15_000; + +type DevinUsageEvent = Extract; + +/** The retry's cumulative usage plus the refused attempt's final counts. */ +function addDevinUsage(event: DevinUsageEvent, prior: DevinUsageEvent): DevinUsageEvent { + const sum = (a?: number, b?: number) => (a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0)); + const out: DevinUsageEvent = { ...event }; + for (const key of ["promptTokens", "completionTokens", "totalTokens", "cachedInputTokens", "cacheCreationInputTokens", "reasoningTokens"] as const) { + const value = sum(event[key], prior[key]); + if (value !== undefined) out[key] = value; + } + return out; +} + /** Map a cloud-direct failure onto the structured fields the error event carries. */ export function devinErrorClassification(error: unknown): { status?: number; errorType?: string; retryable?: boolean } { const status = error instanceof CloudChatError ? error.status : undefined; @@ -764,7 +780,11 @@ export function createDevinAdapter( // reasoning and signature, and the next turn would replay that signature against // the retry's thinking. const held: CloudChatEvent[] = []; + // Usage is still real: the refused attempt was processed, so its final counts are + // added to every usage frame of the retry (frames are cumulative per request). + let refusedUsage: Extract | undefined; let visible = false; + let lastHeartbeat = Date.now(); try { for await (const event of request(signedMessages)) { // Only visible output makes a retry unsafe. Live, the refusal often lands after the @@ -773,15 +793,26 @@ export function createDevinAdapter( visible = true; yield* held.splice(0); } - if (visible) yield event; - else held.push(event); + if (visible) { + yield event; + continue; + } + held.push(event); + if (event.kind === "usage") refusedUsage = event; + // Held reasoning must not look like a stalled upstream to the bridge. + if (Date.now() - lastHeartbeat >= HELD_REASONING_HEARTBEAT_MS) { + lastHeartbeat = Date.now(); + emit({ type: "heartbeat" }); + } } } catch (error) { if (visible || !(error instanceof CloudChatError && error.code === "invalid_argument")) { yield* held.splice(0); throw error; } - yield* request(unsignedMessages); + for await (const event of request(unsignedMessages)) { + yield event.kind === "usage" && refusedUsage ? addDevinUsage(event, refusedUsage) : event; + } return; } yield* held.splice(0); diff --git a/tests/providers/devin-anthropic-signature-fallback.test.ts b/tests/providers/devin-anthropic-signature-fallback.test.ts index 52e8833781e..0497236d2dc 100644 --- a/tests/providers/devin-anthropic-signature-fallback.test.ts +++ b/tests/providers/devin-anthropic-signature-fallback.test.ts @@ -1,11 +1,11 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { createDevinAdapter } from "../../src/adapters/devin"; import { setCachedCatalogForTests } from "../../src/adapters/devin/cloud-direct/catalog"; import { devinCacheIdentity, invalidateSessionIdentity } from "../../src/adapters/devin/cloud-direct/chat"; -import { encodeString, encodeVarintField, iterFields } from "../../src/adapters/devin/cloud-direct/wire"; +import { encodeMessage, encodeString, encodeVarintField, iterFields } from "../../src/adapters/devin/cloud-direct/wire"; import { encodeDevinSignature } from "../../src/adapters/devin/reasoning-signature"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; import { encodeReasoningEnvelope } from "../../src/responses/reasoning-envelope"; @@ -24,7 +24,7 @@ describe("Devin Anthropic signature fallback", () => { const previousFetch = globalThis.fetch; let home = ""; let requests: Buffer[] = []; - let responses: Array<"refuse" | "ok" | "text-then-refuse" | "reasoning-then-refuse" | "reasoning-then-ok"> = []; + let responses: Array<"refuse" | "ok" | "text-then-refuse" | "reasoning-then-refuse" | "reasoning-then-ok" | "usage-reasoning-then-refuse" | "usage-ok"> = []; const frame = (body: Buffer, flags = 0) => { const header = Buffer.alloc(5); @@ -74,6 +74,9 @@ describe("Devin Anthropic signature fallback", () => { // The live shape: reasoning, its signature and a finish frame, then the refusal trailer. : next === "reasoning-then-refuse" ? Buffer.concat([frame(Buffer.concat([encodeString(9, "thinking"), encodeString(10, "EpcBNew"), encodeString(21, "anthropic"), encodeVarintField(5, 2)])), refusal]) : next === "reasoning-then-ok" ? Buffer.concat([frame(Buffer.concat([encodeString(9, "thinking"), encodeString(10, "EpcBNew"), encodeString(21, "anthropic")])), ok]) + // ModelUsageStats (#7) arrives with the reasoning, before the refusal trailer. + : next === "usage-reasoning-then-refuse" ? Buffer.concat([frame(Buffer.concat([encodeMessage(7, Buffer.concat([encodeVarintField(2, 1000), encodeVarintField(3, 40)])), encodeString(9, "thinking")])), frame(encodeString(9, " more")), refusal]) + : next === "usage-ok" ? Buffer.concat([frame(Buffer.concat([encodeMessage(7, Buffer.concat([encodeVarintField(2, 1100), encodeVarintField(3, 20)])), encodeString(3, "ok"), encodeVarintField(5, 2)])), frame(Buffer.from("{}"), 2)]) : ok; return new Response(body, { headers: { "content-type": "application/connect+proto" } }); }) as typeof fetch; @@ -119,6 +122,31 @@ describe("Devin Anthropic signature fallback", () => { expect(kinds.indexOf("thinking_delta")).toBeLessThan(kinds.indexOf("text_delta")); }); + test("the refused attempt's usage is added to the retry's", async () => { + responses = ["usage-reasoning-then-refuse", "usage-ok"]; + const events = await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium"); + expect(requests).toHaveLength(2); + const done = events.find(e => e.type === "done") as { usage?: { inputTokens?: number; outputTokens?: number } } | undefined; + expect(done?.usage?.inputTokens).toBe(2100); + expect(done?.usage?.outputTokens).toBe(60); + }); + + test("held reasoning emits heartbeats, never the held events", async () => { + // Each clock read advances 20s, so every held frame is past the heartbeat interval. + let clock = Date.now(); + const now = spyOn(Date, "now").mockImplementation(() => (clock += 20_000)); + try { + responses = ["usage-reasoning-then-refuse", "ok"]; + const events = await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium"); + const kinds = events.map(e => e.type); + expect(kinds.filter(k => k === "heartbeat").length).toBeGreaterThan(0); + expect(kinds).not.toContain("thinking_delta"); + expect(kinds.indexOf("heartbeat")).toBeLessThan(kinds.indexOf("text_delta")); + } finally { + now.mockRestore(); + } + }); + test("an accepted signed Claude turn is sent once, signature included", async () => { responses = ["ok"]; await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium"); From 601af663250de9b466388a4b4d30f7b9ccd163e5 Mon Sep 17 00:00:00 2001 From: Sayo Date: Mon, 28 Sep 2026 00:27:05 +0530 Subject: [PATCH 09/20] fix(devin): report the refused attempt's usage before the retry starts The refused attempt's usage reached the turn only through the retry's usage frames, so a retry that reported no usage, or failed before its first frame, dropped those tokens again. It is now emitted before the retry and still added to every later retry frame. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit 78d62ed95c699fb408a11b3bd8152b9d062c0ed2) --- src/adapters/devin.ts | 2 ++ .../devin-anthropic-signature-fallback.test.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index 28cefe2e761..2f251141b53 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -810,6 +810,8 @@ export function createDevinAdapter( yield* held.splice(0); throw error; } + // Emitted first so the counts survive a retry that reports no usage or fails early. + if (refusedUsage) yield refusedUsage; for await (const event of request(unsignedMessages)) { yield event.kind === "usage" && refusedUsage ? addDevinUsage(event, refusedUsage) : event; } diff --git a/tests/providers/devin-anthropic-signature-fallback.test.ts b/tests/providers/devin-anthropic-signature-fallback.test.ts index 0497236d2dc..4d9f2e54f7c 100644 --- a/tests/providers/devin-anthropic-signature-fallback.test.ts +++ b/tests/providers/devin-anthropic-signature-fallback.test.ts @@ -131,6 +131,21 @@ describe("Devin Anthropic signature fallback", () => { expect(done?.usage?.outputTokens).toBe(60); }); + test("the refused attempt's usage survives a retry with no usage frame or an early failure", async () => { + responses = ["usage-reasoning-then-refuse", "ok"]; + const done = (await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium")) + .find(e => e.type === "done") as { usage?: { inputTokens?: number; outputTokens?: number } } | undefined; + expect(done?.usage?.inputTokens).toBe(1000); + expect(done?.usage?.outputTokens).toBe(40); + + requests = []; + responses = ["usage-reasoning-then-refuse", "refuse"]; + const failed = (await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium")) + .find(e => e.type === "error") as { usage?: { inputTokens?: number } } | undefined; + expect(requests).toHaveLength(2); + expect(failed?.usage?.inputTokens).toBe(1000); + }); + test("held reasoning emits heartbeats, never the held events", async () => { // Each clock read advances 20s, so every held frame is past the heartbeat interval. let clock = Date.now(); From 6560ca24ce4b44cc560956c1b7c49fbf1b262324 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 17:33:12 +0900 Subject: [PATCH 10/20] fix(devin): pair late signatures only with adjacent reasoning Co-authored-by: Sayo --- src/adapters/devin/reasoning-signature.ts | 8 +++++++- .../devin-reasoning-continuation.test.ts | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/adapters/devin/reasoning-signature.ts b/src/adapters/devin/reasoning-signature.ts index 1071c87e71c..a8e932c6c9c 100644 --- a/src/adapters/devin/reasoning-signature.ts +++ b/src/adapters/devin/reasoning-signature.ts @@ -63,8 +63,14 @@ export function devinAssistantReasoning( let stored: string | undefined; if (textBlocks.length === 1 && isProviderIssuedThinkingSignature(textBlocks[0]!.signature)) { stored = textBlocks[0]!.signature; - } else if (textBlocks.length <= 1 && signatureOnly.length === 1) { + } else if (textBlocks.length === 0 && signatureOnly.length === 1) { stored = signatureOnly[0]!.signature; + } else if (textBlocks.length === 1 && signatureOnly.length === 1) { + // Only the late trailer shape attests this text. The Responses parser can + // fold reasoning around a call into one assistant message, so counting + // blocks without checking their position can attach an unrelated signature. + const textIndex = message.content.indexOf(textBlocks[0]!); + if (message.content[textIndex + 1] === signatureOnly[0]) stored = signatureOnly[0]!.signature; } let decoded = stored ? decodeDevinSignature(stored) : undefined; if (decoded && withholdAnthropic && signatureTypeFor(decoded, modelId) === "anthropic") decoded = undefined; diff --git a/tests/providers/devin-reasoning-continuation.test.ts b/tests/providers/devin-reasoning-continuation.test.ts index 58a76d9411b..32dbc1548b0 100644 --- a/tests/providers/devin-reasoning-continuation.test.ts +++ b/tests/providers/devin-reasoning-continuation.test.ts @@ -58,6 +58,22 @@ describe("Devin reasoning continuation across turns", () => { expect(wire.get(18)).toBe("sealed"); }); + test("a signature separated from its text by a call is not paired", () => { + const history = mapOcxMessagesToDevin(parseRequest({ + model: "devin/swe-2", + input: [ + { role: "user", content: [{ type: "input_text", text: "go" }] }, + { type: "reasoning", id: "rs_text", summary: [{ type: "summary_text", text: "earlier thought" }] }, + { type: "function_call", call_id: "call_1", name: "get_time", arguments: "{}" }, + { type: "reasoning", id: "rs_unrelated", summary: [], encrypted_content: encodeReasoningEnvelope({ sig: encodeDevinSignature(SEALED, "sealed") }) }, + { type: "function_call_output", call_id: "call_1", output: "12:00" }, + ], + })); + const assistant = history.find(m => m.role === "assistant"); + expect(assistant?.thinking).toBe("earlier thought"); + expect(assistant?.signature).toBeUndefined(); + }); + test("a signature-only turn is replayed instead of dropped", () => { const openaiSig = '[{"id":"rs_1","encrypted_content":"opaque"}]'; const history = mapOcxMessagesToDevin(parseRequest({ From df4a3105fb5b1275f9c0c87228f4bdaa46368a9a Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 17:34:17 +0900 Subject: [PATCH 11/20] fix(devin): heartbeat while signed reasoning waits for trailer Co-authored-by: Sayo --- src/adapters/devin.ts | 15 +++-- ...devin-anthropic-signature-fallback.test.ts | 55 ++++++++++++++----- 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index 2f251141b53..db8e57d1a0c 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -784,13 +784,18 @@ export function createDevinAdapter( // added to every usage frame of the retry (frames are cumulative per request). let refusedUsage: Extract | undefined; let visible = false; - let lastHeartbeat = Date.now(); + // The iterator may pause before a trailer. A timer feeds the bridge during that + // pause without starting another upstream read or marking replay unsafe. + const heartbeatTimer = setInterval(() => { + if (!visible) emit({ type: "heartbeat" }); + }, HELD_REASONING_HEARTBEAT_MS); try { for await (const event of request(signedMessages)) { // Only visible output makes a retry unsafe. Live, the refusal often lands after the // model has streamed its reasoning, its signature and a finish frame, and nothing else. if (!visible && (event.kind === "text" || event.kind === "tool_call_start" || event.kind === "tool_call_args")) { visible = true; + clearInterval(heartbeatTimer); yield* held.splice(0); } if (visible) { @@ -799,23 +804,21 @@ export function createDevinAdapter( } held.push(event); if (event.kind === "usage") refusedUsage = event; - // Held reasoning must not look like a stalled upstream to the bridge. - if (Date.now() - lastHeartbeat >= HELD_REASONING_HEARTBEAT_MS) { - lastHeartbeat = Date.now(); - emit({ type: "heartbeat" }); - } } } catch (error) { if (visible || !(error instanceof CloudChatError && error.code === "invalid_argument")) { yield* held.splice(0); throw error; } + clearInterval(heartbeatTimer); // Emitted first so the counts survive a retry that reports no usage or fails early. if (refusedUsage) yield refusedUsage; for await (const event of request(unsignedMessages)) { yield event.kind === "usage" && refusedUsage ? addDevinUsage(event, refusedUsage) : event; } return; + } finally { + clearInterval(heartbeatTimer); } yield* held.splice(0); } diff --git a/tests/providers/devin-anthropic-signature-fallback.test.ts b/tests/providers/devin-anthropic-signature-fallback.test.ts index 4d9f2e54f7c..52a2ad675bf 100644 --- a/tests/providers/devin-anthropic-signature-fallback.test.ts +++ b/tests/providers/devin-anthropic-signature-fallback.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test"; import { createDevinAdapter } from "../../src/adapters/devin"; import { setCachedCatalogForTests } from "../../src/adapters/devin/cloud-direct/catalog"; import { devinCacheIdentity, invalidateSessionIdentity } from "../../src/adapters/devin/cloud-direct/chat"; @@ -42,7 +42,7 @@ describe("Devin Anthropic signature fallback", () => { return { thinking: byNum.get(11), signature: byNum.get(12) }; } - async function run(signature: string, modelId: string): Promise { + async function run(signature: string, modelId: string, observed?: AdapterEvent[]): Promise { const parsed = parseRequest({ model: `devin/${modelId}`, input: [ @@ -55,7 +55,7 @@ describe("Devin Anthropic signature fallback", () => { parsed.modelId = modelId; const adapter = createDevinAdapter({ adapter: "devin", apiKey, baseUrl: host }); const events: AdapterEvent[] = []; - await adapter.runTurn!(parsed, { headers: new Headers(), translatorBudget: createTranslatorBudget() }, event => { events.push(event); }); + await adapter.runTurn!(parsed, { headers: new Headers(), translatorBudget: createTranslatorBudget() }, event => { events.push(event); observed?.push(event); }); return events; } @@ -146,19 +146,46 @@ describe("Devin Anthropic signature fallback", () => { expect(failed?.usage?.inputTokens).toBe(1000); }); - test("held reasoning emits heartbeats, never the held events", async () => { - // Each clock read advances 20s, so every held frame is past the heartbeat interval. - let clock = Date.now(); - const now = spyOn(Date, "now").mockImplementation(() => (clock += 20_000)); + test("a held signed attempt sends a plain heartbeat while the upstream trailer is paused", async () => { + const started = Promise.withResolvers(); + const releaseTrailer = Promise.withResolvers(); + const regularFetch = globalThis.fetch; + const observed: AdapterEvent[] = []; + let first = true; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (!first) return regularFetch(input, init); + first = false; + requests.push(Buffer.from(await (init!.body as Blob).arrayBuffer()).subarray(5)); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(frame(encodeString(9, "held thinking"))); + started.resolve(); + void releaseTrailer.promise.then(() => { + controller.enqueue(refusal); + controller.close(); + }); + }, + }); + return new Response(body, { headers: { "content-type": "application/connect+proto" } }); + }) as typeof fetch; + jest.useFakeTimers(); try { - responses = ["usage-reasoning-then-refuse", "ok"]; - const events = await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium"); - const kinds = events.map(e => e.type); - expect(kinds.filter(k => k === "heartbeat").length).toBeGreaterThan(0); - expect(kinds).not.toContain("thinking_delta"); - expect(kinds.indexOf("heartbeat")).toBeLessThan(kinds.indexOf("text_delta")); + const pending = run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium", observed); + await started.promise; + jest.advanceTimersByTime(15_000); + expect(observed).toContainEqual({ type: "heartbeat" }); + expect(observed.some(e => e.type === "thinking_delta")).toBe(false); + releaseTrailer.resolve(); + const events = await pending; + expect(requests).toHaveLength(2); + expect(events).toContainEqual({ type: "text_delta", text: "ok" }); + const count = events.filter(e => e.type === "heartbeat").length; + jest.advanceTimersByTime(30_000); + expect(observed.filter(e => e.type === "heartbeat")).toHaveLength(count); } finally { - now.mockRestore(); + releaseTrailer.resolve(); + jest.clearAllTimers(); + jest.useRealTimers(); } }); From 73e7c7214e22149c4a13ab28156cf01af1396a87 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 17:34:50 +0900 Subject: [PATCH 12/20] fix(devin): bound held signed reasoning before streaming Co-authored-by: Sayo --- src/adapters/devin.ts | 10 ++++++++++ ...devin-anthropic-signature-fallback.test.ts | 20 ++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index db8e57d1a0c..115bc406754 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -57,6 +57,9 @@ const DEVIN_CLIENT_CLOSED_MESSAGE = "client closed request"; /** Below the bridge's upstream stall deadline, so held reasoning never reads as a stall. */ const HELD_REASONING_HEARTBEAT_MS = 15_000; +/** Once either limit is crossed, forward the signed attempt and disable fallback. */ +const HELD_REASONING_MAX_EVENTS = 1_024; +const HELD_REASONING_MAX_TEXT_BYTES = 1024 * 1024; type DevinUsageEvent = Extract; @@ -784,6 +787,7 @@ export function createDevinAdapter( // added to every usage frame of the retry (frames are cumulative per request). let refusedUsage: Extract | undefined; let visible = false; + let heldTextBytes = 0; // The iterator may pause before a trailer. A timer feeds the bridge during that // pause without starting another upstream read or marking replay unsafe. const heartbeatTimer = setInterval(() => { @@ -804,6 +808,12 @@ export function createDevinAdapter( } held.push(event); if (event.kind === "usage") refusedUsage = event; + if (event.kind === "reasoning") heldTextBytes += event.text.length * 2; + if (held.length > HELD_REASONING_MAX_EVENTS || heldTextBytes > HELD_REASONING_MAX_TEXT_BYTES) { + visible = true; + clearInterval(heartbeatTimer); + yield* held.splice(0); + } } } catch (error) { if (visible || !(error instanceof CloudChatError && error.code === "invalid_argument")) { diff --git a/tests/providers/devin-anthropic-signature-fallback.test.ts b/tests/providers/devin-anthropic-signature-fallback.test.ts index 52a2ad675bf..2c5eba7bda4 100644 --- a/tests/providers/devin-anthropic-signature-fallback.test.ts +++ b/tests/providers/devin-anthropic-signature-fallback.test.ts @@ -24,7 +24,7 @@ describe("Devin Anthropic signature fallback", () => { const previousFetch = globalThis.fetch; let home = ""; let requests: Buffer[] = []; - let responses: Array<"refuse" | "ok" | "text-then-refuse" | "reasoning-then-refuse" | "reasoning-then-ok" | "usage-reasoning-then-refuse" | "usage-ok"> = []; + let responses: Array<"refuse" | "ok" | "text-then-refuse" | "reasoning-then-refuse" | "reasoning-then-ok" | "usage-reasoning-then-refuse" | "usage-ok" | "many-reasoning-then-refuse" | "large-reasoning-then-refuse"> = []; const frame = (body: Buffer, flags = 0) => { const header = Buffer.alloc(5); @@ -77,6 +77,8 @@ describe("Devin Anthropic signature fallback", () => { // ModelUsageStats (#7) arrives with the reasoning, before the refusal trailer. : next === "usage-reasoning-then-refuse" ? Buffer.concat([frame(Buffer.concat([encodeMessage(7, Buffer.concat([encodeVarintField(2, 1000), encodeVarintField(3, 40)])), encodeString(9, "thinking")])), frame(encodeString(9, " more")), refusal]) : next === "usage-ok" ? Buffer.concat([frame(Buffer.concat([encodeMessage(7, Buffer.concat([encodeVarintField(2, 1100), encodeVarintField(3, 20)])), encodeString(3, "ok"), encodeVarintField(5, 2)])), frame(Buffer.from("{}"), 2)]) + : next === "many-reasoning-then-refuse" ? Buffer.concat([frame(encodeString(9, "x")), ...Array.from({ length: 1_024 }, () => frame(encodeString(9, "x"))), refusal]) + : next === "large-reasoning-then-refuse" ? Buffer.concat([frame(encodeString(9, "x".repeat(524_289))), refusal]) : ok; return new Response(body, { headers: { "content-type": "application/connect+proto" } }); }) as typeof fetch; @@ -189,6 +191,22 @@ describe("Devin Anthropic signature fallback", () => { } }); + test("a held event count above the cap flushes and disables unsigned retry", async () => { + responses = ["many-reasoning-then-refuse", "ok"]; + const events = await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium"); + expect(requests).toHaveLength(1); + expect(events.some(e => e.type === "thinking_delta")).toBe(true); + expect(events.some(e => e.type === "error")).toBe(true); + }); + + test("a held reasoning text budget above the cap flushes and disables unsigned retry", async () => { + responses = ["large-reasoning-then-refuse", "ok"]; + const events = await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium"); + expect(requests).toHaveLength(1); + expect(events.some(e => e.type === "thinking_delta")).toBe(true); + expect(events.some(e => e.type === "error")).toBe(true); + }); + test("an accepted signed Claude turn is sent once, signature included", async () => { responses = ["ok"]; await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium"); From 9043ef4793b18519939b2b2c4037127b0d02e662 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 17:35:12 +0900 Subject: [PATCH 13/20] test(devin): retry signed refusal before history overflow classification Co-authored-by: Sayo --- ...devin-anthropic-signature-fallback.test.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/providers/devin-anthropic-signature-fallback.test.ts b/tests/providers/devin-anthropic-signature-fallback.test.ts index 2c5eba7bda4..f2a45ab6274 100644 --- a/tests/providers/devin-anthropic-signature-fallback.test.ts +++ b/tests/providers/devin-anthropic-signature-fallback.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test"; import { createDevinAdapter } from "../../src/adapters/devin"; -import { setCachedCatalogForTests } from "../../src/adapters/devin/cloud-direct/catalog"; +import { parseCatalogBuffer, setCachedCatalogForTests } from "../../src/adapters/devin/cloud-direct/catalog"; import { devinCacheIdentity, invalidateSessionIdentity } from "../../src/adapters/devin/cloud-direct/chat"; import { encodeMessage, encodeString, encodeVarintField, iterFields } from "../../src/adapters/devin/cloud-direct/wire"; import { encodeDevinSignature } from "../../src/adapters/devin/reasoning-signature"; @@ -42,11 +42,11 @@ describe("Devin Anthropic signature fallback", () => { return { thinking: byNum.get(11), signature: byNum.get(12) }; } - async function run(signature: string, modelId: string, observed?: AdapterEvent[]): Promise { + async function run(signature: string, modelId: string, observed?: AdapterEvent[], userText = "go"): Promise { const parsed = parseRequest({ model: `devin/${modelId}`, input: [ - { role: "user", content: [{ type: "input_text", text: "go" }] }, + { role: "user", content: [{ type: "input_text", text: userText }] }, { type: "reasoning", id: "rs", summary: [], encrypted_content: encodeReasoningEnvelope({ txt: "summarised thought", sig: signature }) }, { type: "function_call", call_id: "call_1", name: "get_time", arguments: "{}" }, { type: "function_call_output", call_id: "call_1", output: "12:00" }, @@ -102,6 +102,20 @@ describe("Devin Anthropic signature fallback", () => { expect(events).toContainEqual({ type: "text_delta", text: "ok" }); }); + test("a signed refusal at 95% of the catalog window retries unsigned before overflow classification", async () => { + const modelId = "claude-opus-5-5-medium"; + setCachedCatalogForTests(parseCatalogBuffer(encodeMessage(1, Buffer.concat([ + encodeString(1, modelId), encodeString(22, modelId), encodeVarintField(18, 200_000), encodeVarintField(4, 0), + ])), apiKey, host)); + responses = ["refuse", "ok"]; + const events = await run(encodeDevinSignature("EpcBClaude", "anthropic"), modelId, undefined, "word ".repeat(190_000)); + expect(requests).toHaveLength(2); + expect(assistantSignature(requests[0]!).signature).toBe("EpcBClaude"); + expect(assistantSignature(requests[1]!).signature).toBeUndefined(); + expect(events).toContainEqual({ type: "text_delta", text: "ok" }); + expect(events.some(e => e.type === "error" && e.code === "context_length_exceeded")).toBe(false); + }); + test("a refusal after reasoning alone is still retried, and the refused attempt's reasoning never reaches the client", async () => { responses = ["reasoning-then-refuse", "ok"]; const events = await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium"); From acab73c57e87db6edefd0864a8a36267936143d9 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 17:35:27 +0900 Subject: [PATCH 14/20] docs(devin): explain reasoning continuity and fallback bounds Co-authored-by: Sayo --- docs-site/src/content/docs/reference/adapters.md | 3 +++ structure/providers-and-adapters.md | 2 ++ tests/providers/devin-anthropic-signature-fallback.test.ts | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 78c0338679d..3f26fd859f6 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -560,6 +560,9 @@ configuration that names the old id is rewritten at startup. - Uses `runTurn` rather than the ordinary fetch/parse path. Requests and server events are encoded with manual protobuf framing in `devin/cloud-direct/wire.ts`; the ordinary `buildRequest` / `parseStream` path is disabled. +- Reasoning continuity carries provider signatures across turns. If Cognition refuses a signed + Anthropic replay before visible output, Devin retries once with the signature withheld and the + thinking text preserved. - Live model discovery via `GetCascadeModelConfigs`; the static seed is filtered against the account's live roster so models not on the plan drop out instead of failing at request time. - Tool definitions are encoded in the request and tool-call events are decoded from the response diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index ffcad7aa05b..9203a032bd4 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -139,6 +139,8 @@ rewrite rules and the routed-id settlement. | `src/adapters/azure.ts` | Azure OpenAI bridge. | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | | `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort selects the family's default member, an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays split into per-type `anyOf` branches in tool parameters while preserving outer enum/const and existing null-branch restrictions. A pre-output `invalid_argument` on a history whose word-piece estimate reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. | +Devin pairs a late signature only with immediately preceding unsigned thinking in one assistant message; a call between them breaks the pair. While a signed attempt is held for an optional unsigned retry, a timer emits plain heartbeats even when the upstream stalls. The held queue is capped at 1,024 events or approximately 1 MiB of UTF-16 reasoning text; crossing either cap releases the events and disables that retry. A signed `invalid_argument` refusal is offered the unsigned retry before the history-overflow classifier sees any final refusal. + | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. | | `src/adapters/command-code.ts`, `src/adapters/command-code-tool-text.ts`, `src/adapters/command-code-restored-schema.ts` | Command Code OAuth NDJSON translation. For every `xiaomi/mimo-` model, text, native calls, reasoning, and terminal decisions share one byte-bounded queue with linear queue visits. Markup is deduplicated against matching native calls; text-only restoration requires one contiguous bare text block, a clean finish, a declared tool, and arguments validated against supported schema constraints. A parameter-free (freeform) block may omit `` but must end with ``; parameter blocks keep the canonical close. Markup appended after prose, including a marker in a later delta of the same text block, is held as a tail that can never mint a call: possible trailing marker prefixes stay in the same byte-accounted probe across deltas and release as text on a mismatch, boundary or end; a same-content native call strips a completed envelope as an echo, and anything else releases as presentation text so quoted examples stay inert. A tail waits only while a native input it could echo is open, counting the first later input of its own tool, so it is released as soon as those close without a match. Native, reasoning, and other intervening events interrupt a still-probing block but leave a held block held in arrival order, and the queued byte bound still flushes an unresolved envelope as text. An envelope the strict parser rejects but that opens with ``, closes with ``, and names a declared function is dropped when a native call for that same function arrives and on a clean finish; markup that parses but fits no supported schema is still released as text. Regex patterns, other unsupported constraints, and abnormal finishes fail closed. `tests/providers/command-code-tool-text-prose-split.test.ts` covers the prose boundary, the interleaved-event hold, and both drop paths. | diff --git a/tests/providers/devin-anthropic-signature-fallback.test.ts b/tests/providers/devin-anthropic-signature-fallback.test.ts index f2a45ab6274..af4a90056a2 100644 --- a/tests/providers/devin-anthropic-signature-fallback.test.ts +++ b/tests/providers/devin-anthropic-signature-fallback.test.ts @@ -169,7 +169,7 @@ describe("Devin Anthropic signature fallback", () => { const observed: AdapterEvent[] = []; let first = true; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - if (!first) return regularFetch(input, init); + if (!String(input).endsWith("/GetChatMessage") || !first) return regularFetch(input, init); first = false; requests.push(Buffer.from(await (init!.body as Blob).arrayBuffer()).subarray(5)); const body = new ReadableStream({ From 279eaf768c82f36208635c6d06311b3393510cdd Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 17:37:15 +0900 Subject: [PATCH 15/20] fix(devin): include held signatures in payload bound Co-authored-by: Sayo --- src/adapters/devin.ts | 9 +++++---- structure/providers-and-adapters.md | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index 115bc406754..fa97b381c40 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -59,7 +59,7 @@ const DEVIN_CLIENT_CLOSED_MESSAGE = "client closed request"; const HELD_REASONING_HEARTBEAT_MS = 15_000; /** Once either limit is crossed, forward the signed attempt and disable fallback. */ const HELD_REASONING_MAX_EVENTS = 1_024; -const HELD_REASONING_MAX_TEXT_BYTES = 1024 * 1024; +const HELD_REASONING_MAX_PAYLOAD_BYTES = 1024 * 1024; type DevinUsageEvent = Extract; @@ -787,7 +787,7 @@ export function createDevinAdapter( // added to every usage frame of the retry (frames are cumulative per request). let refusedUsage: Extract | undefined; let visible = false; - let heldTextBytes = 0; + let heldPayloadBytes = 0; // The iterator may pause before a trailer. A timer feeds the bridge during that // pause without starting another upstream read or marking replay unsafe. const heartbeatTimer = setInterval(() => { @@ -808,8 +808,9 @@ export function createDevinAdapter( } held.push(event); if (event.kind === "usage") refusedUsage = event; - if (event.kind === "reasoning") heldTextBytes += event.text.length * 2; - if (held.length > HELD_REASONING_MAX_EVENTS || heldTextBytes > HELD_REASONING_MAX_TEXT_BYTES) { + if (event.kind === "reasoning") heldPayloadBytes += event.text.length * 2; + if (event.kind === "reasoning_signature") heldPayloadBytes += event.signature.length * 2; + if (held.length > HELD_REASONING_MAX_EVENTS || heldPayloadBytes > HELD_REASONING_MAX_PAYLOAD_BYTES) { visible = true; clearInterval(heartbeatTimer); yield* held.splice(0); diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 9203a032bd4..d8ae0f6e5ea 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -139,7 +139,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/azure.ts` | Azure OpenAI bridge. | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | | `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort selects the family's default member, an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays split into per-type `anyOf` branches in tool parameters while preserving outer enum/const and existing null-branch restrictions. A pre-output `invalid_argument` on a history whose word-piece estimate reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. | -Devin pairs a late signature only with immediately preceding unsigned thinking in one assistant message; a call between them breaks the pair. While a signed attempt is held for an optional unsigned retry, a timer emits plain heartbeats even when the upstream stalls. The held queue is capped at 1,024 events or approximately 1 MiB of UTF-16 reasoning text; crossing either cap releases the events and disables that retry. A signed `invalid_argument` refusal is offered the unsigned retry before the history-overflow classifier sees any final refusal. +Devin pairs a late signature only with immediately preceding unsigned thinking in one assistant message; a call between them breaks the pair. While a signed attempt is held for an optional unsigned retry, a timer emits plain heartbeats even when the upstream stalls. The held queue is capped at 1,024 events or approximately 1 MiB of UTF-16 reasoning/signature payload; crossing either cap releases the events and disables that retry. A signed `invalid_argument` refusal is offered the unsigned retry before the history-overflow classifier sees any final refusal. | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. | From bb9cab73de08d4e525bdbdf15e815364c571a133 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 17:37:41 +0900 Subject: [PATCH 16/20] docs(devin): keep adapter inventory table intact Co-authored-by: Sayo --- structure/providers-and-adapters.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index d8ae0f6e5ea..a8d81b23b65 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -139,14 +139,14 @@ rewrite rules and the routed-id settlement. | `src/adapters/azure.ts` | Azure OpenAI bridge. | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | | `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort selects the family's default member, an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays split into per-type `anyOf` branches in tool parameters while preserving outer enum/const and existing null-branch restrictions. A pre-output `invalid_argument` on a history whose word-piece estimate reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. | -Devin pairs a late signature only with immediately preceding unsigned thinking in one assistant message; a call between them breaks the pair. While a signed attempt is held for an optional unsigned retry, a timer emits plain heartbeats even when the upstream stalls. The held queue is capped at 1,024 events or approximately 1 MiB of UTF-16 reasoning/signature payload; crossing either cap releases the events and disables that retry. A signed `invalid_argument` refusal is offered the unsigned retry before the history-overflow classifier sees any final refusal. - | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. | | `src/adapters/command-code.ts`, `src/adapters/command-code-tool-text.ts`, `src/adapters/command-code-restored-schema.ts` | Command Code OAuth NDJSON translation. For every `xiaomi/mimo-` model, text, native calls, reasoning, and terminal decisions share one byte-bounded queue with linear queue visits. Markup is deduplicated against matching native calls; text-only restoration requires one contiguous bare text block, a clean finish, a declared tool, and arguments validated against supported schema constraints. A parameter-free (freeform) block may omit `` but must end with ``; parameter blocks keep the canonical close. Markup appended after prose, including a marker in a later delta of the same text block, is held as a tail that can never mint a call: possible trailing marker prefixes stay in the same byte-accounted probe across deltas and release as text on a mismatch, boundary or end; a same-content native call strips a completed envelope as an echo, and anything else releases as presentation text so quoted examples stay inert. A tail waits only while a native input it could echo is open, counting the first later input of its own tool, so it is released as soon as those close without a match. Native, reasoning, and other intervening events interrupt a still-probing block but leave a held block held in arrival order, and the queued byte bound still flushes an unresolved envelope as text. An envelope the strict parser rejects but that opens with ``, closes with ``, and names a declared function is dropped when a native call for that same function arrives and on a clean finish; markup that parses but fits no supported schema is still released as text. Regex patterns, other unsupported constraints, and abnormal finishes fail closed. `tests/providers/command-code-tool-text-prose-split.test.ts` covers the prose boundary, the interleaved-event hold, and both drop paths. | | `src/adapters/image.ts`, `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts`, `src/adapters/anthropic-image-codec.ts` | Image conversion for adapter ingress and Anthropic-specific normalization/limits. An image's ladder position is pinned to its own identity (content hash + media type), so appending a newer image cannot re-encode older ones and bust Anthropic's prompt prefix cache (#4532). | | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/upstream-http-error.ts` | Shared adapter execution support: turn queueing, tool-catalog nudging, client identity, upstream error normalization. | +Devin pairs a late signature only with immediately preceding unsigned thinking in one assistant message; a call between them breaks the pair. While a signed attempt is held for an optional unsigned retry, a timer emits plain heartbeats even when the upstream stalls. The held queue is capped at 1,024 events or approximately 1 MiB of UTF-16 reasoning/signature payload; crossing either cap releases the events and disables that retry. A signed `invalid_argument` refusal is offered the unsigned retry before the history-overflow classifier sees any final refusal. + Inline document admission shares one encoding predicate between its scanner and parser in `src/responses/inline-document.ts`: malformed base64 quantum/padding lengths are refused, and valid padded or unpadded payloads pass unchanged without a decoding allocation. From 0740ff75e6e160f238f7ae0f2a657f92930f222e Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 17:38:01 +0900 Subject: [PATCH 17/20] fix(devin): stop held heartbeat before release on error Co-authored-by: Sayo --- src/adapters/devin.ts | 2 +- tests/providers/devin-anthropic-signature-fallback.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index fa97b381c40..f1412f58674 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -817,11 +817,11 @@ export function createDevinAdapter( } } } catch (error) { + clearInterval(heartbeatTimer); if (visible || !(error instanceof CloudChatError && error.code === "invalid_argument")) { yield* held.splice(0); throw error; } - clearInterval(heartbeatTimer); // Emitted first so the counts survive a retry that reports no usage or fails early. if (refusedUsage) yield refusedUsage; for await (const event of request(unsignedMessages)) { diff --git a/tests/providers/devin-anthropic-signature-fallback.test.ts b/tests/providers/devin-anthropic-signature-fallback.test.ts index af4a90056a2..fb147ba39a2 100644 --- a/tests/providers/devin-anthropic-signature-fallback.test.ts +++ b/tests/providers/devin-anthropic-signature-fallback.test.ts @@ -188,7 +188,7 @@ describe("Devin Anthropic signature fallback", () => { try { const pending = run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium", observed); await started.promise; - jest.advanceTimersByTime(15_000); + jest.advanceTimersByTime(20_000); expect(observed).toContainEqual({ type: "heartbeat" }); expect(observed.some(e => e.type === "thinking_delta")).toBe(false); releaseTrailer.resolve(); From 82ffab36f0ee32017d204fb35df473c1b2d21f49 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 17:48:56 +0900 Subject: [PATCH 18/20] docs(structure): carry the model and wire rules into the restacked Devin row Co-authored-by: Sayo --- structure/providers-and-adapters.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index a8d81b23b65..84e88a2b26a 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -138,7 +138,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge. | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | -| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort selects the family's default member, an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays split into per-type `anyOf` branches in tool parameters while preserving outer enum/const and existing null-branch restrictions. A pre-output `invalid_argument` on a history whose word-piece estimate reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. | +| `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort anchors on the family's default member and selects the nearest enabled rung, rounding up first; an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays split into per-type `anyOf` branches in tool parameters while preserving outer enum/const, boolean constraints (including `not`, `oneOf`, and `allOf`) on null, and existing null-branch restrictions. A pre-output `invalid_argument` on a history whose word-piece estimate, using the sanitized and truncated tool descriptions actually sent on the wire, reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. | | `src/adapters/command-code.ts`, `src/adapters/command-code-tool-text.ts`, `src/adapters/command-code-restored-schema.ts` | Command Code OAuth NDJSON translation. For every `xiaomi/mimo-` model, text, native calls, reasoning, and terminal decisions share one byte-bounded queue with linear queue visits. Markup is deduplicated against matching native calls; text-only restoration requires one contiguous bare text block, a clean finish, a declared tool, and arguments validated against supported schema constraints. A parameter-free (freeform) block may omit `` but must end with ``; parameter blocks keep the canonical close. Markup appended after prose, including a marker in a later delta of the same text block, is held as a tail that can never mint a call: possible trailing marker prefixes stay in the same byte-accounted probe across deltas and release as text on a mismatch, boundary or end; a same-content native call strips a completed envelope as an echo, and anything else releases as presentation text so quoted examples stay inert. A tail waits only while a native input it could echo is open, counting the first later input of its own tool, so it is released as soon as those close without a match. Native, reasoning, and other intervening events interrupt a still-probing block but leave a held block held in arrival order, and the queued byte bound still flushes an unresolved envelope as text. An envelope the strict parser rejects but that opens with ``, closes with ``, and names a declared function is dropped when a native call for that same function arrives and on a clean finish; markup that parses but fits no supported schema is still released as text. Regex patterns, other unsupported constraints, and abnormal finishes fail closed. `tests/providers/command-code-tool-text-prose-split.test.ts` covers the prose boundary, the interleaved-event hold, and both drop paths. | From ff3a613c2f7c851a3bd1e8e248988a86c69d44b0 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 17:54:49 +0900 Subject: [PATCH 19/20] fix(devin): preserve signed refusal when retry budget is exhausted Co-authored-by: Sayo --- src/adapters/devin.ts | 12 +++++- structure/providers-and-adapters.md | 2 +- ...devin-anthropic-signature-fallback.test.ts | 38 ++++++++++++++++++- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index f1412f58674..fcc844091cd 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -824,8 +824,16 @@ export function createDevinAdapter( } // Emitted first so the counts survive a retry that reports no usage or fails early. if (refusedUsage) yield refusedUsage; - for await (const event of request(unsignedMessages)) { - yield event.kind === "usage" && refusedUsage ? addDevinUsage(event, refusedUsage) : event; + try { + for await (const event of request(unsignedMessages)) { + yield event.kind === "usage" && refusedUsage ? addDevinUsage(event, refusedUsage) : event; + } + } catch (retryError) { + if (retryError instanceof SendBudgetExhaustedError) { + incoming.onRecoveryWithheld?.({ reason: "retry-send-budget" }); + throw error; + } + throw retryError; } return; } finally { diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 84e88a2b26a..b4812fa8810 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -145,7 +145,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/image.ts`, `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts`, `src/adapters/anthropic-image-codec.ts` | Image conversion for adapter ingress and Anthropic-specific normalization/limits. An image's ladder position is pinned to its own identity (content hash + media type), so appending a newer image cannot re-encode older ones and bust Anthropic's prompt prefix cache (#4532). | | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/upstream-http-error.ts` | Shared adapter execution support: turn queueing, tool-catalog nudging, client identity, upstream error normalization. | -Devin pairs a late signature only with immediately preceding unsigned thinking in one assistant message; a call between them breaks the pair. While a signed attempt is held for an optional unsigned retry, a timer emits plain heartbeats even when the upstream stalls. The held queue is capped at 1,024 events or approximately 1 MiB of UTF-16 reasoning/signature payload; crossing either cap releases the events and disables that retry. A signed `invalid_argument` refusal is offered the unsigned retry before the history-overflow classifier sees any final refusal. +Devin pairs a late signature only with immediately preceding unsigned thinking in one assistant message; a call between them breaks the pair. While a signed attempt is held for an optional unsigned retry, a timer emits plain heartbeats even when the upstream stalls. The held queue is capped at 1,024 events or approximately 1 MiB of UTF-16 reasoning/signature payload; crossing either cap releases the events and disables that retry. A signed `invalid_argument` refusal is offered the unsigned retry before the history-overflow classifier sees any final refusal. If the send budget withholds that retry, the original refusal reaches the classifier and the recovery is recorded as withheld. Inline document admission shares one encoding predicate between its scanner and parser in `src/responses/inline-document.ts`: malformed base64 quantum/padding lengths are refused, diff --git a/tests/providers/devin-anthropic-signature-fallback.test.ts b/tests/providers/devin-anthropic-signature-fallback.test.ts index fb147ba39a2..b92a0d501d8 100644 --- a/tests/providers/devin-anthropic-signature-fallback.test.ts +++ b/tests/providers/devin-anthropic-signature-fallback.test.ts @@ -3,10 +3,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test"; import { createDevinAdapter } from "../../src/adapters/devin"; +import type { IncomingMeta } from "../../src/adapters/base"; import { parseCatalogBuffer, setCachedCatalogForTests } from "../../src/adapters/devin/cloud-direct/catalog"; import { devinCacheIdentity, invalidateSessionIdentity } from "../../src/adapters/devin/cloud-direct/chat"; import { encodeMessage, encodeString, encodeVarintField, iterFields } from "../../src/adapters/devin/cloud-direct/wire"; import { encodeDevinSignature } from "../../src/adapters/devin/reasoning-signature"; +import { createRequestExecutionBudget } from "../../src/lib/request-execution-budget"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; import { encodeReasoningEnvelope } from "../../src/responses/reasoning-envelope"; import { parseRequest } from "../../src/responses/parser"; @@ -42,7 +44,7 @@ describe("Devin Anthropic signature fallback", () => { return { thinking: byNum.get(11), signature: byNum.get(12) }; } - async function run(signature: string, modelId: string, observed?: AdapterEvent[], userText = "go"): Promise { + async function run(signature: string, modelId: string, observed?: AdapterEvent[], userText = "go", meta: Pick = {}): Promise { const parsed = parseRequest({ model: `devin/${modelId}`, input: [ @@ -55,7 +57,7 @@ describe("Devin Anthropic signature fallback", () => { parsed.modelId = modelId; const adapter = createDevinAdapter({ adapter: "devin", apiKey, baseUrl: host }); const events: AdapterEvent[] = []; - await adapter.runTurn!(parsed, { headers: new Headers(), translatorBudget: createTranslatorBudget() }, event => { events.push(event); observed?.push(event); }); + await adapter.runTurn!(parsed, { headers: new Headers(), translatorBudget: createTranslatorBudget(), ...meta }, event => { events.push(event); observed?.push(event); }); return events; } @@ -116,6 +118,38 @@ describe("Devin Anthropic signature fallback", () => { expect(events.some(e => e.type === "error" && e.code === "context_length_exceeded")).toBe(false); }); + test("an unsigned retry denied by the send budget preserves the signed invalid_argument", async () => { + responses = ["refuse", "ok"]; + const withheld: string[] = []; + const budget = createRequestExecutionBudget({ + maxTotalModelSends: 1, baseSendAllowance: 1, finalRecoveryAllowance: 0, + maxAlternateTargetSends: 0, maxTargetTransitions: 0, + }, "devin-signature-refusal"); + const events = await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium", undefined, "go", { + sendBudget: budget, + onRecoveryWithheld: event => { withheld.push(event.reason); }, + }); + expect(requests).toHaveLength(1); + expect(budget.used).toBe(1); + expect(withheld).toEqual(["retry-send-budget"]); + expect(events.find(e => e.type === "error")).toMatchObject({ type: "error", code: "invalid_argument", status: 400 }); + }); + + test("a budget-withheld unsigned retry still classifies a full signed history as context overflow", async () => { + const modelId = "claude-opus-5-5-medium"; + setCachedCatalogForTests(parseCatalogBuffer(encodeMessage(1, Buffer.concat([ + encodeString(1, modelId), encodeString(22, modelId), encodeVarintField(18, 200_000), encodeVarintField(4, 0), + ])), apiKey, host)); + responses = ["refuse", "ok"]; + const budget = createRequestExecutionBudget({ + maxTotalModelSends: 1, baseSendAllowance: 1, finalRecoveryAllowance: 0, + maxAlternateTargetSends: 0, maxTargetTransitions: 0, + }, "devin-signature-overflow"); + const events = await run(encodeDevinSignature("EpcBClaude", "anthropic"), modelId, undefined, "word ".repeat(190_000), { sendBudget: budget }); + expect(requests).toHaveLength(1); + expect(events.find(e => e.type === "error")).toMatchObject({ type: "error", code: "context_length_exceeded", status: 400 }); + }); + test("a refusal after reasoning alone is still retried, and the refused attempt's reasoning never reaches the client", async () => { responses = ["reasoning-then-refuse", "ok"]; const events = await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium"); From c77a708a7e5431ae648ff36db0642cd4aacd3138 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 17:56:30 +0900 Subject: [PATCH 20/20] fix(devin): merge all held signature-attempt usage frames Co-authored-by: Sayo --- src/adapters/devin.ts | 46 +++++++++++++------ structure/providers-and-adapters.md | 2 +- ...devin-anthropic-signature-fallback.test.ts | 17 ++++++- 3 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index fcc844091cd..0cafb1161fb 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -63,6 +63,30 @@ const HELD_REASONING_MAX_PAYLOAD_BYTES = 1024 * 1024; type DevinUsageEvent = Extract; +function toOcxDevinUsage(event: DevinUsageEvent): OcxUsage { + const total = event.totalTokens ?? ((event.promptTokens ?? 0) + (event.completionTokens ?? 0)); + return { + inputTokens: event.promptTokens ?? 0, + outputTokens: event.completionTokens ?? 0, + ...(total > 0 ? { totalTokens: total } : {}), + ...(event.cachedInputTokens !== undefined ? { cachedInputTokens: event.cachedInputTokens } : {}), + ...(event.cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens: event.cacheCreationInputTokens } : {}), + ...(event.reasoningTokens !== undefined ? { reasoningOutputTokens: event.reasoningTokens } : {}), + }; +} + +function toCloudDevinUsage(usage: OcxUsage): DevinUsageEvent { + return { + kind: "usage", + promptTokens: usage.inputTokens, + completionTokens: usage.outputTokens, + totalTokens: usage.totalTokens, + cachedInputTokens: usage.cachedInputTokens, + cacheCreationInputTokens: usage.cacheCreationInputTokens, + reasoningTokens: usage.reasoningOutputTokens, + }; +} + /** The retry's cumulative usage plus the refused attempt's final counts. */ function addDevinUsage(event: DevinUsageEvent, prior: DevinUsageEvent): DevinUsageEvent { const sum = (a?: number, b?: number) => (a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0)); @@ -785,7 +809,7 @@ export function createDevinAdapter( const held: CloudChatEvent[] = []; // Usage is still real: the refused attempt was processed, so its final counts are // added to every usage frame of the retry (frames are cumulative per request). - let refusedUsage: Extract | undefined; + let refusedUsage: OcxUsage | undefined; let visible = false; let heldPayloadBytes = 0; // The iterator may pause before a trailer. A timer feeds the bridge during that @@ -807,7 +831,10 @@ export function createDevinAdapter( continue; } held.push(event); - if (event.kind === "usage") refusedUsage = event; + if (event.kind === "usage") { + const next = toOcxDevinUsage(event); + refusedUsage = refusedUsage ? mergeDevinUsage(refusedUsage, next) : next; + } if (event.kind === "reasoning") heldPayloadBytes += event.text.length * 2; if (event.kind === "reasoning_signature") heldPayloadBytes += event.signature.length * 2; if (held.length > HELD_REASONING_MAX_EVENTS || heldPayloadBytes > HELD_REASONING_MAX_PAYLOAD_BYTES) { @@ -823,10 +850,11 @@ export function createDevinAdapter( throw error; } // Emitted first so the counts survive a retry that reports no usage or fails early. - if (refusedUsage) yield refusedUsage; + const cumulativeRefusedUsage = refusedUsage ? toCloudDevinUsage(refusedUsage) : undefined; + if (cumulativeRefusedUsage) yield cumulativeRefusedUsage; try { for await (const event of request(unsignedMessages)) { - yield event.kind === "usage" && refusedUsage ? addDevinUsage(event, refusedUsage) : event; + yield event.kind === "usage" && cumulativeRefusedUsage ? addDevinUsage(event, cumulativeRefusedUsage) : event; } } catch (retryError) { if (retryError instanceof SendBudgetExhaustedError) { @@ -891,15 +919,7 @@ export function createDevinAdapter( continue; } if (event.kind === "usage") { - const total = event.totalTokens ?? ((event.promptTokens ?? 0) + (event.completionTokens ?? 0)); - const next: OcxUsage = { - inputTokens: event.promptTokens ?? 0, - outputTokens: event.completionTokens ?? 0, - ...(total > 0 ? { totalTokens: total } : {}), - ...(event.cachedInputTokens !== undefined ? { cachedInputTokens: event.cachedInputTokens } : {}), - ...(event.cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens: event.cacheCreationInputTokens } : {}), - ...(event.reasoningTokens !== undefined ? { reasoningOutputTokens: event.reasoningTokens } : {}), - }; + const next = toOcxDevinUsage(event); // Merge rather than replace. A turn can carry more than one usage // frame, and the counters are cumulative, so a later partial frame // that omits a field used to zero a count the earlier frame had diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index b4812fa8810..1d8c6c4a0ef 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -145,7 +145,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/image.ts`, `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts`, `src/adapters/anthropic-image-codec.ts` | Image conversion for adapter ingress and Anthropic-specific normalization/limits. An image's ladder position is pinned to its own identity (content hash + media type), so appending a newer image cannot re-encode older ones and bust Anthropic's prompt prefix cache (#4532). | | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/upstream-http-error.ts` | Shared adapter execution support: turn queueing, tool-catalog nudging, client identity, upstream error normalization. | -Devin pairs a late signature only with immediately preceding unsigned thinking in one assistant message; a call between them breaks the pair. While a signed attempt is held for an optional unsigned retry, a timer emits plain heartbeats even when the upstream stalls. The held queue is capped at 1,024 events or approximately 1 MiB of UTF-16 reasoning/signature payload; crossing either cap releases the events and disables that retry. A signed `invalid_argument` refusal is offered the unsigned retry before the history-overflow classifier sees any final refusal. If the send budget withholds that retry, the original refusal reaches the classifier and the recovery is recorded as withheld. +Devin pairs a late signature only with immediately preceding unsigned thinking in one assistant message; a call between them breaks the pair. While a signed attempt is held for an optional unsigned retry, a timer emits plain heartbeats even when the upstream stalls. The held queue is capped at 1,024 events or approximately 1 MiB of UTF-16 reasoning/signature payload; crossing either cap releases the events and disables that retry. Held usage frames merge per cumulative field, and the refused attempt's usage is added to the retry. A signed `invalid_argument` refusal is offered the unsigned retry before the history-overflow classifier sees any final refusal. If the send budget withholds that retry, the original refusal reaches the classifier and the recovery is recorded as withheld. Inline document admission shares one encoding predicate between its scanner and parser in `src/responses/inline-document.ts`: malformed base64 quantum/padding lengths are refused, diff --git a/tests/providers/devin-anthropic-signature-fallback.test.ts b/tests/providers/devin-anthropic-signature-fallback.test.ts index b92a0d501d8..47bf34571fb 100644 --- a/tests/providers/devin-anthropic-signature-fallback.test.ts +++ b/tests/providers/devin-anthropic-signature-fallback.test.ts @@ -26,7 +26,7 @@ describe("Devin Anthropic signature fallback", () => { const previousFetch = globalThis.fetch; let home = ""; let requests: Buffer[] = []; - let responses: Array<"refuse" | "ok" | "text-then-refuse" | "reasoning-then-refuse" | "reasoning-then-ok" | "usage-reasoning-then-refuse" | "usage-ok" | "many-reasoning-then-refuse" | "large-reasoning-then-refuse"> = []; + let responses: Array<"refuse" | "ok" | "text-then-refuse" | "reasoning-then-refuse" | "reasoning-then-ok" | "usage-reasoning-then-refuse" | "split-usage-then-refuse" | "usage-ok" | "many-reasoning-then-refuse" | "large-reasoning-then-refuse"> = []; const frame = (body: Buffer, flags = 0) => { const header = Buffer.alloc(5); @@ -78,6 +78,10 @@ describe("Devin Anthropic signature fallback", () => { : next === "reasoning-then-ok" ? Buffer.concat([frame(Buffer.concat([encodeString(9, "thinking"), encodeString(10, "EpcBNew"), encodeString(21, "anthropic")])), ok]) // ModelUsageStats (#7) arrives with the reasoning, before the refusal trailer. : next === "usage-reasoning-then-refuse" ? Buffer.concat([frame(Buffer.concat([encodeMessage(7, Buffer.concat([encodeVarintField(2, 1000), encodeVarintField(3, 40)])), encodeString(9, "thinking")])), frame(encodeString(9, " more")), refusal]) + : next === "split-usage-then-refuse" ? Buffer.concat([ + frame(encodeMessage(7, Buffer.concat([encodeVarintField(2, 1000), encodeVarintField(4, 100), encodeVarintField(5, 600)]))), + frame(encodeMessage(7, encodeVarintField(3, 40))), refusal, + ]) : next === "usage-ok" ? Buffer.concat([frame(Buffer.concat([encodeMessage(7, Buffer.concat([encodeVarintField(2, 1100), encodeVarintField(3, 20)])), encodeString(3, "ok"), encodeVarintField(5, 2)])), frame(Buffer.from("{}"), 2)]) : next === "many-reasoning-then-refuse" ? Buffer.concat([frame(encodeString(9, "x")), ...Array.from({ length: 1_024 }, () => frame(encodeString(9, "x"))), refusal]) : next === "large-reasoning-then-refuse" ? Buffer.concat([frame(encodeString(9, "x".repeat(524_289))), refusal]) @@ -181,6 +185,17 @@ describe("Devin Anthropic signature fallback", () => { expect(done?.usage?.outputTokens).toBe(60); }); + test("partial held usage frames retain input and cache counts when the last frame reports output only", async () => { + responses = ["split-usage-then-refuse", "usage-ok"]; + const events = await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium"); + expect(requests).toHaveLength(2); + const done = events.find(e => e.type === "done"); + expect(done).toMatchObject({ + type: "done", + usage: { inputTokens: 2100, outputTokens: 60, totalTokens: 2160, cachedInputTokens: 600, cacheCreationInputTokens: 100 }, + }); + }); + test("the refused attempt's usage survives a retry with no usage frame or an early failure", async () => { responses = ["usage-reasoning-then-refuse", "ok"]; const done = (await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium"))