Skip to content

Commit b373f93

Browse files
committed
fix: capture reasoning signatures and strip unsigned reasoning
Root cause: Anthropic's Extended Thinking API requires thinking blocks to include a signature for replay. The Vercel AI SDK silently drops reasoning parts without providerOptions.anthropic.signature, which can leave assistant messages empty and cause API rejection with 'all messages must have non-empty content'. Changes: - Add signature field to MuxReasoningPart and ReasoningDeltaEvent - Capture signatures from SDK stream events (signature_delta) - Store providerOptions.anthropic.signature for SDK compatibility - Add stripUnsendableReasoning() to remove reasoning without signatures before API calls, preventing empty messages - Add --workspace flag to CLI for debugging existing workspaces
1 parent e0b51d2 commit b373f93

File tree

6 files changed

+232
-22
lines changed

6 files changed

+232
-22
lines changed

src/browser/utils/messages/ChatEventProcessor.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,12 +254,27 @@ export function createChatEventProcessor(): ChatEventProcessor {
254254

255255
const lastPart = message.parts.at(-1);
256256
if (lastPart?.type === "reasoning") {
257-
lastPart.text += event.delta;
257+
// Signature updates come with empty delta - just update the signature
258+
if (event.signature && !event.delta) {
259+
lastPart.signature = event.signature;
260+
lastPart.providerOptions = { anthropic: { signature: event.signature } };
261+
} else {
262+
lastPart.text += event.delta;
263+
// Also capture signature if present with text
264+
if (event.signature) {
265+
lastPart.signature = event.signature;
266+
lastPart.providerOptions = { anthropic: { signature: event.signature } };
267+
}
268+
}
258269
} else {
259270
message.parts.push({
260271
type: "reasoning",
261272
text: event.delta,
262273
timestamp: event.timestamp,
274+
signature: event.signature,
275+
providerOptions: event.signature
276+
? { anthropic: { signature: event.signature } }
277+
: undefined,
263278
});
264279
}
265280
return;

src/browser/utils/messages/modelMessageTransform.ts

Lines changed: 113 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,62 @@ function filterReasoningOnlyMessages(messages: ModelMessage[]): ModelMessage[] {
490490
});
491491
}
492492

493+
/**
494+
* Strip Anthropic reasoning parts that lack a valid signature.
495+
*
496+
* Anthropic's Extended Thinking API requires thinking blocks to include a signature
497+
* for replay. The Vercel AI SDK's Anthropic provider only sends reasoning parts to
498+
* the API if they have providerOptions.anthropic.signature. Reasoning parts we create
499+
* (placeholders) or from history (where we didn't capture the signature) will be
500+
* silently dropped by the SDK.
501+
*
502+
* If all parts of an assistant message are unsigned reasoning, the SDK drops them all,
503+
* leaving an empty message that Anthropic rejects with:
504+
* "all messages must have non-empty content except for the optional final assistant message"
505+
*
506+
* This function removes unsigned reasoning upfront and filters resulting empty messages.
507+
*
508+
* NOTE: This is Anthropic-specific. Other providers (e.g., OpenAI) handle reasoning
509+
* differently and don't require signatures.
510+
*/
511+
function stripUnsignedAnthropicReasoning(messages: ModelMessage[]): ModelMessage[] {
512+
const stripped = messages.map((msg) => {
513+
if (msg.role !== "assistant") {
514+
return msg;
515+
}
516+
517+
const assistantMsg = msg;
518+
if (typeof assistantMsg.content === "string") {
519+
return msg;
520+
}
521+
522+
// Filter out reasoning parts without anthropic.signature in providerOptions
523+
const content = assistantMsg.content.filter((part) => {
524+
if (part.type !== "reasoning") {
525+
return true;
526+
}
527+
// Check for anthropic.signature in providerOptions
528+
const anthropicMeta = (part.providerOptions as { anthropic?: { signature?: string } })
529+
?.anthropic;
530+
return anthropicMeta?.signature != null;
531+
});
532+
533+
const result: typeof assistantMsg = { ...assistantMsg, content };
534+
return result;
535+
});
536+
537+
// Filter out messages that became empty after stripping reasoning
538+
return stripped.filter((msg) => {
539+
if (msg.role !== "assistant") {
540+
return true;
541+
}
542+
if (typeof msg.content === "string") {
543+
return msg.content.length > 0;
544+
}
545+
return msg.content.length > 0;
546+
});
547+
}
548+
493549
/**
494550
* Coalesce consecutive parts of the same type within each message.
495551
* Streaming creates many individual text/reasoning parts; merge them for easier debugging.
@@ -540,6 +596,47 @@ function coalesceConsecutiveParts(messages: ModelMessage[]): ModelMessage[] {
540596
});
541597
}
542598

599+
/**
600+
* Merge consecutive assistant messages by combining their content arrays.
601+
* This can happen when splitMixedContentMessages creates multiple assistant messages
602+
* (text-only followed by tool-call-only) that are then followed by more tool operations.
603+
* Anthropic API requires no two consecutive assistant messages.
604+
*/
605+
function mergeConsecutiveAssistantMessages(messages: ModelMessage[]): ModelMessage[] {
606+
const merged: ModelMessage[] = [];
607+
608+
for (const msg of messages) {
609+
if (
610+
msg.role === "assistant" &&
611+
merged.length > 0 &&
612+
merged[merged.length - 1].role === "assistant"
613+
) {
614+
// Consecutive assistant message - merge content arrays
615+
const prevMsg = merged[merged.length - 1];
616+
const currentMsg = msg;
617+
618+
// Get content arrays (handle string content by wrapping in text part)
619+
const prevContent = Array.isArray(prevMsg.content)
620+
? prevMsg.content
621+
: [{ type: "text" as const, text: prevMsg.content }];
622+
623+
const currentContent = Array.isArray(currentMsg.content)
624+
? currentMsg.content
625+
: [{ type: "text" as const, text: currentMsg.content }];
626+
627+
// Merge content arrays - use type assertion since we're combining valid parts
628+
merged[merged.length - 1] = {
629+
role: "assistant",
630+
content: [...prevContent, ...currentContent] as AssistantModelMessage["content"],
631+
};
632+
} else {
633+
merged.push(msg);
634+
}
635+
}
636+
637+
return merged;
638+
}
639+
543640
/**
544641
* Merge consecutive user messages with newline separators.
545642
* When filtering removes assistant messages, we can end up with consecutive user messages.
@@ -637,9 +734,10 @@ function ensureAnthropicThinkingBeforeToolCalls(messages: ModelMessage[]): Model
637734
}
638735

639736
// Anthropic extended thinking requires tool-use assistant messages to start with a thinking block.
640-
// If we still have no reasoning available, insert an empty reasoning part as a minimal placeholder.
737+
// If we still have no reasoning available, insert a minimal placeholder reasoning part.
738+
// NOTE: The text cannot be empty - Anthropic API rejects empty content.
641739
if (reasoningParts.length === 0) {
642-
reasoningParts = [{ type: "reasoning" as const, text: "" }];
740+
reasoningParts = [{ type: "reasoning" as const, text: "..." }];
643741
}
644742

645743
result.push({
@@ -668,7 +766,7 @@ function ensureAnthropicThinkingBeforeToolCalls(messages: ModelMessage[]): Model
668766
result[i] = {
669767
...assistantMsg,
670768
content: [
671-
{ type: "reasoning" as const, text: "" },
769+
{ type: "reasoning" as const, text: "..." },
672770
{ type: "text" as const, text },
673771
],
674772
};
@@ -685,7 +783,7 @@ function ensureAnthropicThinkingBeforeToolCalls(messages: ModelMessage[]): Model
685783

686784
result[i] = {
687785
...assistantMsg,
688-
content: [{ type: "reasoning" as const, text: "" }, ...content],
786+
content: [{ type: "reasoning" as const, text: "..." }, ...content],
689787
};
690788
break;
691789
}
@@ -730,7 +828,9 @@ export function transformModelMessages(
730828
// Anthropic: When extended thinking is enabled, preserve reasoning-only messages and ensure
731829
// tool-call messages start with reasoning. When it's disabled, filter reasoning-only messages.
732830
if (options?.anthropicThinkingEnabled) {
733-
reasoningHandled = ensureAnthropicThinkingBeforeToolCalls(split);
831+
// First strip reasoning without signatures (SDK will drop them anyway, causing empty messages)
832+
const signedReasoning = stripUnsignedAnthropicReasoning(split);
833+
reasoningHandled = ensureAnthropicThinkingBeforeToolCalls(signedReasoning);
734834
} else {
735835
reasoningHandled = filterReasoningOnlyMessages(split);
736836
}
@@ -739,8 +839,14 @@ export function transformModelMessages(
739839
reasoningHandled = split;
740840
}
741841

742-
// Pass 3: Merge consecutive user messages (applies to all providers)
743-
const merged = mergeConsecutiveUserMessages(reasoningHandled);
842+
// Pass 3: Merge consecutive assistant messages (applies to all providers)
843+
// This can happen when splitMixedContentMessages splits text from tool calls,
844+
// creating [assistant:text] [tool:result] [assistant:tools] patterns that become
845+
// [assistant:text] [assistant:tools] after tool result processing.
846+
const mergedAssistants = mergeConsecutiveAssistantMessages(reasoningHandled);
847+
848+
// Pass 4: Merge consecutive user messages (applies to all providers)
849+
const merged = mergeConsecutiveUserMessages(mergedAssistants);
744850

745851
return merged;
746852
}

src/cli/run.ts

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ program
195195
.option("--json", "output NDJSON for programmatic consumption")
196196
.option("-q, --quiet", "only output final result")
197197
.option("--workspace-id <id>", "explicit workspace ID (auto-generated if not provided)")
198+
.option("--workspace <id>", "continue an existing workspace (loads history, skips init)")
198199
.option("--config-root <path>", "mux config directory")
199200
.option("--mcp <server>", "MCP server as name=command (can be repeated)", collectMcpServers, [])
200201
.option("--no-mcp-config", "ignore .mux/mcp.jsonc, use only --mcp servers")
@@ -227,6 +228,7 @@ interface CLIOptions {
227228
json?: boolean;
228229
quiet?: boolean;
229230
workspaceId?: string;
231+
workspace?: string;
230232
configRoot?: string;
231233
mcp: MCPServerEntry[];
232234
mcpConfig: boolean;
@@ -250,10 +252,6 @@ async function main(): Promise<void> {
250252
}
251253
// Default is already "warn" for CLI mode (set in log.ts)
252254

253-
// Resolve directory
254-
const projectDir = path.resolve(opts.dir);
255-
await ensureDirectory(projectDir);
256-
257255
// Get message from arg or stdin
258256
const stdinMessage = await gatherMessageFromStdin();
259257
const message = messageArg?.trim() ?? stdinMessage.trim();
@@ -266,7 +264,35 @@ async function main(): Promise<void> {
266264

267265
// Setup config
268266
const config = new Config(opts.configRoot);
269-
const workspaceId = opts.workspaceId ?? generateWorkspaceId();
267+
268+
// Determine if continuing an existing workspace
269+
const continueWorkspace = opts.workspace;
270+
const workspaceId = continueWorkspace ?? opts.workspaceId ?? generateWorkspaceId();
271+
272+
// Resolve directory - for continuing workspace, try to get from metadata
273+
let projectDir: string;
274+
if (continueWorkspace) {
275+
const metadataPath = path.join(config.sessionsDir, continueWorkspace, "metadata.json");
276+
try {
277+
const metadataContent = await fs.readFile(metadataPath, "utf-8");
278+
const metadata = JSON.parse(metadataContent) as { projectPath?: string };
279+
if (metadata.projectPath) {
280+
projectDir = metadata.projectPath;
281+
log.info(`Continuing workspace ${continueWorkspace}, using project path: ${projectDir}`);
282+
} else {
283+
projectDir = path.resolve(opts.dir);
284+
log.warn(`No projectPath in metadata, using --dir: ${projectDir}`);
285+
}
286+
} catch {
287+
// Metadata doesn't exist or is invalid, fall back to --dir
288+
projectDir = path.resolve(opts.dir);
289+
log.warn(`Could not read metadata for ${continueWorkspace}, using --dir: ${projectDir}`);
290+
}
291+
} else {
292+
projectDir = path.resolve(opts.dir);
293+
await ensureDirectory(projectDir);
294+
}
295+
270296
const model: string = opts.model;
271297
const runtimeConfig = parseRuntimeConfig(opts.runtime, config.srcDir);
272298
const thinkingLevel = parseThinkingLevel(opts.thinking);
@@ -333,11 +359,17 @@ async function main(): Promise<void> {
333359
backgroundProcessManager,
334360
});
335361

336-
await session.ensureMetadata({
337-
workspacePath: projectDir,
338-
projectName: path.basename(projectDir),
339-
runtimeConfig,
340-
});
362+
// For continuing workspace, metadata should already exist
363+
// For new workspace, create it
364+
if (!continueWorkspace) {
365+
await session.ensureMetadata({
366+
workspacePath: projectDir,
367+
projectName: path.basename(projectDir),
368+
runtimeConfig,
369+
});
370+
} else {
371+
log.info(`Continuing workspace ${workspaceId} - using existing metadata`);
372+
}
341373

342374
const buildSendOptions = (cliMode: CLIMode): SendMessageOptions => ({
343375
model,

src/common/orpc/schemas/stream.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,10 @@ export const ReasoningDeltaEventSchema = z.object({
201201
delta: z.string(),
202202
tokens: z.number().meta({ description: "Token count for this delta" }),
203203
timestamp: z.number().meta({ description: "When delta was received (Date.now())" }),
204+
signature: z
205+
.string()
206+
.optional()
207+
.meta({ description: "Anthropic thinking block signature for replay" }),
204208
});
205209

206210
export const ReasoningEndEventSchema = z.object({

src/common/types/message.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,24 @@ export interface MuxReasoningPart {
146146
type: "reasoning";
147147
text: string;
148148
timestamp?: number;
149+
/**
150+
* Anthropic thinking block signature for replay.
151+
* Required to send reasoning back to Anthropic - the API validates signatures
152+
* to ensure thinking blocks haven't been tampered with. Reasoning without
153+
* signatures will be stripped before sending to avoid "empty content" errors.
154+
*/
155+
signature?: string;
156+
/**
157+
* Provider options for SDK compatibility.
158+
* When converting to ModelMessages via the SDK's convertToModelMessages,
159+
* this is passed through. For Anthropic thinking blocks, this should contain
160+
* { anthropic: { signature } } to allow reasoning replay.
161+
*/
162+
providerOptions?: {
163+
anthropic?: {
164+
signature?: string;
165+
};
166+
};
149167
}
150168

151169
// File/Image part type for multimodal messages (matches AI SDK FileUIPart)

src/node/services/streamManager.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ globalThis.AI_SDK_LOG_WARNINGS = false;
4949
interface ReasoningDeltaPart {
5050
type: "reasoning-delta";
5151
text?: string;
52+
delta?: string;
53+
providerMetadata?: {
54+
anthropic?: {
55+
signature?: string;
56+
redactedData?: string;
57+
};
58+
};
5259
}
5360

5461
// Branded types for compile-time safety
@@ -480,6 +487,7 @@ export class StreamManager extends EventEmitter {
480487
delta: part.text,
481488
tokens,
482489
timestamp,
490+
signature: part.signature,
483491
});
484492
} else if (part.type === "dynamic-tool") {
485493
const inputText = JSON.stringify(part.input);
@@ -926,18 +934,45 @@ export class StreamManager extends EventEmitter {
926934

927935
case "reasoning-delta": {
928936
// Both Anthropic and OpenAI use reasoning-delta for streaming reasoning content
929-
const delta = (part as ReasoningDeltaPart).text ?? "";
937+
const reasoningPart = part as ReasoningDeltaPart;
938+
const delta = reasoningPart.text ?? reasoningPart.delta ?? "";
939+
const signature = reasoningPart.providerMetadata?.anthropic?.signature;
940+
941+
// Signature deltas come separately with empty text - attach to last reasoning part
942+
if (signature && !delta) {
943+
const lastPart = streamInfo.parts.at(-1);
944+
if (lastPart?.type === "reasoning") {
945+
lastPart.signature = signature;
946+
// Also set providerOptions for SDK compatibility when converting to ModelMessages
947+
lastPart.providerOptions = { anthropic: { signature } };
948+
// Emit signature update event
949+
this.emit("reasoning-delta", {
950+
type: "reasoning-delta",
951+
workspaceId: workspaceId as string,
952+
messageId: streamInfo.messageId,
953+
delta: "",
954+
tokens: 0,
955+
timestamp: Date.now(),
956+
signature,
957+
});
958+
void this.schedulePartialWrite(workspaceId, streamInfo);
959+
}
960+
break;
961+
}
930962

931963
// Append each delta as a new part (merging happens at display time)
932-
const reasoningPart = {
964+
// Include providerOptions for SDK compatibility when converting to ModelMessages
965+
const newPart = {
933966
type: "reasoning" as const,
934967
text: delta,
935968
timestamp: Date.now(),
969+
signature, // May be undefined, will be filled by subsequent signature delta
970+
providerOptions: signature ? { anthropic: { signature } } : undefined,
936971
};
937-
streamInfo.parts.push(reasoningPart);
972+
streamInfo.parts.push(newPart);
938973

939974
// Emit using shared logic (ensures replay consistency)
940-
await this.emitPartAsEvent(workspaceId, streamInfo.messageId, reasoningPart);
975+
await this.emitPartAsEvent(workspaceId, streamInfo.messageId, newPart);
941976

942977
void this.schedulePartialWrite(workspaceId, streamInfo);
943978
break;

0 commit comments

Comments
 (0)