Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/services/aiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,12 @@ export class AIService extends EventEmitter {
const [providerName] = modelString.split(":");

// Filter out assistant messages with only reasoning (no text/tools)
const filteredMessages = filterEmptyAssistantMessages(messages);
// Pass provider and thinking level for context-aware filtering
const filteredMessages = filterEmptyAssistantMessages(
messages,
providerName,
thinkingLevel
);
log.debug(`Filtered ${messages.length - filteredMessages.length} empty assistant messages`);
log.debug_obj(`${workspaceId}/1a_filtered_messages.json`, filteredMessages);

Expand Down
33 changes: 30 additions & 3 deletions src/utils/messages/modelMessageTransform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,26 @@ import type { CmuxMessage } from "@/types/message";

/**
* Filter out assistant messages that only contain reasoning parts (no text or tool parts).
* These messages are invalid for the API and provide no value to the model.
* These messages are invalid for most API calls and provide no value to the model.
* This happens when a message is interrupted during thinking before producing any text.
*
* EXCEPTION: For Anthropic, when resuming a partial message with thinking enabled,
* we preserve reasoning-only messages. This is because Anthropic's extended thinking
* API expects reasoning content to be present in the history.
*
* Note: This function filters out reasoning-only messages but does NOT strip reasoning
* parts from messages that have other content. Reasoning parts are handled differently
* per provider (see stripReasoningForOpenAI).
*
* @param messages - Messages to filter
* @param provider - AI provider (optional, for context-aware filtering)
* @param thinkingLevel - Thinking level setting (optional, for context-aware filtering)
*/
export function filterEmptyAssistantMessages(messages: CmuxMessage[]): CmuxMessage[] {
export function filterEmptyAssistantMessages(
messages: CmuxMessage[],
provider?: string,
thinkingLevel?: string
): CmuxMessage[] {
return messages.filter((msg) => {
// Keep all non-assistant messages
if (msg.role !== "assistant") {
Expand All @@ -27,7 +39,22 @@ export function filterEmptyAssistantMessages(messages: CmuxMessage[]): CmuxMessa
(part) => (part.type === "text" && part.text) || part.type === "dynamic-tool"
);

return hasContent;
if (hasContent) {
return true;
}

// For Anthropic with thinking enabled, preserve reasoning-only partial messages
// This prevents "Expected `thinking` but found `text`" errors on resume
if (
provider === "anthropic" &&
thinkingLevel && thinkingLevel !== "off" &&
msg.metadata?.partial
) {
return true; // Keep reasoning-only messages for Anthropic thinking
}

// Otherwise filter out reasoning-only messages
return false;
});
}

Expand Down
149 changes: 149 additions & 0 deletions tests/ipcMain/resumeStream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,153 @@ describeIntegration("IpcMain resumeStream integration tests", () => {
},
45000 // 45 second timeout for this test
);

// Define tricky message histories that could cause issues
const trickyHistories = [
{
name: "reasoning-only",
description: "Assistant message with only reasoning, no text",
createMessage: (id: string) => ({
id,
role: "assistant" as const,
parts: [{ type: "reasoning" as const, text: "Let me think about this..." }],
metadata: { historySequence: 2, partial: true },
}),
},
{
name: "empty-text",
description: "Assistant message with empty text content",
createMessage: (id: string) => ({
id,
role: "assistant" as const,
parts: [{ type: "text" as const, text: "" }],
metadata: { historySequence: 2, partial: true },
}),
},
{
name: "reasoning-then-empty-text",
description: "Assistant message with reasoning followed by empty text",
createMessage: (id: string) => ({
id,
role: "assistant" as const,
parts: [
{ type: "reasoning" as const, text: "Thinking deeply..." },
{ type: "text" as const, text: "" },
],
metadata: { historySequence: 2, partial: true },
}),
},
{
name: "multiple-reasoning-blocks",
description: "Assistant message with multiple reasoning blocks, no text",
createMessage: (id: string) => ({
id,
role: "assistant" as const,
parts: [
{ type: "reasoning" as const, text: "First thought..." },
{ type: "reasoning" as const, text: "Second thought..." },
],
metadata: { historySequence: 2, partial: true },
}),
},
{
name: "whitespace-only-text",
description: "Assistant message with whitespace-only text content",
createMessage: (id: string) => ({
id,
role: "assistant" as const,
parts: [{ type: "text" as const, text: " \n\t " }],
metadata: { historySequence: 2, partial: true },
}),
},
];

test.concurrent.each([
{ provider: "anthropic" as const, model: "claude-sonnet-4-5" },
{ provider: "openai" as const, model: "gpt-4o" },
])(
"should handle resume with tricky message histories ($provider)",
async ({ provider, model }) => {
const { HistoryService } = await import("../../src/services/historyService");
const { createCmuxMessage } = await import("../../src/types/message");

for (const history of trickyHistories) {
const { env, workspaceId, cleanup } = await setupWorkspace(provider);
try {
// Create history service to directly manipulate messages
const historyService = new HistoryService(env.config);

// Create a user message first
const userMessage = createCmuxMessage(
`user-${Date.now()}`,
"user",
"Please help me with this task.",
{ historySequence: 1 }
);

const userAppendResult = await historyService.appendToHistory(
workspaceId,
userMessage
);
expect(userAppendResult.success).toBe(true);

// Create the tricky assistant message
const trickyMessage = history.createMessage(`assistant-${Date.now()}`);

// Append the tricky message to history
const appendResult = await historyService.appendToHistory(
workspaceId,
trickyMessage
);
expect(appendResult.success).toBe(true);

// Clear events before resume
env.sentEvents.length = 0;

// Resume the stream with thinking enabled
// This exercises the context-aware filtering logic
const resumeResult = (await env.mockIpcRenderer.invoke(
IPC_CHANNELS.WORKSPACE_RESUME_STREAM,
workspaceId,
{ model: `${provider}:${model}`, thinkingLevel: "high" }
)) as Result<void, SendMessageError>;

// Should succeed for all tricky histories with the fix
if (!resumeResult.success) {
console.error(
`[${provider}/${history.name}] Failed to resume:`,
resumeResult.error
);
}
expect(resumeResult.success).toBe(true);

// Verify the stream completes successfully
const collector = createEventCollector(env.sentEvents, workspaceId);
const streamEnd = await collector.waitForEvent("stream-end", 30000);
expect(streamEnd).toBeDefined();

// Verify no errors occurred during streaming
collector.collect();
const streamErrors = collector
.getEvents()
.filter((e) => "type" in e && e.type === "stream-error");

if (streamErrors.length > 0) {
console.error(
`[${provider}/${history.name}] Stream errors:`,
streamErrors
);
}
expect(streamErrors.length).toBe(0);

// Verify we received some content
const deltas = collector.getDeltas();
expect(deltas.length).toBeGreaterThan(0);
} finally {
await cleanup();
}
}
},
90000 // 90 second timeout - testing multiple scenarios per provider
);
});
Loading