Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/adapters/devin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -836,7 +836,7 @@ export function createDevinAdapter(
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 (event.kind === "reasoning_signature") heldPayloadBytes += (event.signature.length + (event.signatureType?.length ?? 0)) * 2;
if (held.length > HELD_REASONING_MAX_EVENTS || heldPayloadBytes > HELD_REASONING_MAX_PAYLOAD_BYTES) {
visible = true;
clearInterval(heartbeatTimer);
Expand Down
4 changes: 3 additions & 1 deletion src/adapters/devin/cloud-direct/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ function cloudStreamHeadersMs(): number {
export const cloudStreamHeadersMsForTests = cloudStreamHeadersMs;
/** Maximum acceptable Connect-RPC frame length (16 MB). */
const MAX_FRAME_LEN = 16 * 1024 * 1024;
/** Bound the wire field before allocating its decoded UTF-8 string. */
const MAX_SIGNATURE_TYPE_BYTES = 4 * 1024;

/**
* PromptCacheOptions.type = EPHEMERAL. Marks the system prefix as a cache entry
Expand Down Expand Up @@ -819,7 +821,7 @@ export function* decodeChatFrame(proto: Buffer): Generator<CloudChatEvent> {
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;
if (f.num === 21 && f.wire === 2 && Buffer.isBuffer(f.value) && f.value.length <= MAX_SIGNATURE_TYPE_BYTES) signatureType = f.value.toString('utf8') || undefined;
}
for (const f of iterFields(proto)) {
if (f.num === 3 && f.wire === 2 && Buffer.isBuffer(f.value)) {
Expand Down
2 changes: 1 addition & 1 deletion structure/providers-and-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,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. 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.
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, and signature-type payload; signature types are individually capped at 4 KiB before UTF-8 decoding, and crossing either queue 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,
Expand Down
20 changes: 19 additions & 1 deletion tests/providers/devin-anthropic-signature-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" | "split-usage-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" | "large-signature-types-then-refuse" | "signature-types-below-cap-then-refuse"> = [];

const frame = (body: Buffer, flags = 0) => {
const header = Buffer.alloc(5);
Expand Down Expand Up @@ -85,6 +85,7 @@ describe("Devin Anthropic signature fallback", () => {
: 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])
: (next === "large-signature-types-then-refuse" || next === "signature-types-below-cap-then-refuse") ? Buffer.concat([...Array.from({ length: next === "large-signature-types-then-refuse" ? 129 : 127 }, () => frame(Buffer.concat([encodeString(10, "s"), encodeString(21, "x".repeat(4_096))]))), refusal])
: ok;
return new Response(body, { headers: { "content-type": "application/connect+proto" } });
}) as typeof fetch;
Expand Down Expand Up @@ -270,6 +271,23 @@ describe("Devin Anthropic signature fallback", () => {
expect(events.some(e => e.type === "error")).toBe(true);
});

test("held signature types below the payload cap still allow unsigned retry", async () => {
responses = ["signature-types-below-cap-then-refuse", "ok"];
const events = await run(encodeDevinSignature("EpcBClaude", "anthropic"), "claude-opus-5-5-medium");
expect(requests).toHaveLength(2);
expect(events.some(e => e.type === "thinking_signature")).toBe(false);
expect(events.some(e => e.type === "error")).toBe(false);
expect(events).toContainEqual({ type: "text_delta", text: "ok" });
});

test("held signature types count toward the payload cap", async () => {
responses = ["large-signature-types-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_signature")).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");
Expand Down
36 changes: 36 additions & 0 deletions tests/providers/devin-reasoning-continuation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,42 @@ describe("Devin reasoning continuation across turns", () => {
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 });
expect([...decodeChatFrame(Buffer.concat([encodeString(10, SEALED), encodeString(21, "x".repeat(4_097))]))])
.toContainEqual({ kind: "reasoning_signature", signature: SEALED });
});

test.each(["x".repeat(4_096), "é".repeat(2_048)])("signature types at the byte cap remain available", signatureType => {
expect([...decodeChatFrame(Buffer.concat([encodeString(10, SEALED), encodeString(21, signatureType)]))])
.toContainEqual({ kind: "reasoning_signature", signature: SEALED, signatureType });
});

test("the signature type cap counts UTF-8 bytes", () => {
expect([...decodeChatFrame(Buffer.concat([encodeString(10, SEALED), encodeString(21, "é".repeat(2_049))]))])
.toContainEqual({ kind: "reasoning_signature", signature: SEALED });
});

test("an oversized wire type retains the signature through stored-envelope replay", () => {
const event = [...decodeChatFrame(Buffer.concat([
encodeString(10, SEALED), encodeString(21, "x".repeat(4_097)),
]))].find(event => event.kind === "reasoning_signature");
if (!event || event.kind !== "reasoning_signature") throw new Error("Missing signature event");
expect(event.signatureType).toBeUndefined();
const stored = encodeDevinSignature(event.signature, event.signatureType);
expect(stored).toBe(SEALED);
const history = mapOcxMessagesToDevin(parseRequest({
model: "devin/swe-2",
input: [
{ role: "user", content: [{ type: "input_text", text: "go" }] },
{ type: "reasoning", id: "rs_sig", summary: [], encrypted_content: encodeReasoningEnvelope({ sig: stored }) },
{ role: "user", content: [{ type: "input_text", text: "continue" }] },
],
}));
const assistant = history.find(message => message.role === "assistant");
expect(assistant?.signature).toBe(SEALED);
expect(assistant?.signature_type).toBeUndefined();
const wire = assistantPrompt(history);
expect(wire.get(12)).toBe(SEALED);
expect(wire.has(18)).toBe(false);
});

test("the stored signature carries its type and an older stored signature still replays", () => {
Expand Down
Loading