Skip to content

Commit ecc2e22

Browse files
authored
🤖 fix: avoid invalid requests from tool-only partials (#1251)
## Summary Fixes Anthropic API validation error: `messages.X: all messages must have non-empty content except for the optional final assistant message` ## 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`. When all parts of an assistant message are unsigned reasoning, the SDK drops them all, leaving an empty message that Anthropic rejects. ## Changes ### Core Fix - **`stripUnsendableReasoning()`** - Removes reasoning parts without signatures before API calls, preventing empty messages from reaching Anthropic ### Signature Capture (for future reasoning replay) - Add `signature` and `providerOptions` fields to `MuxReasoningPart` - Add `signature` to `ReasoningDeltaEventSchema` for event transmission - Capture signatures from SDK stream events (`signature_delta`) - Store `providerOptions.anthropic.signature` for SDK compatibility ### Debug Tooling - Add `--workspace <id>` flag to CLI for testing against existing workspaces ## Testing Verified against a corrupted workspace (`e8dab40a78`) with 286 empty assistant messages that previously caused consistent API failures. After fix: - Empty assistant messages filtered correctly - Unsigned reasoning stripped before API call - Request accepted by Anthropic, streaming succeeded --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high`_
1 parent 4e342d5 commit ecc2e22

File tree

13 files changed

+520
-30
lines changed

13 files changed

+520
-30
lines changed

docs/AGENTS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ gh pr view <number> --json mergeable,mergeStateStatus | jq '.'
4141
## Repo Reference
4242

4343
- Core files: `src/main.ts`, `src/preload.ts`, `src/App.tsx`, `src/config.ts`.
44+
- Up-to-date model names: see `src/common/knownModels.ts` for current provider model IDs.
4445
- Persistent data: `~/.mux/config.json`, `~/.mux/src/<project>/<branch>` (worktrees), `~/.mux/sessions/<workspace>/chat.jsonl`.
4546

4647
## Documentation Rules
@@ -69,6 +70,12 @@ gh pr view <number> --json mergeable,mergeStateStatus | jq '.'
6970
- Use `git mv` to retain history when moving files.
7071
- Never kill the running mux process; rely on `make typecheck` + targeted `bun test path/to/file.test.ts` for validation (run `make test` only when necessary; it can be slow).
7172

73+
## Self-Healing & Crash Resilience
74+
75+
- Prefer **self-healing** behavior: if corrupted or invalid data exists in persisted state (e.g., `chat.jsonl`), the system should sanitize or filter it at load/request time rather than failing permanently.
76+
- Never let a single malformed line in history brick a workspace—apply defensive filtering in request-building paths so the user can continue working.
77+
- When streaming crashes, any incomplete state committed to disk should either be repairable on next load or excluded from provider requests to avoid API validation errors.
78+
7279
## Testing Doctrine
7380

7481
Two types of tests are preferred:

src/browser/stories/App.projectSettings.stories.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ async function openProjectSettings(canvasElement: HTMLElement): Promise<void> {
148148
const settingsButton = await canvas.findByTestId("settings-button", {}, { timeout: 10000 });
149149
await userEvent.click(settingsButton);
150150

151-
await body.findByRole("dialog");
151+
await body.findByRole("dialog", {}, { timeout: 10000 });
152152

153153
const projectsButton = await body.findByRole("button", { name: /Projects/i });
154154
await userEvent.click(projectsButton);
@@ -171,7 +171,7 @@ async function openWorkspaceMCPModal(canvasElement: HTMLElement): Promise<void>
171171
await userEvent.click(mcpButton);
172172

173173
// Wait for dialog
174-
await body.findByRole("dialog");
174+
await body.findByRole("dialog", {}, { timeout: 10000 });
175175
}
176176

177177
// ═══════════════════════════════════════════════════════════════════════════════

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.test.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ describe("modelMessageTransform", () => {
152152
expect(lastAssistant).toBeTruthy();
153153
expect(Array.isArray(lastAssistant?.content)).toBe(true);
154154
if (Array.isArray(lastAssistant?.content)) {
155-
expect(lastAssistant.content[0]).toEqual({ type: "reasoning", text: "" });
155+
expect(lastAssistant.content[0]).toEqual({ type: "reasoning", text: "..." });
156156
}
157157
});
158158
it("should keep text-only messages unchanged", () => {
@@ -1151,6 +1151,70 @@ describe("filterEmptyAssistantMessages", () => {
11511151
expect(result2[0].id).toBe("assistant-1");
11521152
});
11531153

1154+
it("should filter out assistant messages with only incomplete tool calls (input-available)", () => {
1155+
const messages: MuxMessage[] = [
1156+
{
1157+
id: "user-1",
1158+
role: "user",
1159+
parts: [{ type: "text", text: "Run a command" }],
1160+
metadata: { timestamp: 1000 },
1161+
},
1162+
{
1163+
id: "assistant-1",
1164+
role: "assistant",
1165+
parts: [
1166+
{
1167+
type: "dynamic-tool",
1168+
state: "input-available",
1169+
toolCallId: "call-1",
1170+
toolName: "bash",
1171+
input: { script: "pwd" },
1172+
},
1173+
],
1174+
metadata: { timestamp: 2000, partial: true },
1175+
},
1176+
{
1177+
id: "user-2",
1178+
role: "user",
1179+
parts: [{ type: "text", text: "Continue" }],
1180+
metadata: { timestamp: 3000 },
1181+
},
1182+
];
1183+
1184+
// Incomplete tool calls are dropped by convertToModelMessages(ignoreIncompleteToolCalls: true),
1185+
// so we must treat them as empty here to avoid generating an invalid request.
1186+
const result = filterEmptyAssistantMessages(messages, false);
1187+
expect(result.map((m) => m.id)).toEqual(["user-1", "user-2"]);
1188+
});
1189+
1190+
it("should preserve assistant messages with completed tool calls (output-available)", () => {
1191+
const messages: MuxMessage[] = [
1192+
{
1193+
id: "user-1",
1194+
role: "user",
1195+
parts: [{ type: "text", text: "Run a command" }],
1196+
metadata: { timestamp: 1000 },
1197+
},
1198+
{
1199+
id: "assistant-1",
1200+
role: "assistant",
1201+
parts: [
1202+
{
1203+
type: "dynamic-tool",
1204+
state: "output-available",
1205+
toolCallId: "call-1",
1206+
toolName: "bash",
1207+
input: { script: "pwd" },
1208+
output: { stdout: "/home/user" },
1209+
},
1210+
],
1211+
metadata: { timestamp: 2000 },
1212+
},
1213+
];
1214+
1215+
const result = filterEmptyAssistantMessages(messages, false);
1216+
expect(result.map((m) => m.id)).toEqual(["user-1", "assistant-1"]);
1217+
});
11541218
it("should filter out assistant messages with only empty text regardless of preserveReasoningOnly", () => {
11551219
const messages: MuxMessage[] = [
11561220
{

src/browser/utils/messages/modelMessageTransform.ts

Lines changed: 95 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,37 @@ export function filterEmptyAssistantMessages(
4444
return false;
4545
}
4646

47-
// Keep assistant messages that have at least one text or tool part
48-
const hasContent = msg.parts.some(
49-
(part) => (part.type === "text" && part.text) || part.type === "dynamic-tool"
50-
);
47+
// Keep assistant messages that have at least one part that will survive
48+
// conversion to provider ModelMessages.
49+
//
50+
// Important: We call convertToModelMessages(..., { ignoreIncompleteToolCalls: true }).
51+
// That means *incomplete* tool calls (state: "input-available") will be dropped.
52+
// If we treat them as content here, we can end up sending an assistant message that
53+
// becomes empty after conversion, which the AI SDK rejects ("all messages must have
54+
// non-empty content...") and can brick a workspace after a crash.
55+
const hasContent = msg.parts.some((part) => {
56+
if (part.type === "text") {
57+
return part.text.length > 0;
58+
}
59+
60+
// Reasoning-only messages are handled below (provider-dependent).
61+
if (part.type === "reasoning") {
62+
return false;
63+
}
64+
65+
if (part.type === "dynamic-tool") {
66+
// Only completed tool calls produce content that can be replayed to the model.
67+
return part.state === "output-available";
68+
}
69+
70+
// File/image parts count as content.
71+
if (part.type === "file") {
72+
return true;
73+
}
74+
75+
// Future-proofing: unknown parts should not brick the request.
76+
return true;
77+
});
5178

5279
if (hasContent) {
5380
return true;
@@ -463,6 +490,62 @@ function filterReasoningOnlyMessages(messages: ModelMessage[]): ModelMessage[] {
463490
});
464491
}
465492

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+
466549
/**
467550
* Coalesce consecutive parts of the same type within each message.
468551
* Streaming creates many individual text/reasoning parts; merge them for easier debugging.
@@ -512,7 +595,6 @@ function coalesceConsecutiveParts(messages: ModelMessage[]): ModelMessage[] {
512595
};
513596
});
514597
}
515-
516598
/**
517599
* Merge consecutive user messages with newline separators.
518600
* When filtering removes assistant messages, we can end up with consecutive user messages.
@@ -610,9 +692,10 @@ function ensureAnthropicThinkingBeforeToolCalls(messages: ModelMessage[]): Model
610692
}
611693

612694
// Anthropic extended thinking requires tool-use assistant messages to start with a thinking block.
613-
// If we still have no reasoning available, insert an empty reasoning part as a minimal placeholder.
695+
// If we still have no reasoning available, insert a minimal placeholder reasoning part.
696+
// NOTE: The text cannot be empty - Anthropic API rejects empty content.
614697
if (reasoningParts.length === 0) {
615-
reasoningParts = [{ type: "reasoning" as const, text: "" }];
698+
reasoningParts = [{ type: "reasoning" as const, text: "..." }];
616699
}
617700

618701
result.push({
@@ -641,7 +724,7 @@ function ensureAnthropicThinkingBeforeToolCalls(messages: ModelMessage[]): Model
641724
result[i] = {
642725
...assistantMsg,
643726
content: [
644-
{ type: "reasoning" as const, text: "" },
727+
{ type: "reasoning" as const, text: "..." },
645728
{ type: "text" as const, text },
646729
],
647730
};
@@ -658,7 +741,7 @@ function ensureAnthropicThinkingBeforeToolCalls(messages: ModelMessage[]): Model
658741

659742
result[i] = {
660743
...assistantMsg,
661-
content: [{ type: "reasoning" as const, text: "" }, ...content],
744+
content: [{ type: "reasoning" as const, text: "..." }, ...content],
662745
};
663746
break;
664747
}
@@ -703,7 +786,9 @@ export function transformModelMessages(
703786
// Anthropic: When extended thinking is enabled, preserve reasoning-only messages and ensure
704787
// tool-call messages start with reasoning. When it's disabled, filter reasoning-only messages.
705788
if (options?.anthropicThinkingEnabled) {
706-
reasoningHandled = ensureAnthropicThinkingBeforeToolCalls(split);
789+
// First strip reasoning without signatures (SDK will drop them anyway, causing empty messages)
790+
const signedReasoning = stripUnsignedAnthropicReasoning(split);
791+
reasoningHandled = ensureAnthropicThinkingBeforeToolCalls(signedReasoning);
707792
} else {
708793
reasoningHandled = filterReasoningOnlyMessages(split);
709794
}

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,

0 commit comments

Comments
 (0)