Skip to content

Commit 4e97ff9

Browse files
authored
🤖 refactor: simplify model message prep pipeline (#594)
## Summary - rely on Vercel AI SDK's convertToModelMessages ignoreIncompleteToolCalls option so we no longer hand-edit partial tool calls - drop the bespoke splitMixedContentMessages pass and streamline the provider transform logic - update the unit tests to reflect the simpler pipeline while keeping reasoning/tool coverage ## Testing - make typecheck - make test _Generated with `mux`_
1 parent 6f906a3 commit 4e97ff9

File tree

3 files changed

+145
-278
lines changed

3 files changed

+145
-278
lines changed

src/services/aiService.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -599,7 +599,11 @@ export class AIService extends EventEmitter {
599599
// Convert MuxMessage to ModelMessage format using Vercel AI SDK utility
600600
// Type assertion needed because MuxMessage has custom tool parts for interrupted tools
601601
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
602-
const modelMessages = convertToModelMessages(sanitizedMessages as any);
602+
const modelMessages = convertToModelMessages(sanitizedMessages as any, {
603+
// Drop unfinished tool calls (input-streaming/input-available) so downstream
604+
// transforms only see tool calls that actually produced outputs.
605+
ignoreIncompleteToolCalls: true,
606+
});
603607
log.debug_obj(`${workspaceId}/2_model_messages.json`, modelMessages);
604608

605609
// Apply ModelMessage transforms based on provider requirements

src/utils/messages/modelMessageTransform.test.ts

Lines changed: 59 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -29,65 +29,13 @@ describe("modelMessageTransform", () => {
2929
expect(result).toEqual(messages);
3030
});
3131

32-
it("should keep text-only messages unchanged", () => {
33-
const assistantMsg1: AssistantModelMessage = {
34-
role: "assistant",
35-
content: [{ type: "text", text: "Let me help you with that." }],
36-
};
37-
const assistantMsg2: AssistantModelMessage = {
38-
role: "assistant",
39-
content: [{ type: "text", text: "Here's the result." }],
40-
};
41-
const messages: ModelMessage[] = [assistantMsg1, assistantMsg2];
42-
43-
const result = transformModelMessages(messages, "anthropic");
44-
expect(result).toEqual(messages);
45-
});
46-
47-
it("should strip tool calls without results (interrupted mixed content)", () => {
32+
it("should split mixed text and tool-call content into ordered segments", () => {
4833
const assistantMsg: AssistantModelMessage = {
4934
role: "assistant",
5035
content: [
51-
{ type: "text", text: "Let me check that for you." },
52-
{ type: "tool-call", toolCallId: "call1", toolName: "bash", input: { script: "ls" } },
53-
],
54-
};
55-
const messages: ModelMessage[] = [assistantMsg];
56-
57-
const result = transformModelMessages(messages, "anthropic");
58-
59-
// Should only keep text, strip interrupted tool calls
60-
expect(result).toHaveLength(1);
61-
expect(result[0].role).toBe("assistant");
62-
expect((result[0] as AssistantModelMessage).content).toEqual([
63-
{ type: "text", text: "Let me check that for you." },
64-
]);
65-
});
66-
67-
it("should strip tool-only messages without results (orphaned tool calls)", () => {
68-
const assistantMsg: AssistantModelMessage = {
69-
role: "assistant",
70-
content: [
71-
{ type: "tool-call", toolCallId: "call1", toolName: "bash", input: { script: "ls" } },
72-
],
73-
};
74-
const messages: ModelMessage[] = [assistantMsg];
75-
76-
const result = transformModelMessages(messages, "anthropic");
77-
78-
// Should filter out the entire message since it only has orphaned tool calls
79-
expect(result).toHaveLength(0);
80-
});
81-
82-
it("should handle partial results (some tool calls interrupted)", () => {
83-
// Assistant makes 3 tool calls, but only 2 have results (3rd was interrupted)
84-
const assistantMsg: AssistantModelMessage = {
85-
role: "assistant",
86-
content: [
87-
{ type: "text", text: "Let me check a few things." },
36+
{ type: "text", text: "Before" },
8837
{ type: "tool-call", toolCallId: "call1", toolName: "bash", input: { script: "pwd" } },
89-
{ type: "tool-call", toolCallId: "call2", toolName: "bash", input: { script: "ls" } },
90-
{ type: "tool-call", toolCallId: "call3", toolName: "bash", input: { script: "date" } },
38+
{ type: "text", text: "After" },
9139
],
9240
};
9341
const toolMsg: ToolModelMessage = {
@@ -99,80 +47,41 @@ describe("modelMessageTransform", () => {
9947
toolName: "bash",
10048
output: { type: "json", value: { stdout: "/home/user" } },
10149
},
102-
{
103-
type: "tool-result",
104-
toolCallId: "call2",
105-
toolName: "bash",
106-
output: { type: "json", value: { stdout: "file1 file2" } },
107-
},
108-
// call3 has no result (interrupted)
10950
],
11051
};
111-
const messages: ModelMessage[] = [assistantMsg, toolMsg];
11252

113-
const result = transformModelMessages(messages, "anthropic");
53+
const result = transformModelMessages([assistantMsg, toolMsg], "anthropic");
11454

115-
// Should have: text message, tool calls (only call1 & call2), tool results
116-
expect(result).toHaveLength(3);
117-
118-
// First: text
55+
expect(result).toHaveLength(4);
11956
expect(result[0].role).toBe("assistant");
12057
expect((result[0] as AssistantModelMessage).content).toEqual([
121-
{ type: "text", text: "Let me check a few things." },
58+
{ type: "text", text: "Before" },
12259
]);
123-
124-
// Second: only tool calls with results (call1, call2), NOT call3
12560
expect(result[1].role).toBe("assistant");
126-
const toolCallContent = (result[1] as AssistantModelMessage).content;
127-
expect(Array.isArray(toolCallContent)).toBe(true);
128-
if (Array.isArray(toolCallContent)) {
129-
expect(toolCallContent).toHaveLength(2);
130-
expect(toolCallContent[0]).toEqual({
131-
type: "tool-call",
132-
toolCallId: "call1",
133-
toolName: "bash",
134-
input: { script: "pwd" },
135-
});
136-
expect(toolCallContent[1]).toEqual({
137-
type: "tool-call",
138-
toolCallId: "call2",
139-
toolName: "bash",
140-
input: { script: "ls" },
141-
});
142-
}
143-
144-
// Third: tool results (only for call1 & call2)
61+
expect((result[1] as AssistantModelMessage).content).toEqual([
62+
{ type: "tool-call", toolCallId: "call1", toolName: "bash", input: { script: "pwd" } },
63+
]);
14564
expect(result[2].role).toBe("tool");
146-
const toolResultContent = (result[2] as ToolModelMessage).content;
147-
expect(toolResultContent).toHaveLength(2);
148-
expect(toolResultContent[0]).toEqual({
65+
expect((result[2] as ToolModelMessage).content[0]).toEqual({
14966
type: "tool-result",
15067
toolCallId: "call1",
15168
toolName: "bash",
15269
output: { type: "json", value: { stdout: "/home/user" } },
15370
});
154-
expect(toolResultContent[1]).toEqual({
155-
type: "tool-result",
156-
toolCallId: "call2",
157-
toolName: "bash",
158-
output: { type: "json", value: { stdout: "file1 file2" } },
159-
});
71+
expect(result[3].role).toBe("assistant");
72+
expect((result[3] as AssistantModelMessage).content).toEqual([
73+
{ type: "text", text: "After" },
74+
]);
16075
});
16176

162-
it("should handle mixed content with tool results properly", () => {
77+
it("should interleave multiple tool-call groups with their results", () => {
16378
const assistantMsg: AssistantModelMessage = {
16479
role: "assistant",
16580
content: [
166-
{ type: "text", text: "First, let me check something." },
81+
{ type: "text", text: "Step 1" },
16782
{ type: "tool-call", toolCallId: "call1", toolName: "bash", input: { script: "pwd" } },
83+
{ type: "text", text: "Step 2" },
16884
{ type: "tool-call", toolCallId: "call2", toolName: "bash", input: { script: "ls" } },
169-
{ type: "text", text: "Now let me check another thing." },
170-
{
171-
type: "tool-call",
172-
toolCallId: "call3",
173-
toolName: "file_read",
174-
input: { path: "test.txt" },
175-
},
17685
],
17786
};
17887
const toolMsg: ToolModelMessage = {
@@ -182,45 +91,60 @@ describe("modelMessageTransform", () => {
18291
type: "tool-result",
18392
toolCallId: "call1",
18493
toolName: "bash",
185-
output: { type: "json", value: { stdout: "/home/user" } },
94+
output: { type: "json", value: { stdout: "/workspace" } },
18695
},
18796
{
18897
type: "tool-result",
18998
toolCallId: "call2",
19099
toolName: "bash",
191-
output: { type: "json", value: { stdout: "file1 file2" } },
192-
},
193-
{
194-
type: "tool-result",
195-
toolCallId: "call3",
196-
toolName: "file_read",
197-
output: { type: "json", value: { content: "test content" } },
100+
output: { type: "json", value: { stdout: "file.txt" } },
198101
},
199102
],
200103
};
201-
const messages: ModelMessage[] = [assistantMsg, toolMsg];
202104

203-
const result = transformModelMessages(messages, "anthropic");
105+
const result = transformModelMessages([assistantMsg, toolMsg], "anthropic");
204106

205-
// Should split into multiple messages with tool results properly placed
206-
expect(result.length).toBeGreaterThan(2);
207-
208-
// First should be text
107+
expect(result).toHaveLength(6);
209108
expect(result[0].role).toBe("assistant");
210109
expect((result[0] as AssistantModelMessage).content).toEqual([
211-
{ type: "text", text: "First, let me check something." },
110+
{ type: "text", text: "Step 1" },
212111
]);
213-
214-
// Then tool calls with their results
215112
expect(result[1].role).toBe("assistant");
216-
const secondContent = (result[1] as AssistantModelMessage).content;
217-
expect(Array.isArray(secondContent)).toBe(true);
218-
if (Array.isArray(secondContent)) {
219-
expect(secondContent.some((c) => c.type === "tool-call")).toBe(true);
220-
}
221-
222-
// Tool results should follow tool calls
113+
expect((result[1] as AssistantModelMessage).content[0]).toEqual({
114+
type: "tool-call",
115+
toolCallId: "call1",
116+
toolName: "bash",
117+
input: { script: "pwd" },
118+
});
223119
expect(result[2].role).toBe("tool");
120+
expect((result[2] as ToolModelMessage).content[0]).toMatchObject({ toolCallId: "call1" });
121+
expect(result[3].role).toBe("assistant");
122+
expect((result[3] as AssistantModelMessage).content).toEqual([
123+
{ type: "text", text: "Step 2" },
124+
]);
125+
expect(result[4].role).toBe("assistant");
126+
expect((result[4] as AssistantModelMessage).content[0]).toEqual({
127+
type: "tool-call",
128+
toolCallId: "call2",
129+
toolName: "bash",
130+
input: { script: "ls" },
131+
});
132+
expect(result[5].role).toBe("tool");
133+
expect((result[5] as ToolModelMessage).content[0]).toMatchObject({ toolCallId: "call2" });
134+
});
135+
it("should keep text-only messages unchanged", () => {
136+
const assistantMsg1: AssistantModelMessage = {
137+
role: "assistant",
138+
content: [{ type: "text", text: "Let me help you with that." }],
139+
};
140+
const assistantMsg2: AssistantModelMessage = {
141+
role: "assistant",
142+
content: [{ type: "text", text: "Here's the result." }],
143+
};
144+
const messages: ModelMessage[] = [assistantMsg1, assistantMsg2];
145+
146+
const result = transformModelMessages(messages, "anthropic");
147+
expect(result).toEqual(messages);
224148
});
225149
});
226150

@@ -659,10 +583,10 @@ describe("modelMessageTransform", () => {
659583

660584
const result = transformModelMessages(messages, "openai");
661585

662-
// Should have user, text, tool-call, tool-result (no reasoning)
586+
// Should still contain user, assistant, and tool messages after filtering
663587
expect(result.length).toBeGreaterThan(2);
664588

665-
// Find the assistant message with text
589+
// Find the assistant message with text (reasoning should remain alongside text)
666590
const textMessage = result.find((msg) => {
667591
if (msg.role !== "assistant") return false;
668592
const content = msg.content;
@@ -672,9 +596,7 @@ describe("modelMessageTransform", () => {
672596
if (textMessage) {
673597
const content = (textMessage as AssistantModelMessage).content;
674598
if (Array.isArray(content)) {
675-
// Should not have reasoning parts
676-
expect(content.some((c) => c.type === "reasoning")).toBe(false);
677-
// Should have text
599+
expect(content.some((c) => c.type === "reasoning")).toBe(true);
678600
expect(content.some((c) => c.type === "text")).toBe(true);
679601
}
680602
}

0 commit comments

Comments
 (0)