Skip to content

Commit 5835eb2

Browse files
committed
fix: self-heal empty ModelMessages at request-build time
Add defensive filter after convertToModelMessages to catch any empty assistant messages that slip through. This is a final safety net - if corrupted history produces empty ModelMessages, they are filtered out before sending to the API. Also adds integration tests that seed corrupted history and verify the system self-heals.
1 parent 00d1892 commit 5835eb2

File tree

2 files changed

+172
-1
lines changed

2 files changed

+172
-1
lines changed

src/node/services/aiService.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1202,11 +1202,26 @@ export class AIService extends EventEmitter {
12021202
// Convert MuxMessage to ModelMessage format using Vercel AI SDK utility
12031203
// Type assertion needed because MuxMessage has custom tool parts for interrupted tools
12041204
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
1205-
const modelMessages = convertToModelMessages(sanitizedMessages as any, {
1205+
const rawModelMessages = convertToModelMessages(sanitizedMessages as any, {
12061206
// Drop unfinished tool calls (input-streaming/input-available) so downstream
12071207
// transforms only see tool calls that actually produced outputs.
12081208
ignoreIncompleteToolCalls: true,
12091209
});
1210+
1211+
// Self-healing: Filter out any empty ModelMessages that could brick the request.
1212+
// The SDK's ignoreIncompleteToolCalls can drop all parts from a message, leaving
1213+
// an assistant with empty content array. The API rejects these with "all messages
1214+
// must have non-empty content except for the optional final assistant message".
1215+
const modelMessages = rawModelMessages.filter((msg) => {
1216+
if (msg.role !== "assistant") return true;
1217+
if (typeof msg.content === "string") return msg.content.length > 0;
1218+
return Array.isArray(msg.content) && msg.content.length > 0;
1219+
});
1220+
if (modelMessages.length < rawModelMessages.length) {
1221+
log.debug(
1222+
`Self-healing: Filtered ${rawModelMessages.length - modelMessages.length} empty ModelMessage(s)`
1223+
);
1224+
}
12101225
log.debug_obj(`${workspaceId}/2_model_messages.json`, modelMessages);
12111226

12121227
// Apply ModelMessage transforms based on provider requirements
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/**
2+
* Test that corrupted chat history with empty assistant messages
3+
* does not brick the workspace (self-healing behavior).
4+
*
5+
* Reproduction of: "messages.95: all messages must have non-empty content
6+
* except for the optional final assistant message"
7+
*/
8+
import { setupWorkspace, shouldRunIntegrationTests, validateApiKeys } from "./setup";
9+
import {
10+
sendMessageWithModel,
11+
createStreamCollector,
12+
modelString,
13+
HAIKU_MODEL,
14+
} from "./helpers";
15+
import { HistoryService } from "../../src/node/services/historyService";
16+
import { createMuxMessage } from "../../src/common/types/message";
17+
18+
// Skip all tests if TEST_INTEGRATION is not set
19+
const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip;
20+
21+
// Validate API keys before running tests
22+
if (shouldRunIntegrationTests()) {
23+
validateApiKeys(["ANTHROPIC_API_KEY"]);
24+
}
25+
26+
describeIntegration("empty assistant message self-healing", () => {
27+
test.concurrent(
28+
"should handle corrupted history with empty assistant parts array",
29+
async () => {
30+
const { env, workspaceId, cleanup } = await setupWorkspace("anthropic");
31+
try {
32+
const historyService = new HistoryService(env.config);
33+
34+
// Seed history that mimics a crash-corrupted chat.jsonl:
35+
// 1. User message
36+
// 2. Assistant message with content
37+
// 3. User follow-up
38+
// 4. Empty assistant message (crash during stream start - placeholder persisted)
39+
const messages = [
40+
createMuxMessage("msg-1", "user", "Hello", {}),
41+
createMuxMessage("msg-2", "assistant", "Hi there!", {}),
42+
createMuxMessage("msg-3", "user", "Follow up question", {}),
43+
// Corrupted: empty parts array (placeholder message from crash)
44+
{
45+
id: "msg-4-corrupted",
46+
role: "assistant" as const,
47+
parts: [], // Empty - this is the corruption
48+
metadata: {
49+
timestamp: Date.now(),
50+
model: "anthropic:claude-haiku-4-5",
51+
mode: "exec" as const,
52+
historySequence: 3,
53+
},
54+
},
55+
];
56+
57+
// Write corrupted history directly
58+
for (const msg of messages) {
59+
const result = await historyService.appendToHistory(workspaceId, msg as any);
60+
if (!result.success) {
61+
throw new Error(`Failed to seed history: ${result.error}`);
62+
}
63+
}
64+
65+
// Now try to send a new message - this should NOT fail with
66+
// "all messages must have non-empty content"
67+
const collector = createStreamCollector(env.orpc, workspaceId);
68+
collector.start();
69+
70+
const sendResult = await sendMessageWithModel(
71+
env,
72+
workspaceId,
73+
"This should work despite corrupted history",
74+
HAIKU_MODEL
75+
);
76+
77+
// The send should succeed (not fail due to corrupted history)
78+
expect(sendResult.success).toBe(true);
79+
80+
// Wait for stream to complete successfully
81+
const streamEnd = await collector.waitForEvent("stream-end", 30000);
82+
expect(streamEnd).toBeDefined();
83+
84+
collector.stop();
85+
} finally {
86+
await cleanup();
87+
}
88+
},
89+
60000
90+
);
91+
92+
test.concurrent(
93+
"should handle corrupted history with incomplete tool-only assistant message",
94+
async () => {
95+
const { env, workspaceId, cleanup } = await setupWorkspace("anthropic");
96+
try {
97+
const historyService = new HistoryService(env.config);
98+
99+
// Seed history with an assistant message that has only an incomplete tool call
100+
// (state: "input-available" means tool was requested but never executed)
101+
const messages = [
102+
createMuxMessage("msg-1", "user", "Run a command", {}),
103+
// Corrupted: tool-only with incomplete state
104+
{
105+
id: "msg-2-corrupted",
106+
role: "assistant" as const,
107+
parts: [
108+
{
109+
type: "dynamic-tool" as const,
110+
toolName: "bash",
111+
toolCallId: "call-123",
112+
state: "input-available" as const, // Incomplete - will be dropped by SDK
113+
input: { script: "echo hello" },
114+
},
115+
],
116+
metadata: {
117+
timestamp: Date.now(),
118+
model: "anthropic:claude-haiku-4-5",
119+
mode: "exec" as const,
120+
historySequence: 1,
121+
},
122+
},
123+
];
124+
125+
// Write corrupted history directly
126+
for (const msg of messages) {
127+
const result = await historyService.appendToHistory(workspaceId, msg as any);
128+
if (!result.success) {
129+
throw new Error(`Failed to seed history: ${result.error}`);
130+
}
131+
}
132+
133+
// Now try to send a new message
134+
const collector = createStreamCollector(env.orpc, workspaceId);
135+
collector.start();
136+
137+
const sendResult = await sendMessageWithModel(
138+
env,
139+
workspaceId,
140+
"This should work despite corrupted tool history",
141+
HAIKU_MODEL
142+
);
143+
144+
expect(sendResult.success).toBe(true);
145+
146+
const streamEnd = await collector.waitForEvent("stream-end", 30000);
147+
expect(streamEnd).toBeDefined();
148+
149+
collector.stop();
150+
} finally {
151+
await cleanup();
152+
}
153+
},
154+
60000
155+
);
156+
});

0 commit comments

Comments
 (0)