Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 30 additions & 9 deletions src/adapters/devin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,26 +311,47 @@ 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,
): { thinking?: string; signature?: string } {
const blocks = message.content.filter(
(part): part is Extract<typeof part, { type: "thinking" }> => 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
Expand Down
7 changes: 7 additions & 0 deletions structure/adapters/registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions tests/providers/devin-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, { wire: number; value: unknown }> {
Expand Down Expand Up @@ -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",
Expand Down
Loading