Skip to content
7 changes: 7 additions & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ gh pr view <number> --json mergeable,mergeStateStatus | jq '.'
## Repo Reference

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

## Documentation Rules
Expand Down Expand Up @@ -69,6 +70,12 @@ gh pr view <number> --json mergeable,mergeStateStatus | jq '.'
- Use `git mv` to retain history when moving files.
- 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).

## Self-Healing & Crash Resilience

- 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.
- Never let a single malformed line in history brick a workspace—apply defensive filtering in request-building paths so the user can continue working.
- 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.

## Testing Doctrine

Two types of tests are preferred:
Expand Down
4 changes: 2 additions & 2 deletions src/browser/stories/App.projectSettings.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ async function openProjectSettings(canvasElement: HTMLElement): Promise<void> {
const settingsButton = await canvas.findByTestId("settings-button", {}, { timeout: 10000 });
await userEvent.click(settingsButton);

await body.findByRole("dialog");
await body.findByRole("dialog", {}, { timeout: 10000 });

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

// Wait for dialog
await body.findByRole("dialog");
await body.findByRole("dialog", {}, { timeout: 10000 });
}

// ═══════════════════════════════════════════════════════════════════════════════
Expand Down
17 changes: 16 additions & 1 deletion src/browser/utils/messages/ChatEventProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,12 +254,27 @@ export function createChatEventProcessor(): ChatEventProcessor {

const lastPart = message.parts.at(-1);
if (lastPart?.type === "reasoning") {
lastPart.text += event.delta;
// Signature updates come with empty delta - just update the signature
if (event.signature && !event.delta) {
lastPart.signature = event.signature;
lastPart.providerOptions = { anthropic: { signature: event.signature } };
} else {
lastPart.text += event.delta;
// Also capture signature if present with text
if (event.signature) {
lastPart.signature = event.signature;
lastPart.providerOptions = { anthropic: { signature: event.signature } };
}
}
} else {
message.parts.push({
type: "reasoning",
text: event.delta,
timestamp: event.timestamp,
signature: event.signature,
providerOptions: event.signature
? { anthropic: { signature: event.signature } }
: undefined,
});
}
return;
Expand Down
66 changes: 65 additions & 1 deletion src/browser/utils/messages/modelMessageTransform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ describe("modelMessageTransform", () => {
expect(lastAssistant).toBeTruthy();
expect(Array.isArray(lastAssistant?.content)).toBe(true);
if (Array.isArray(lastAssistant?.content)) {
expect(lastAssistant.content[0]).toEqual({ type: "reasoning", text: "" });
expect(lastAssistant.content[0]).toEqual({ type: "reasoning", text: "..." });
}
});
it("should keep text-only messages unchanged", () => {
Expand Down Expand Up @@ -1151,6 +1151,70 @@ describe("filterEmptyAssistantMessages", () => {
expect(result2[0].id).toBe("assistant-1");
});

it("should filter out assistant messages with only incomplete tool calls (input-available)", () => {
const messages: MuxMessage[] = [
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Run a command" }],
metadata: { timestamp: 1000 },
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "dynamic-tool",
state: "input-available",
toolCallId: "call-1",
toolName: "bash",
input: { script: "pwd" },
},
],
metadata: { timestamp: 2000, partial: true },
},
{
id: "user-2",
role: "user",
parts: [{ type: "text", text: "Continue" }],
metadata: { timestamp: 3000 },
},
];

// Incomplete tool calls are dropped by convertToModelMessages(ignoreIncompleteToolCalls: true),
// so we must treat them as empty here to avoid generating an invalid request.
const result = filterEmptyAssistantMessages(messages, false);
expect(result.map((m) => m.id)).toEqual(["user-1", "user-2"]);
});

it("should preserve assistant messages with completed tool calls (output-available)", () => {
const messages: MuxMessage[] = [
{
id: "user-1",
role: "user",
parts: [{ type: "text", text: "Run a command" }],
metadata: { timestamp: 1000 },
},
{
id: "assistant-1",
role: "assistant",
parts: [
{
type: "dynamic-tool",
state: "output-available",
toolCallId: "call-1",
toolName: "bash",
input: { script: "pwd" },
output: { stdout: "/home/user" },
},
],
metadata: { timestamp: 2000 },
},
];

const result = filterEmptyAssistantMessages(messages, false);
expect(result.map((m) => m.id)).toEqual(["user-1", "assistant-1"]);
});
it("should filter out assistant messages with only empty text regardless of preserveReasoningOnly", () => {
const messages: MuxMessage[] = [
{
Expand Down
105 changes: 95 additions & 10 deletions src/browser/utils/messages/modelMessageTransform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,37 @@ export function filterEmptyAssistantMessages(
return false;
}

// Keep assistant messages that have at least one text or tool part
const hasContent = msg.parts.some(
(part) => (part.type === "text" && part.text) || part.type === "dynamic-tool"
);
// Keep assistant messages that have at least one part that will survive
// conversion to provider ModelMessages.
//
// Important: We call convertToModelMessages(..., { ignoreIncompleteToolCalls: true }).
// That means *incomplete* tool calls (state: "input-available") will be dropped.
// If we treat them as content here, we can end up sending an assistant message that
// becomes empty after conversion, which the AI SDK rejects ("all messages must have
// non-empty content...") and can brick a workspace after a crash.
const hasContent = msg.parts.some((part) => {
if (part.type === "text") {
return part.text.length > 0;
}

// Reasoning-only messages are handled below (provider-dependent).
if (part.type === "reasoning") {
return false;
}

if (part.type === "dynamic-tool") {
// Only completed tool calls produce content that can be replayed to the model.
return part.state === "output-available";
}

// File/image parts count as content.
if (part.type === "file") {
return true;
}

// Future-proofing: unknown parts should not brick the request.
return true;
});

if (hasContent) {
return true;
Expand Down Expand Up @@ -463,6 +490,62 @@ function filterReasoningOnlyMessages(messages: ModelMessage[]): ModelMessage[] {
});
}

/**
* Strip Anthropic reasoning parts that lack a valid signature.
*
* Anthropic's Extended Thinking API requires thinking blocks to include a signature
* for replay. The Vercel AI SDK's Anthropic provider only sends reasoning parts to
* the API if they have providerOptions.anthropic.signature. Reasoning parts we create
* (placeholders) or from history (where we didn't capture the signature) will be
* silently dropped by the SDK.
*
* If all parts of an assistant message are unsigned reasoning, the SDK drops them all,
* leaving an empty message that Anthropic rejects with:
* "all messages must have non-empty content except for the optional final assistant message"
*
* This function removes unsigned reasoning upfront and filters resulting empty messages.
*
* NOTE: This is Anthropic-specific. Other providers (e.g., OpenAI) handle reasoning
* differently and don't require signatures.
*/
function stripUnsignedAnthropicReasoning(messages: ModelMessage[]): ModelMessage[] {
const stripped = messages.map((msg) => {
if (msg.role !== "assistant") {
return msg;
}

const assistantMsg = msg;
if (typeof assistantMsg.content === "string") {
return msg;
}

// Filter out reasoning parts without anthropic.signature in providerOptions
const content = assistantMsg.content.filter((part) => {
if (part.type !== "reasoning") {
return true;
}
// Check for anthropic.signature in providerOptions
const anthropicMeta = (part.providerOptions as { anthropic?: { signature?: string } })
?.anthropic;
return anthropicMeta?.signature != null;
});

const result: typeof assistantMsg = { ...assistantMsg, content };
return result;
});

// Filter out messages that became empty after stripping reasoning
return stripped.filter((msg) => {
if (msg.role !== "assistant") {
return true;
}
if (typeof msg.content === "string") {
return msg.content.length > 0;
}
return msg.content.length > 0;
});
}

/**
* Coalesce consecutive parts of the same type within each message.
* Streaming creates many individual text/reasoning parts; merge them for easier debugging.
Expand Down Expand Up @@ -512,7 +595,6 @@ function coalesceConsecutiveParts(messages: ModelMessage[]): ModelMessage[] {
};
});
}

/**
* Merge consecutive user messages with newline separators.
* When filtering removes assistant messages, we can end up with consecutive user messages.
Expand Down Expand Up @@ -610,9 +692,10 @@ function ensureAnthropicThinkingBeforeToolCalls(messages: ModelMessage[]): Model
}

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

result.push({
Expand Down Expand Up @@ -641,7 +724,7 @@ function ensureAnthropicThinkingBeforeToolCalls(messages: ModelMessage[]): Model
result[i] = {
...assistantMsg,
content: [
{ type: "reasoning" as const, text: "" },
{ type: "reasoning" as const, text: "..." },
{ type: "text" as const, text },
],
};
Expand All @@ -658,7 +741,7 @@ function ensureAnthropicThinkingBeforeToolCalls(messages: ModelMessage[]): Model

result[i] = {
...assistantMsg,
content: [{ type: "reasoning" as const, text: "" }, ...content],
content: [{ type: "reasoning" as const, text: "..." }, ...content],
};
break;
}
Expand Down Expand Up @@ -703,7 +786,9 @@ export function transformModelMessages(
// Anthropic: When extended thinking is enabled, preserve reasoning-only messages and ensure
// tool-call messages start with reasoning. When it's disabled, filter reasoning-only messages.
if (options?.anthropicThinkingEnabled) {
reasoningHandled = ensureAnthropicThinkingBeforeToolCalls(split);
// First strip reasoning without signatures (SDK will drop them anyway, causing empty messages)
const signedReasoning = stripUnsignedAnthropicReasoning(split);
reasoningHandled = ensureAnthropicThinkingBeforeToolCalls(signedReasoning);
} else {
reasoningHandled = filterReasoningOnlyMessages(split);
}
Expand Down
52 changes: 42 additions & 10 deletions src/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ program
.option("--json", "output NDJSON for programmatic consumption")
.option("-q, --quiet", "only output final result")
.option("--workspace-id <id>", "explicit workspace ID (auto-generated if not provided)")
.option("--workspace <id>", "continue an existing workspace (loads history, skips init)")
.option("--config-root <path>", "mux config directory")
.option("--mcp <server>", "MCP server as name=command (can be repeated)", collectMcpServers, [])
.option("--no-mcp-config", "ignore .mux/mcp.jsonc, use only --mcp servers")
Expand Down Expand Up @@ -227,6 +228,7 @@ interface CLIOptions {
json?: boolean;
quiet?: boolean;
workspaceId?: string;
workspace?: string;
configRoot?: string;
mcp: MCPServerEntry[];
mcpConfig: boolean;
Expand All @@ -250,10 +252,6 @@ async function main(): Promise<void> {
}
// Default is already "warn" for CLI mode (set in log.ts)

// Resolve directory
const projectDir = path.resolve(opts.dir);
await ensureDirectory(projectDir);

// Get message from arg or stdin
const stdinMessage = await gatherMessageFromStdin();
const message = messageArg?.trim() ?? stdinMessage.trim();
Expand All @@ -266,7 +264,35 @@ async function main(): Promise<void> {

// Setup config
const config = new Config(opts.configRoot);
const workspaceId = opts.workspaceId ?? generateWorkspaceId();

// Determine if continuing an existing workspace
const continueWorkspace = opts.workspace;
const workspaceId = continueWorkspace ?? opts.workspaceId ?? generateWorkspaceId();

// Resolve directory - for continuing workspace, try to get from metadata
let projectDir: string;
if (continueWorkspace) {
const metadataPath = path.join(config.sessionsDir, continueWorkspace, "metadata.json");
try {
const metadataContent = await fs.readFile(metadataPath, "utf-8");
const metadata = JSON.parse(metadataContent) as { projectPath?: string };
if (metadata.projectPath) {
projectDir = metadata.projectPath;
log.info(`Continuing workspace ${continueWorkspace}, using project path: ${projectDir}`);
} else {
projectDir = path.resolve(opts.dir);
log.warn(`No projectPath in metadata, using --dir: ${projectDir}`);
}
} catch {
// Metadata doesn't exist or is invalid, fall back to --dir
projectDir = path.resolve(opts.dir);
log.warn(`Could not read metadata for ${continueWorkspace}, using --dir: ${projectDir}`);
}
} else {
projectDir = path.resolve(opts.dir);
await ensureDirectory(projectDir);
}

const model: string = opts.model;
const runtimeConfig = parseRuntimeConfig(opts.runtime, config.srcDir);
const thinkingLevel = parseThinkingLevel(opts.thinking);
Expand Down Expand Up @@ -333,11 +359,17 @@ async function main(): Promise<void> {
backgroundProcessManager,
});

await session.ensureMetadata({
workspacePath: projectDir,
projectName: path.basename(projectDir),
runtimeConfig,
});
// For continuing workspace, metadata should already exist
// For new workspace, create it
if (!continueWorkspace) {
await session.ensureMetadata({
workspacePath: projectDir,
projectName: path.basename(projectDir),
runtimeConfig,
});
} else {
log.info(`Continuing workspace ${workspaceId} - using existing metadata`);
}

const buildSendOptions = (cliMode: CLIMode): SendMessageOptions => ({
model,
Expand Down
Loading