Compare commits

...
Author SHA1 Message Date
陈大猫 7debb69be8 chore: retrigger CI after runner shutdown cancel on lint-and-test (#4074) 2026-10-31 12:22:59 +08:00
陈大猫 4bc977b95d fix(ai): only strip Anthropic signed thinking when provider allows removal (#4074)
DeepSeek Anthropic-compat requires thinking blocks with replayed tool calls;
gate durable-summary and prepareStep strips behind providerAllowsAnthropicSignedThinkingRemoval. Keep 400 invalid-signature strip-and-retry unchanged.
2026-10-31 12:13:44 +08:00
陈大猫 9bbf62e6e6 fix(ai): preserve plain-object Anthropic signature messages in retry wrapper (#4074) 2026-10-31 11:47:41 +08:00
陈大猫 8a8da57a49 fix(ai): propagate Anthropic signature stream errors with progress-aware retry (#4074)
P1-1: rethrow signature failures from cattyStreamProcessor error chunks
(and reader catch) via createCattyAnthropicSignatureRetryError so the
turn-driver strip-and-retry path can see them.

P1-2: mirror the 413 hadToolProgress path on signature retry — rebuild
from latest session, retain completed tool results, new assistant
message, and enable write replay before stripping/retrying.
2026-10-31 11:43:23 +08:00
陈大猫 fb03abea64 fix(ai): strip Anthropic signed thinking when context prefix mutates (#4073)
Anthropic preserved-thinking signatures bind to the exact conversation
prefix. Step compression/prune and durable summary keep-tail were
replaying later signed blocks after mutating earlier tool_results,
causing stable 400 Invalid signature errors on long tool chats.

Strip signed reasoning when prepareStep didAdjust, skip signatures on
durable-summary tails, and one-shot retry after matching 400.
2026-10-31 11:32:36 +08:00
10 changed files with 789 additions and 52 deletions

No files matched your search

@@ -2,9 +2,11 @@ import test from "node:test";
import assert from "node:assert/strict";
import {
createCattyAnthropicSignatureRetryError,
createCattyRequestTooLargeRetryError,
hadToolProgressBeforeRequestTooLarge,
} from "./cattyRequestTooLargeRetry.ts";
import { isInvalidAnthropicThinkingSignatureError } from "./errorClassifier.ts";
test("createCattyRequestTooLargeRetryError marks 413 retry errors after tool progress", () => {
const source = Object.assign(new Error("HTTP 413 Request Entity Too Large"), {
@@ -27,3 +29,71 @@ test("hadToolProgressBeforeRequestTooLarge is false when no tool progress was re
assert.equal(hadToolProgressBeforeRequestTooLarge(retryError), false);
assert.equal(hadToolProgressBeforeRequestTooLarge(new Error("HTTP 413")), false);
});
test("createCattyAnthropicSignatureRetryError preserves classifier fields and tool progress", () => {
const source = Object.assign(
new Error("Invalid signature in thinking block. The block is bound to a different conversation."),
{
statusCode: 400,
responseBody: JSON.stringify({
error: {
message: "Invalid signature in thinking block. The block is bound to a different conversation.",
},
}),
},
);
const retryError = createCattyAnthropicSignatureRetryError(source, true);
assert.equal(retryError.statusCode, 400);
assert.equal(retryError.responseBody, source.responseBody);
assert.equal(retryError.cause, source);
assert.equal(hadToolProgressBeforeRequestTooLarge(retryError), true);
assert.equal(isInvalidAnthropicThinkingSignatureError(retryError), true);
});
test("createCattyAnthropicSignatureRetryError defaults status 400 and no progress", () => {
const retryError = createCattyAnthropicSignatureRetryError(
"Invalid signature in thinking block. The block is bound to a different conversation.",
false,
);
assert.equal(retryError.statusCode, 400);
assert.equal(hadToolProgressBeforeRequestTooLarge(retryError), false);
assert.equal(isInvalidAnthropicThinkingSignatureError(retryError), true);
});
test("createCattyAnthropicSignatureRetryError preserves plain-object SSE signature message", () => {
const source = {
type: "invalid_request_error",
message: "Invalid signature in thinking block",
status: 400,
};
const retryError = createCattyAnthropicSignatureRetryError(source, false);
assert.equal(retryError.message, "Invalid signature in thinking block");
assert.notEqual(retryError.message, "[object Object]");
assert.equal(retryError.statusCode, 400);
assert.equal(retryError.status, 400);
assert.equal(retryError.cause, source);
assert.equal(isInvalidAnthropicThinkingSignatureError(retryError), true);
});
test("createCattyAnthropicSignatureRetryError preserves plain-object message with statusCode", () => {
const source = {
type: "invalid_request_error",
message: "Invalid signature in thinking block. The block is bound to a different conversation.",
statusCode: 400,
};
const retryError = createCattyAnthropicSignatureRetryError(source, true);
assert.equal(
retryError.message,
"Invalid signature in thinking block. The block is bound to a different conversation.",
);
assert.equal(retryError.statusCode, 400);
assert.equal(hadToolProgressBeforeRequestTooLarge(retryError), true);
assert.equal(isInvalidAnthropicThinkingSignatureError(retryError), true);
});
+49 -3
View File
@@ -5,13 +5,32 @@ export type CattyRequestTooLargeRetryError = Error & {
responseBody?: string;
};
export type CattyAnthropicSignatureRetryError = Error & {
cattyHadToolProgress?: boolean;
statusCode?: number;
status?: number;
responseBody?: string;
};
/**
* Mirror errorClassifier.extractMessage so plain SDK / SSE error objects
* with a string `.message` keep that text (not `[object Object]`).
*/
function extractRetryMessage(error: unknown, fallback: string): string {
if (error instanceof Error) return error.message || fallback;
if (typeof error === 'string') return error;
if (error && typeof error === 'object' && 'message' in error) {
const m = (error as { message: unknown }).message;
if (typeof m === 'string' && m.length > 0) return m;
}
return fallback;
}
export function createCattyRequestTooLargeRetryError(
error: unknown,
hadToolProgress: boolean,
): CattyRequestTooLargeRetryError {
const message = error instanceof Error
? error.message
: String(error ?? 'Request too large');
const message = extractRetryMessage(error, 'Request too large');
const retryError = new Error(message) as CattyRequestTooLargeRetryError;
retryError.name = 'CattyRequestTooLargeRetryError';
retryError.cause = error;
@@ -25,6 +44,33 @@ export function createCattyRequestTooLargeRetryError(
return retryError;
}
/**
* Wrap an Anthropic thinking-signature failure so the turn-driver retry path
* can see prior tool progress, while keeping message/body/status intact for
* `isInvalidAnthropicThinkingSignatureError`.
*/
export function createCattyAnthropicSignatureRetryError(
error: unknown,
hadToolProgress: boolean,
): CattyAnthropicSignatureRetryError {
const message = extractRetryMessage(error, 'Invalid signature in thinking block');
const retryError = new Error(message) as CattyAnthropicSignatureRetryError;
retryError.name = 'CattyAnthropicSignatureRetryError';
retryError.cause = error;
retryError.cattyHadToolProgress = hadToolProgress;
if (error && typeof error === 'object') {
const source = error as Record<string, unknown>;
if (typeof source.statusCode === 'number') retryError.statusCode = source.statusCode;
else if (typeof source.status === 'number') retryError.statusCode = source.status;
else retryError.statusCode = 400;
if (typeof source.status === 'number') retryError.status = source.status;
if (typeof source.responseBody === 'string') retryError.responseBody = source.responseBody;
} else {
retryError.statusCode = 400;
}
return retryError;
}
export function hadToolProgressBeforeRequestTooLarge(error: unknown): boolean {
return !!(
error &&
+56 -1
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { classifyError, isRequestTooLargeError, sanitizeErrorMessage } from "./errorClassifier.ts";
import { classifyError, isInvalidAnthropicThinkingSignatureError, isRequestTooLargeError, sanitizeErrorMessage } from "./errorClassifier.ts";
// -------------------------------------------------------------------
// sanitizeErrorMessage — regression guard for pre-existing behavior
@@ -74,6 +74,61 @@ test("isRequestTooLargeError does not treat timing text as HTTP 413", () => {
assert.equal(isRequestTooLargeError("413 ms"), false);
});
// -------------------------------------------------------------------
// isInvalidAnthropicThinkingSignatureError — #4073 prefix binding
// -------------------------------------------------------------------
test("isInvalidAnthropicThinkingSignatureError matches Anthropic 400 signature text", () => {
const err = Object.assign(
new Error("Invalid signature in thinking block. The block is bound to a different conversation."),
{ statusCode: 400 },
);
assert.equal(isInvalidAnthropicThinkingSignatureError(err), true);
});
test("isInvalidAnthropicThinkingSignatureError matches responseBody-only signature errors", () => {
const err = Object.assign(new Error("AI_APICallError"), {
statusCode: 400,
responseBody: JSON.stringify({
type: "error",
error: {
type: "invalid_request_error",
message: "Invalid signature in thinking block. The block is bound to a different conversation.",
},
}),
});
assert.equal(isInvalidAnthropicThinkingSignatureError(err), true);
});
test("isInvalidAnthropicThinkingSignatureError matches text without statusCode", () => {
assert.equal(
isInvalidAnthropicThinkingSignatureError(
new Error("bound to a different conversation"),
),
true,
);
});
test("isInvalidAnthropicThinkingSignatureError rejects unrelated 400s and non-400 signature-ish text", () => {
assert.equal(
isInvalidAnthropicThinkingSignatureError(
Object.assign(new Error("Invalid request: missing field"), { statusCode: 400 }),
),
false,
);
assert.equal(
isInvalidAnthropicThinkingSignatureError(
Object.assign(
new Error("Invalid signature in thinking block. The block is bound to a different conversation."),
{ statusCode: 500 },
),
),
false,
);
assert.equal(isInvalidAnthropicThinkingSignatureError(new Error("plain failure")), false);
});
// -------------------------------------------------------------------
// classifyError — 502 / 503 / 504 upstream gateway
// -------------------------------------------------------------------
+19
View File
@@ -72,6 +72,25 @@ export function isRequestTooLargeError(error: unknown): boolean {
);
}
/**
* Anthropic preserved-thinking prefix-binding failure (#4073):
* "Invalid signature in thinking block. The block is bound to a different conversation."
* Triggered when client history mutates an earlier tool_result / drops early turns /
* injects a durable summary while still replaying later signed thinking blocks.
*/
export function isInvalidAnthropicThinkingSignatureError(error: unknown): boolean {
const message = extractMessage(error).trim();
const responseBody = extractResponseBody(error) ?? "";
const combined = `${message}\n${responseBody}`;
const statusCode = extractStatusCode(error, combined);
const looksLikeSignature = /Invalid[\s\S]*signature[\s\S]*thinking|bound to a different conversation/i.test(combined);
if (!looksLikeSignature) return false;
// Prefer structured 400 when present; still match when only the text is available
// (some SDK shims omit statusCode and put the body in the message).
if (statusCode !== undefined && statusCode !== 400) return false;
return true;
}
function looksLikeHtml(text: string): boolean {
if (!text) return false;
const lower = text.toLowerCase();
@@ -0,0 +1,191 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ModelMessage } from 'ai';
import {
hasAnthropicThinkingSignature,
providerAllowsAnthropicSignedThinkingRemoval,
stripAnthropicSignedReasoningParts,
} from './anthropicThinkingBinding.ts';
function assistantWithParts(parts: unknown[]): ModelMessage {
return { role: 'assistant', content: parts } as ModelMessage;
}
test('stripAnthropicSignedReasoningParts drops only signed anthropic reasoning', () => {
const input: ModelMessage[] = [
{ role: 'user', content: 'hi' },
assistantWithParts([
{ type: 'reasoning', text: 'signed', providerOptions: { anthropic: { signature: 'sig-1' } } },
{ type: 'reasoning', text: 'unsigned' },
{ type: 'text', text: 'hello' },
{
type: 'tool-call',
toolCallId: 'c1',
toolName: 'terminal_execute',
input: { command: 'ls' },
},
]),
assistantWithParts([
{ type: 'reasoning', text: 'also signed', providerOptions: { anthropic: { signature: 'sig-2' } } },
{ type: 'text', text: 'done' },
]),
];
const { messages, didStrip } = stripAnthropicSignedReasoningParts(input);
assert.equal(didStrip, true);
assert.equal(messages[0], input[0], 'unchanged messages stay same reference');
assert.notEqual(messages[1], input[1], 'changed assistant gets new object');
assert.deepEqual(
(messages[1].content as { type: string; text?: string }[]).map((p) => [p.type, p.text]),
[
['reasoning', 'unsigned'],
['text', 'hello'],
['tool-call', undefined],
],
);
assert.deepEqual(
(messages[2].content as { type: string; text?: string }[]).map((p) => [p.type, p.text]),
[['text', 'done']],
);
// Input not mutated
assert.equal((input[1].content as unknown[]).length, 4);
});
test('stripAnthropicSignedReasoningParts leaves unsigned / empty-signature alone', () => {
const input: ModelMessage[] = [
assistantWithParts([
{ type: 'reasoning', text: 'plain' },
{ type: 'reasoning', text: 'empty sig', providerOptions: { anthropic: { signature: '' } } },
{ type: 'reasoning', text: 'other provider', providerOptions: { openai: { itemId: 'rs_1' } } },
{ type: 'text', text: 'ok' },
]),
];
const { messages, didStrip } = stripAnthropicSignedReasoningParts(input);
assert.equal(didStrip, false);
assert.equal(messages, input);
});
test('stripAnthropicSignedReasoningParts ignores non-array assistant content', () => {
const input: ModelMessage[] = [
{ role: 'assistant', content: 'plain string' },
{ role: 'tool', content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 't', output: { type: 'text', value: 'x' } }] } as ModelMessage,
];
const { messages, didStrip } = stripAnthropicSignedReasoningParts(input);
assert.equal(didStrip, false);
assert.equal(messages, input);
});
test('hasAnthropicThinkingSignature matches non-empty anthropic.signature', () => {
assert.equal(hasAnthropicThinkingSignature({
providerOptions: { anthropic: { signature: 'abc' } },
}), true);
assert.equal(hasAnthropicThinkingSignature({
providerOptions: { anthropic: { signature: '' } },
}), false);
assert.equal(hasAnthropicThinkingSignature({ providerOptions: {} }), false);
assert.equal(hasAnthropicThinkingSignature({}), false);
});
test('prepareStep strip condition: signed after long tool_result + didAdjust → no signatures remain', () => {
// Mirrors the long-chat path: prepareStepContext mutates an earlier
// tool_result (didAdjust=true) while a later assistant still carries a
// signed thinking block. After the strip helper runs, no anthropic.signature
// may remain on the retained tail.
const longToolResult = 'x'.repeat(5000);
const messages: ModelMessage[] = [
assistantWithParts([
{ type: 'reasoning', text: 'plan', providerOptions: { anthropic: { signature: 'bound-to-prefix' } } },
{ type: 'text', text: 'running' },
{ type: 'tool-call', toolCallId: 'c1', toolName: 'terminal_execute', input: { command: 'cat big' } },
]),
{
role: 'tool',
content: [{
type: 'tool-result',
toolCallId: 'c1',
toolName: 'terminal_execute',
output: { type: 'text', value: longToolResult },
}],
} as ModelMessage,
assistantWithParts([
{ type: 'reasoning', text: 'after tool', providerOptions: { anthropic: { signature: 'later-sig' } } },
{ type: 'text', text: 'done' },
]),
];
// Simulate the turn-driver wiring: when didAdjust and provider allows
// removal, strip before returning (official Anthropic).
const didAdjust = true;
const preserveAnthropicThinkingReplay = true;
const allowStripSignedThinking = true;
let stepMessages = messages;
if (preserveAnthropicThinkingReplay && didAdjust && allowStripSignedThinking) {
stepMessages = stripAnthropicSignedReasoningParts(stepMessages).messages;
}
for (const message of stepMessages) {
if (message.role !== 'assistant' || !Array.isArray(message.content)) continue;
for (const part of message.content as { type?: string; providerOptions?: { anthropic?: { signature?: string } } }[]) {
if (part.type === 'reasoning') {
assert.equal(
part.providerOptions?.anthropic?.signature,
undefined,
'no anthropic.signature may remain after strip-on-didAdjust',
);
}
}
}
// Unsigned-looking text / tool-call parts still present
const first = stepMessages[0].content as { type: string }[];
assert.deepEqual(first.map((p) => p.type), ['text', 'tool-call']);
});
test('providerAllowsAnthropicSignedThinkingRemoval is false for DeepSeek identities', () => {
assert.equal(providerAllowsAnthropicSignedThinkingRemoval({ providerId: 'deepseek' }), false);
assert.equal(providerAllowsAnthropicSignedThinkingRemoval({
providerId: 'custom',
baseURL: 'https://api.deepseek.com/anthropic',
}), false);
assert.equal(providerAllowsAnthropicSignedThinkingRemoval(
{ providerId: 'anthropic' },
'deepseek-v4-flash',
), false);
assert.equal(providerAllowsAnthropicSignedThinkingRemoval({
providerId: 'custom',
defaultModel: 'DeepSeek-V3',
}), false);
assert.equal(providerAllowsAnthropicSignedThinkingRemoval({
providerId: 'anthropic',
defaultModel: 'claude-opus-4-5',
}), true);
assert.equal(providerAllowsAnthropicSignedThinkingRemoval({
providerId: 'anthropic',
baseURL: 'https://api.anthropic.com',
}, 'claude-sonnet-4'), true);
});
test('prepareStep strip is skipped when provider forbids thinking removal (DeepSeek)', () => {
const messages: ModelMessage[] = [
assistantWithParts([
{ type: 'reasoning', text: 'plan', providerOptions: { anthropic: { signature: 'keep-me' } } },
{ type: 'text', text: 'running' },
{ type: 'tool-call', toolCallId: 'c1', toolName: 'terminal_execute', input: { command: 'ls' } },
]),
];
const didAdjust = true;
const preserveAnthropicThinkingReplay = true;
const allowStripSignedThinking = providerAllowsAnthropicSignedThinkingRemoval(
{ providerId: 'deepseek' },
'deepseek-v4-flash',
);
let stepMessages = messages;
if (preserveAnthropicThinkingReplay && didAdjust && allowStripSignedThinking) {
stepMessages = stripAnthropicSignedReasoningParts(stepMessages).messages;
}
assert.equal(allowStripSignedThinking, false);
assert.equal(stepMessages, messages);
const first = stepMessages[0].content as { type: string; providerOptions?: { anthropic?: { signature?: string } } }[];
assert.equal(first[0].providerOptions?.anthropic?.signature, 'keep-me');
});
@@ -0,0 +1,89 @@
import type { ModelMessage } from 'ai';
/**
* Anthropic preserved-thinking signatures are cryptographically bound to the
* exact conversation prefix (system + tools + every earlier messages[] entry).
* Client-side edits that shorten tool_results, drop early turns, or inject a
* durable summary invalidate later signatures with:
* "Invalid signature in thinking block. The block is bound to a different conversation."
*
* Anthropic allows removing thinking blocks wholesale; it forbids keeping a
* later signed block after mutating its prefix. Strip signed reasoning parts
* when we know the prefix mutated (#4073).
*/
type ContentPart = Extract<ModelMessage['content'], readonly unknown[]>[number];
function anthropicSignatureOf(part: ContentPart): string | undefined {
if (!part || typeof part !== 'object') return undefined;
if (!('type' in part) || (part as { type?: unknown }).type !== 'reasoning') return undefined;
const providerOptions = (part as { providerOptions?: unknown }).providerOptions;
if (!providerOptions || typeof providerOptions !== 'object') return undefined;
const anthropic = (providerOptions as { anthropic?: unknown }).anthropic;
if (!anthropic || typeof anthropic !== 'object') return undefined;
const signature = (anthropic as { signature?: unknown }).signature;
return typeof signature === 'string' && signature.length > 0 ? signature : undefined;
}
function isSignedAnthropicReasoningPart(part: ContentPart): boolean {
return anthropicSignatureOf(part) !== undefined;
}
/**
* Drop assistant `reasoning` parts that carry a non-empty
* `providerOptions.anthropic.signature`. Unsigned reasoning / text / tool-call
* parts are left alone. Does not mutate input arrays in place — returns new
* message objects only when a message actually changes.
*/
export function stripAnthropicSignedReasoningParts(
messages: ModelMessage[],
): { messages: ModelMessage[]; didStrip: boolean } {
let didStrip = false;
const next = messages.map((message) => {
if (message.role !== 'assistant' || !Array.isArray(message.content)) {
return message;
}
const content = message.content as ContentPart[];
const filtered = content.filter((part) => !isSignedAnthropicReasoningPart(part));
if (filtered.length === content.length) {
return message;
}
didStrip = true;
return { ...message, content: filtered } as ModelMessage;
});
return { messages: didStrip ? next : messages, didStrip };
}
/** True when a reasoning-like part carries a non-empty Anthropic signature. */
export function hasAnthropicThinkingSignature(part: {
providerOptions?: { anthropic?: { signature?: unknown } } | Record<string, unknown> | undefined;
}): boolean {
const providerOptions = part.providerOptions;
if (!providerOptions || typeof providerOptions !== 'object') return false;
const anthropic = (providerOptions as { anthropic?: unknown }).anthropic;
if (!anthropic || typeof anthropic !== 'object') return false;
const signature = (anthropic as { signature?: unknown }).signature;
return typeof signature === 'string' && signature.length > 0;
}
/**
* Official Anthropic allows removing thinking blocks wholesale after a prefix
* mutate (#4073). DeepSeek's Anthropic-compatible endpoint does NOT — it
* requires thinking blocks to accompany replayed tool calls. Return false when
* the active provider/model looks like DeepSeek so callers keep signed thinking.
*/
export function providerAllowsAnthropicSignedThinkingRemoval(
provider: { providerId?: string; baseURL?: string; defaultModel?: string } | null | undefined,
modelId?: string | null,
): boolean {
const providerId = String(provider?.providerId ?? '').toLowerCase();
if (providerId === 'deepseek') return false;
const baseURL = String(provider?.baseURL ?? '').toLowerCase();
if (baseURL.includes('deepseek.com')) return false;
const models = [modelId, provider?.defaultModel]
.map((value) => String(value ?? '').toLowerCase())
.filter(Boolean);
if (models.some((value) => value.includes('deepseek'))) return false;
return true;
}
@@ -665,3 +665,157 @@ test('Anthropic-format signed thinking survives build and stream preparation', (
assert.equal(plainThinking?.text, 'final thinking');
assert.deepEqual(plainThinking?.providerOptions?.anthropic, { signature: 'sig-xyz' });
});
test('durable summary skips Anthropic signed thinking on the retained tail (#4073)', () => {
// Durable compaction injects a synthetic summary and keeps the recent tail.
// Replaying signed thinking on that tail would 400 (prefix binding). Unsigned
// reasoning may still be replayed.
const messages: ChatMessage[] = [
{
id: 'assistant-old',
role: 'assistant',
content: 'Earlier.',
timestamp: 1,
providerContinuation: {
source: { providerConfigId: 'anthropic-1', providerType: 'anthropic', modelId: 'claude-opus-4-5' },
reasoningParts: [{
text: 'compacted thinking',
providerOptions: { anthropic: { signature: 'sig-old' } },
}],
},
},
{
id: 'assistant-tail',
role: 'assistant',
content: 'Recent reply.',
timestamp: 2,
providerContinuation: {
source: { providerConfigId: 'anthropic-1', providerType: 'anthropic', modelId: 'claude-opus-4-5' },
reasoningParts: [
{
text: 'signed recent',
providerOptions: { anthropic: { signature: 'sig-tail' } },
},
{ text: 'unsigned recent' },
],
},
toolCalls: [{ id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } }],
},
{
id: 'tool-1',
role: 'tool',
content: '',
timestamp: 3,
toolResults: [{ toolCallId: 'call-1', content: 'ok' }],
},
];
const built = buildCattySdkMessages({
allMessages: messages,
contextCompaction: {
summary: 'Earlier work finished.',
compactedMessageCount: 1,
},
includeCurrentUserMessage: false,
trimmed: '',
continuationContext: createContinuationContext('anthropic-1', 'anthropic', 'claude-opus-4-5'),
chatSessionId: 'chat-1',
toolOutputStore: new ToolOutputStore(),
fieldsByMessage: new Map(),
});
assert.match(String(built[0].content), /Earlier work finished/);
const tailAssistant = built.find((m) => m.role === 'assistant' && Array.isArray(m.content)
&& (m.content as { type?: string }[]).some((p) => p.type === 'tool-call'));
assert.ok(tailAssistant);
const parts = tailAssistant!.content as {
type: string;
text?: string;
providerOptions?: { anthropic?: { signature?: string } };
}[];
const reasoning = parts.filter((p) => p.type === 'reasoning');
assert.equal(reasoning.length, 1, 'only unsigned reasoning should remain');
assert.equal(reasoning[0].text, 'unsigned recent');
assert.equal(reasoning[0].providerOptions?.anthropic?.signature, undefined);
for (const part of parts) {
if (part.type === 'reasoning') {
assert.equal(part.providerOptions?.anthropic?.signature, undefined);
}
}
});
test('durable summary keeps Anthropic signed thinking for DeepSeek (#4074 P2)', () => {
// DeepSeek Anthropic-compat requires thinking blocks with replayed tool calls.
// Even with a durable summary (prefix mutate), do not strip signed thinking.
const messages: ChatMessage[] = [
{
id: 'assistant-old',
role: 'assistant',
content: 'Earlier.',
timestamp: 1,
providerContinuation: {
source: { providerConfigId: 'anthropic-1', providerType: 'anthropic', modelId: 'deepseek-v4-flash' },
reasoningParts: [{
text: 'compacted thinking',
providerOptions: { anthropic: { signature: 'sig-old' } },
}],
},
},
{
id: 'assistant-tail',
role: 'assistant',
content: 'Recent reply.',
timestamp: 2,
providerContinuation: {
source: { providerConfigId: 'anthropic-1', providerType: 'anthropic', modelId: 'deepseek-v4-flash' },
reasoningParts: [
{
text: 'signed recent',
providerOptions: { anthropic: { signature: 'sig-tail' } },
},
{ text: 'unsigned recent' },
],
},
toolCalls: [{ id: 'call-1', name: 'terminal_execute', arguments: { command: 'ls' } }],
},
{
id: 'tool-1',
role: 'tool',
content: '',
timestamp: 3,
toolResults: [{ toolCallId: 'call-1', content: 'ok' }],
},
];
const built = buildCattySdkMessages({
allMessages: messages,
contextCompaction: {
summary: 'Earlier work finished.',
compactedMessageCount: 1,
},
includeCurrentUserMessage: false,
trimmed: '',
continuationContext: createContinuationContext('anthropic-1', 'anthropic', 'deepseek-v4-flash'),
chatSessionId: 'chat-1',
toolOutputStore: new ToolOutputStore(),
fieldsByMessage: new Map(),
allowStripAnthropicSignedThinking: false,
});
assert.match(String(built[0].content), /Earlier work finished/);
const tailAssistant = built.find((m) => m.role === 'assistant' && Array.isArray(m.content)
&& (m.content as { type?: string }[]).some((p) => p.type === 'tool-call'));
assert.ok(tailAssistant);
const parts = tailAssistant!.content as {
type: string;
text?: string;
providerOptions?: { anthropic?: { signature?: string } };
}[];
const reasoning = parts.filter((p) => p.type === 'reasoning');
assert.equal(reasoning.length, 2, 'signed + unsigned reasoning must both remain for DeepSeek');
const signed = reasoning.find((p) => p.providerOptions?.anthropic?.signature === 'sig-tail');
assert.ok(signed, 'signed thinking must survive when allowStrip is false');
assert.equal(signed!.text, 'signed recent');
assert.ok(reasoning.some((p) => p.text === 'unsigned recent'));
});
@@ -30,6 +30,7 @@ import {
import { redactSecretsForModel, redactSecretsInValueForModel } from '../modelSecretRedaction';
import { fitLargeUserInputForModel } from '../largeUserInput';
import type { ToolOutputStore } from '../toolOutputStore';
import { hasAnthropicThinkingSignature } from '../anthropicThinkingBinding';
const OPENAI_CHAT_ASSISTANT_FIELDS = Symbol('netcatty.openAIChatAssistantFields');
@@ -201,6 +202,12 @@ export interface BuildCattySdkMessagesInput {
chatSessionId: string;
toolOutputStore: ToolOutputStore;
fieldsByMessage: Map<ModelMessage, OpenAIChatAssistantFields | undefined>;
/**
* When true (default), durable-summary builds skip Anthropic signed thinking
* on the retained tail (#4073). DeepSeek Anthropic-compat must pass false —
* it requires thinking blocks with replayed tool calls.
*/
allowStripAnthropicSignedThinking?: boolean;
}
export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelMessage[] {
@@ -214,6 +221,7 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
chatSessionId,
toolOutputStore,
fieldsByMessage,
allowStripAnthropicSignedThinking = true,
} = input;
const { resolvedToolCallsByAssistant, toolCallByToolResult } = buildHistoricalToolReplayMaps(allMessages);
@@ -231,6 +239,14 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
// The boundary can become zero when storage trims messages that were all
// covered by the durable summary. Keep injecting that summary even though
// no remaining persisted message needs to be skipped.
// Durable summary replaces the compacted prefix. Replaying Anthropic signed
// thinking on the retained tail would 400 on official Anthropic (prefix
// binding / #4073) — skip those signatures while leaving unsigned reasoning
// intact. DeepSeek Anthropic-compat forbids wholesale removal, so callers
// pass allowStripAnthropicSignedThinking=false for that provider.
const skipSignedAnthropicReasoning = Boolean(contextCompaction?.summary)
&& allowStripAnthropicSignedThinking;
if (contextCompaction?.summary) {
sdkMessages.push({
role: 'user',
@@ -322,6 +338,7 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
if (replayedCalls.length > 0) {
for (const part of collectReplayableReasoningParts(replayContinuation)) {
if (!part.text && !part.providerOptions) continue;
if (skipSignedAnthropicReasoning && hasAnthropicThinkingSignature(part)) continue;
contentParts.push({
type: 'reasoning' as const,
text: part.text,
@@ -357,6 +374,7 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
const contentParts: AssistantContentPart[] = [];
for (const part of collectReplayableReasoningParts(replayContinuation)) {
if (!part.text && !part.providerOptions) continue;
if (skipSignedAnthropicReasoning && hasAnthropicThinkingSignature(part)) continue;
contentParts.push({
type: 'reasoning' as const,
text: part.text,
@@ -1,9 +1,9 @@
import { streamText, isStepCount, type ModelMessage } from 'ai';
import type { LanguageModelV4 } from '@ai-sdk/provider';
import { classifyError } from '../../errorClassifier';
import { isRequestTooLargeError } from '../../errorClassifier';
import { classifyError, isInvalidAnthropicThinkingSignatureError, isRequestTooLargeError } from '../../errorClassifier';
import { isSdkStreamStateError } from '../../shared/streamStateErrors';
import {
createCattyAnthropicSignatureRetryError,
createCattyRequestTooLargeRetryError,
hadToolProgressBeforeRequestTooLarge,
} from '../../cattyRequestTooLargeRetry';
@@ -368,6 +368,9 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
if (isRequestTooLargeError(readErr)) {
throw createCattyRequestTooLargeRetryError(readErr, hadToolProgress);
}
if (isInvalidAnthropicThinkingSignatureError(readErr)) {
throw createCattyAnthropicSignatureRetryError(readErr, hadToolProgress);
}
throw readErr;
}
const { done, value } = readResult;
@@ -535,6 +538,16 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
hadToolProgress,
);
}
if (isInvalidAnthropicThinkingSignatureError(typedChunk.error)) {
// Propagate to turn-driver strip-and-retry (#4074 P1); do not consume
// as a terminal error message the way other stream errors are.
cancelPendingFlush();
flushText();
throw createCattyAnthropicSignatureRetryError(
typedChunk.error,
hadToolProgress,
);
}
cancelPendingFlush();
flushText();
ui.updateMessageById(streamSessionId, activeMsgId, msg => ({
@@ -31,7 +31,11 @@ import {
} from '../cattyRuntime';
import { computeTotalInputTokens, DEFAULT_MAX_OUTPUT_TOKENS } from '../contextBudget';
import { clearChatSessionCancelled } from '../agentStop';
import { isRequestTooLargeError } from '../../errorClassifier';
import { isInvalidAnthropicThinkingSignatureError, isRequestTooLargeError } from '../../errorClassifier';
import {
providerAllowsAnthropicSignedThinkingRemoval,
stripAnthropicSignedReasoningParts,
} from '../anthropicThinkingBinding';
import { getNetcattyBridge, generateId, resolveUserSkillsContext } from '../../aiChatStreamingSupport';
import {
buildCattySdkMessages,
@@ -217,6 +221,10 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
try {
const openAIChatAssistantFieldsByMessage = new Map<ModelMessage, OpenAIChatAssistantFields | undefined>();
const allowStripSignedThinking = providerAllowsAnthropicSignedThinkingRemoval(
context.activeProvider,
activeModelId,
);
const buildSdkMessages = (
allMessages: import('../../types').ChatMessage[],
@@ -232,6 +240,7 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
chatSessionId: sessionId,
toolOutputStore: ctx.toolOutputStore,
fieldsByMessage: openAIChatAssistantFieldsByMessage,
allowStripAnthropicSignedThinking: allowStripSignedThinking,
});
const responseIdleTimeoutSeconds = normalizeResponseIdleTimeoutSeconds(
@@ -298,6 +307,7 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
// Keep the reasoning parts buildCattySdkMessages replayed instead of
// pruning them like generic Chat Completions histories.
const preserveAnthropicThinkingReplay = resolveProviderStyle(context.activeProvider) === 'anthropic';
// allowStripSignedThinking computed once above (DeepSeek must keep signed thinking).
const preserveReasoningForStream = preserveStatelessResponsesReasoning || preserveAnthropicThinkingReplay;
const reasoningReserveTokens = estimateReasoningOutputReserve(reasoningProviderOptions);
// Fold thinking budget into compaction maxOutput only. reservedTokens is
@@ -497,9 +507,20 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
runtimeContext: stepRuntimeContext,
onEvent: (event) => ctx.emit(event),
});
emitContextSnapshot(prepared.messages);
// Prefix-mutating step prep (tool_result compress/prune/budget head-prune)
// invalidates Anthropic preserved-thinking signatures on the retained
// tail (#4073). Strip signed reasoning when we know the prefix changed.
let stepMessages = prepared.messages;
if (preserveAnthropicThinkingReplay && prepared.didAdjust && allowStripSignedThinking) {
const stripped = stripAnthropicSignedReasoningParts(stepMessages);
if (stripped.didStrip) {
console.warn('[Catty] Stripped Anthropic signed thinking after step context mutate (#4073)');
stepMessages = stripped.messages;
}
}
emitContextSnapshot(stepMessages);
return {
messages: prepared.messages,
messages: stepMessages,
runtimeContext: prepared.runtimeContext,
};
},
@@ -513,55 +534,116 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
try {
await runStream(messagesForStream, assistantMsgId);
} catch (streamErr) {
if (signal.aborted || !isRequestTooLargeError(streamErr)) {
if (signal.aborted) {
throw streamErr;
}
if (context.delegated) console.warn('[Plugin Agent] Context compaction requested');
else console.warn('[Catty] Request hit HTTP 413; forcing context compaction and retrying once.', streamErr);
const hadToolProgress = hadToolProgressBeforeRequestTooLarge(streamErr);
let retryBaseMessages = messagesForStream;
let retryAssistantMsgId = assistantMsgId;
let preservedWriteFingerprints: string[] = [];
if (hadToolProgress) {
const latestSession = ui.getLatestSession?.(sessionId);
if (latestSession) {
preservedWriteFingerprints = collectPreservedTerminalWriteFingerprints(
latestSession.messages,
assistantMsgId,
sessionId,
);
retryBaseMessages = buildSdkMessages(latestSession.messages, false, latestSession.contextCompaction);
if (isRequestTooLargeError(streamErr)) {
if (context.delegated) console.warn('[Plugin Agent] Context compaction requested');
else console.warn('[Catty] Request hit HTTP 413; forcing context compaction and retrying once.', streamErr);
const hadToolProgress = hadToolProgressBeforeRequestTooLarge(streamErr);
let retryBaseMessages = messagesForStream;
let retryAssistantMsgId = assistantMsgId;
let preservedWriteFingerprints: string[] = [];
if (hadToolProgress) {
const latestSession = ui.getLatestSession?.(sessionId);
if (latestSession) {
preservedWriteFingerprints = collectPreservedTerminalWriteFingerprints(
latestSession.messages,
assistantMsgId,
sessionId,
);
retryBaseMessages = buildSdkMessages(latestSession.messages, false, latestSession.contextCompaction);
}
retryAssistantMsgId = generateId();
ui.addMessageToSession(sessionId, {
id: retryAssistantMsgId,
role: 'assistant',
content: '',
timestamp: Date.now(),
model: activeModelId || context.activeProvider?.defaultModel || '',
providerId: context.activeProvider?.providerId,
});
} else {
ui.updateMessageById(sessionId, assistantMsgId, msg => ({
...msg,
content: '',
thinking: undefined,
thinkingDurationMs: undefined,
providerContinuation: undefined,
toolCalls: undefined,
errorInfo: undefined,
executionStatus: undefined,
pendingApproval: undefined,
}));
}
retryAssistantMsgId = generateId();
ui.addMessageToSession(sessionId, {
id: retryAssistantMsgId,
role: 'assistant',
content: '',
timestamp: Date.now(),
model: activeModelId || context.activeProvider?.defaultModel || '',
providerId: context.activeProvider?.providerId,
});
ctx.toolResultDedup.enableWriteReplay(preservedWriteFingerprints);
const retryMessages = prepareMessagesForStream((await compactMessages(retryBaseMessages, {
force: true,
compressForRequestTooLargeRetry: true,
})).messages);
emitContextSnapshot(retryMessages);
await runStream(retryMessages, retryAssistantMsgId);
} else if (
preserveAnthropicThinkingReplay
&& isInvalidAnthropicThinkingSignatureError(streamErr)
) {
// Once per turn: strip signed thinking and retry. Nested failure throws
// to the outer catch (no loop). Mirror the 413 progress-aware path so
// completed tool calls/results are retained and write replay is enabled
// (#4074 P1) instead of clearing the initial assistant and restarting.
console.warn(
'[Catty] Anthropic thinking signature invalid after prefix mutate; stripping signed reasoning and retrying once (#4073).',
streamErr,
);
const hadToolProgress = hadToolProgressBeforeRequestTooLarge(streamErr);
let retryBaseMessages = messagesForStream;
let retryAssistantMsgId = assistantMsgId;
let preservedWriteFingerprints: string[] = [];
if (hadToolProgress) {
const latestSession = ui.getLatestSession?.(sessionId);
if (latestSession) {
preservedWriteFingerprints = collectPreservedTerminalWriteFingerprints(
latestSession.messages,
assistantMsgId,
sessionId,
);
retryBaseMessages = buildSdkMessages(latestSession.messages, false, latestSession.contextCompaction);
retryBaseMessages = prepareMessagesForStream(retryBaseMessages);
}
retryAssistantMsgId = generateId();
ui.addMessageToSession(sessionId, {
id: retryAssistantMsgId,
role: 'assistant',
content: '',
timestamp: Date.now(),
model: activeModelId || context.activeProvider?.defaultModel || '',
providerId: context.activeProvider?.providerId,
});
} else {
ui.updateMessageById(sessionId, assistantMsgId, msg => ({
...msg,
content: '',
thinking: undefined,
thinkingDurationMs: undefined,
providerContinuation: undefined,
toolCalls: undefined,
errorInfo: undefined,
executionStatus: undefined,
pendingApproval: undefined,
}));
}
const stripped = stripAnthropicSignedReasoningParts(retryBaseMessages);
if (!stripped.didStrip) {
throw streamErr;
}
ctx.toolResultDedup.enableWriteReplay(preservedWriteFingerprints);
const retryMessages = stripped.messages;
emitContextSnapshot(retryMessages);
await runStream(retryMessages, retryAssistantMsgId);
} else {
ui.updateMessageById(sessionId, assistantMsgId, msg => ({
...msg,
content: '',
thinking: undefined,
thinkingDurationMs: undefined,
providerContinuation: undefined,
toolCalls: undefined,
errorInfo: undefined,
executionStatus: undefined,
pendingApproval: undefined,
}));
throw streamErr;
}
ctx.toolResultDedup.enableWriteReplay(preservedWriteFingerprints);
const retryMessages = prepareMessagesForStream((await compactMessages(retryBaseMessages, {
force: true,
compressForRequestTooLargeRetry: true,
})).messages);
emitContextSnapshot(retryMessages);
await runStream(retryMessages, retryAssistantMsgId);
}
} catch (err) {
if (context.delegated && signal.aborted) return;