diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index 082cde39d0d..80070163ac6 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -311,8 +311,11 @@ function assistantText(message: OcxAssistantMessage): string { * clients of the same service write #11 thinking with #12 signature and #18 * signature_type on the assistant prompt. * - * The signature attests the thinking it was produced with, so a block without - * one contributes its text and nothing else rather than borrowing a neighbour's. + * The wire has room for only one thinking/signature pair. Preserve that + * association by replaying the last block with thinking text as a unit rather + * than combining independently signed blocks. A signature-only block attests + * encrypted thinking that is not being replayed, so it cannot sign a + * neighbour's text and is skipped. */ function assistantThinking( message: OcxAssistantMessage, @@ -320,17 +323,35 @@ function assistantThinking( const blocks = message.content.filter( (part): part is Extract => part.type === "thinking", ); - if (blocks.length === 0) return {}; - const thinking = blocks.map(b => b.thinking).filter(Boolean).join("\n"); - // Only one signature can ride the prompt, so take the last block that has - // one: that is the block the turn actually ended on. - const signature = blocks.filter(b => b.signature).at(-1)?.signature; + const block = blocks.findLast(b => Boolean(b.thinking)); + if (!block) return {}; return { - ...(thinking ? { thinking } : {}), - ...(signature ? { signature } : {}), + ...(block.thinking ? { thinking: block.thinking } : {}), + ...(isCognitionReplayableSignature(block.signature) ? { signature: block.signature } : {}), }; } +/** + * Field #12 attests the #11 thinking to Cognition, so it can only carry a + * signature the service (or the source provider's thinking block) actually + * issued. The Responses parser stores a JSON.stringify(reasoningItem) dump on + * an unsigned thinking part so the opaque item survives a same-provider round + * trip; that serialized item is parseable provider state, not an attestation, + * and sending it as the signature hands Cognition a JSON dump where it expects + * its own issued token. + */ +function isCognitionReplayableSignature(signature: string | undefined): signature is string { + if (typeof signature !== "string" || signature.length === 0) return false; + if (!signature.startsWith("{")) return true; + try { + const parsed: unknown = JSON.parse(signature); + return !parsed || typeof parsed !== "object" || Array.isArray(parsed) + || (parsed as { type?: unknown }).type !== "reasoning"; + } catch { + return true; + } +} + export function mapOcxMessagesToDevin(parsed: OcxParsedRequest): ChatHistoryItem[] { const items: ChatHistoryItem[] = []; // Cognition is not an OpenAI host, and this adapter does advertise a real diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 75238008dfe..6d845ae0802 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -52,6 +52,13 @@ Some adapters share another adapter's routed-tool semantics while retaining inde adapter accepts them on return. One tool's canonical identity can be another tool's advertised local name, and resolving that name to either owner would dispatch the call to a tool the caller may not have named, so it is treated as ambiguous and fails before dispatch too. + Assistant reasoning replay likewise follows the Cognition wire shape: because one history prompt + carries only one thinking/signature pair, the adapter selects the final thinking block with text + as a unit and never combines text from one block with another block's signature; a signature-only + block attests encrypted thinking that is not replayed and is skipped rather than paired or sent + alone. The pair's signature is replayed only when the source envelope actually carried one: the + serialized reasoning item the Responses parser parks on unsigned thinking parts is provider + state, not an attestation, so it is dropped at the field boundary rather than sent as #12. There is no second Devin transport. An Agent Client Protocol adapter that spawned a local `devin acp` child once existed under the `devin-cli` adapter id and was removed: the CLI's diff --git a/tests/providers/devin-hardening.test.ts b/tests/providers/devin-hardening.test.ts index 319b1330614..834a6c52fae 100644 --- a/tests/providers/devin-hardening.test.ts +++ b/tests/providers/devin-hardening.test.ts @@ -12,6 +12,8 @@ import { connectTrailerHttpStatus } from "../../src/adapters/devin/cloud-direct/ import { devinErrorClassification, mergeDevinUsage } from "../../src/adapters/devin"; import { iterFields } from "../../src/adapters/devin/cloud-direct/wire"; import { buildMetadata, normalizeDevinSessionToken } from "../../src/adapters/devin/cloud-direct/metadata"; +import { parseRequest } from "../../src/responses/parser"; +import { encodeReasoningEnvelope } from "../../src/responses/reasoning-envelope"; /** Tag -> field for one encoded proto message. */ function iterFieldMap(buf: Buffer): Record { @@ -496,6 +498,98 @@ describe("devin reasoning replay", () => { expect(history.find(m => m.role === "assistant")?.thinking).toBe("only thought"); }); + test("multiple thinking blocks keep the final block's text and signature together", () => { + const history = mapOcxMessagesToDevin(parsedWith([ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "first thought", signature: "sig-first" }, + { type: "thinking", thinking: "final thought", signature: "sig-final" }, + ], + }, + ])); + expect(history[0]?.thinking).toBe("final thought"); + expect(history[0]?.signature).toBe("sig-final"); + + const unsignedLast = mapOcxMessagesToDevin(parsedWith([ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "signed thought", signature: "sig-signed" }, + { type: "thinking", thinking: "unsigned thought" }, + ], + }, + ])); + expect(unsignedLast[0]?.thinking).toBe("unsigned thought"); + expect(unsignedLast[0]?.signature).toBeUndefined(); + }); + + test("a signature-only tail block cannot steal the pair or drop the turn", () => { + // Encrypted-only reasoning parts (thinking: "" + signature) are real: the + // Responses parser emits them for opaque blobs. Picking one as the replay + // unit used to send a signature with no thinking and drop a reasoning-only + // turn outright. Fed through the real parser: direct part injection used to + // bypass the shapes replay actually carries (including the unsigned-item + // signature dump covered below). + const reasoningOnly = mapOcxMessagesToDevin(parseRequest({ + model: "swe-2", + input: [ + { type: "reasoning", id: "rs_signed", summary: [], encrypted_content: encodeReasoningEnvelope({ txt: "signed thought", sig: "sig-signed" }) }, + { type: "reasoning", id: "rs_orphan", summary: [], encrypted_content: encodeReasoningEnvelope({ sig: "sig-orphan" }) }, + ], + })); + expect(reasoningOnly[0]?.thinking).toBe("signed thought"); + expect(reasoningOnly[0]?.signature).toBe("sig-signed"); + + const trailingText = mapOcxMessagesToDevin(parseRequest({ + model: "swe-2", + input: [ + { type: "reasoning", id: "rs_signed", summary: [], encrypted_content: encodeReasoningEnvelope({ txt: "signed thought", sig: "sig-signed" }) }, + { type: "reasoning", id: "rs_orphan", summary: [], encrypted_content: encodeReasoningEnvelope({ sig: "sig-orphan" }) }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "answer" }] }, + ], + })); + const assistant = trailingText.find(m => m.role === "assistant"); + expect(assistant?.thinking).toBe("signed thought"); + expect(assistant?.signature).toBe("sig-signed"); + expect(assistant?.content).toBe("answer"); + }); + + test("an unsigned reasoning part's serialized item is never sent as the signature", () => { + // The parser stores JSON.stringify(reasoningItem) on an unsigned thinking + // part so the opaque item survives a same-provider round trip. Cognition's + // #12 expects the service's own issued token, so the dump must be dropped + // at the field boundary rather than relayed as an attestation. + const parsed = parseRequest({ + model: "swe-2", + input: [ + { type: "reasoning", id: "rs_unsigned", summary: [{ type: "summary_text", text: "unsigned thought" }] }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "answer" }] }, + ], + }); + const thinkingPart = parsed.context.messages + .find(m => m.role === "assistant")?.content + .find(p => p.type === "thinking") as { signature?: string } | undefined; + const dumped = JSON.parse(thinkingPart?.signature ?? "null") as { type?: string } | null; + expect(dumped?.type).toBe("reasoning"); + + const history = mapOcxMessagesToDevin(parsed); + const assistant = history.find(m => m.role === "assistant"); + expect(assistant?.thinking).toBe("unsigned thought"); + expect(assistant?.signature).toBeUndefined(); + + const unsignedReq = buildGetChatMessageRequestForTests({ + apiKey: "devin-session-token$x", + modelUid: "swe-2", + messages: history, + cascadeId: "c", + } as never); + const unsignedPrompts = fieldsOf(unsignedReq)[3] ?? []; + const unsignedPrompt = unsignedPrompts.map(fieldsOf).find(p => p[11]); + expect(unsignedPrompt?.[11]?.[0]?.toString("utf8")).toBe("unsigned thought"); + expect(unsignedPrompt?.[12]).toBeUndefined(); + }); + test("the encoded prompt carries thinking at #11 and its signature at #12", () => { const req = buildGetChatMessageRequestForTests({ apiKey: "devin-session-token$x",