Skip to content

Commit b85def1

Browse files
committed
fix(alpha): forward native tool-call history
Alpha follow-up turns now send assistant tool-call parts and role:tool results instead of flattening them into user text, so non-streaming tool loops can complete after the first call.
1 parent ccc372e commit b85def1

4 files changed

Lines changed: 159 additions & 53 deletions

File tree

src/converter.ts

Lines changed: 32 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import { randomUUID } from "node:crypto";
22
import { cwd as processCwd } from "node:process";
33

44
import type {
5+
CommandCodeContentPart,
56
CommandCodeGenerateBody,
67
CommandCodeMessage,
78
CommandCodeTool,
9+
CommandCodeToolResultPart,
810
OpenAIChatCompletionRequest,
911
OpenAIChatMessage,
1012
OpenAIChatTool,
@@ -51,36 +53,30 @@ function asTextContent(text: string): OpenAITextContentPart[] {
5153
return [{ type: "text", text }];
5254
}
5355

54-
function formatToolCallArguments(value: string): string {
55-
if (!value) return "{}";
56+
function parseToolInput(value: string): unknown {
57+
if (!value.trim()) return {};
5658
try {
57-
return JSON.stringify(JSON.parse(value));
59+
return JSON.parse(value) as unknown;
5860
} catch {
59-
return value;
61+
return { value };
6062
}
6163
}
6264

6365
interface ToolCallTrace {
6466
name: string;
65-
arguments: string;
6667
}
6768

68-
function priorToolResultText(
69+
function toolResultPart(
6970
message: OpenAIChatMessage,
7071
toolCallsById: Map<string, ToolCallTrace>,
71-
): string {
72+
): CommandCodeToolResultPart {
7273
const trace = message.tool_call_id ? toolCallsById.get(message.tool_call_id) : undefined;
73-
const functionName = message.name ?? trace?.name;
74-
const content = flattenOpenAIContent(message.content);
75-
return [
76-
"Prior function execution context:",
77-
functionName ? `function: ${functionName}` : undefined,
78-
trace?.arguments ? `arguments: ${trace.arguments}` : undefined,
79-
"result:",
80-
content,
81-
]
82-
.filter((part): part is string => part !== undefined)
83-
.join("\n");
74+
return {
75+
type: "tool-result",
76+
toolCallId: message.tool_call_id ?? "call_unknown",
77+
toolName: message.name ?? trace?.name ?? "unknown_tool",
78+
output: { type: "text", value: flattenOpenAIContent(message.content) },
79+
};
8480
}
8581

8682
export function isSupportedToolChoice(toolChoice: unknown): boolean {
@@ -108,29 +104,36 @@ function convertMessages(messages: OpenAIChatMessage[]): CommandCodeMessage[] {
108104
if (message.role === "developer" || message.role === "system") continue;
109105

110106
if (message.role === "assistant") {
107+
const content: CommandCodeContentPart[] = [];
108+
const text = flattenOpenAIContent(message.content).trim();
109+
if (text.length > 0) content.push(...asTextContent(text));
110+
111111
const toolCalls = message.tool_calls ?? [];
112112
for (let index = 0; index < toolCalls.length; index += 1) {
113113
const toolCall = toolCalls[index];
114114
if (!toolCall) continue;
115115
const id = toolCall.id ?? `call_${index}`;
116-
toolCallsById.set(id, {
117-
name: toolCall.function.name,
118-
arguments: formatToolCallArguments(toolCall.function.arguments),
116+
toolCallsById.set(id, { name: toolCall.function.name });
117+
content.push({
118+
type: "tool-call",
119+
toolCallId: id,
120+
toolName: toolCall.function.name,
121+
input: parseToolInput(toolCall.function.arguments),
119122
});
120123
}
121124

122-
const content = flattenOpenAIContent(message.content).trim();
123-
if (content.length > 0) {
124-
converted.push({ role: "assistant", content: asTextContent(content) });
125-
}
125+
if (content.length > 0) converted.push({ role: "assistant", content });
126126
continue;
127127
}
128128

129129
if (message.role === "tool") {
130-
converted.push({
131-
role: "user",
132-
content: asTextContent(priorToolResultText(message, toolCallsById)),
133-
});
130+
const part = toolResultPart(message, toolCallsById);
131+
const previous = converted[converted.length - 1];
132+
if (previous?.role === "tool") {
133+
previous.content.push(part);
134+
} else {
135+
converted.push({ role: "tool", content: [part] });
136+
}
134137
continue;
135138
}
136139

@@ -164,16 +167,6 @@ function buildSystemPrompt(request: OpenAIChatCompletionRequest): string {
164167
.filter((message) => message.role === "developer" || message.role === "system")
165168
.map((message) => flattenOpenAIContent(message.content))
166169
.filter(Boolean);
167-
const hasPriorToolHistory = request.messages.some(
168-
(message) => message.role === "tool" || (message.tool_calls?.length ?? 0) > 0,
169-
);
170-
if (hasPriorToolHistory) {
171-
systemMessages.push(
172-
"Prior function execution context in the conversation is internal bridge context. " +
173-
"Use function results as evidence when answering, but do not quote, expose, or mention " +
174-
"bridge transcript labels, function call IDs, or internal tool-history formatting.",
175-
);
176-
}
177170
const formatInstruction = responseFormatInstruction(request.response_format);
178171
if (formatInstruction) systemMessages.push(formatInstruction);
179172
return systemMessages.join("\n\n");

src/types.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,28 @@ export interface CommandCodeTool {
8080
input_schema: Record<string, unknown>;
8181
}
8282

83+
export interface CommandCodeToolCallPart {
84+
type: "tool-call";
85+
toolCallId: string;
86+
toolName: string;
87+
input: unknown;
88+
}
89+
90+
export interface CommandCodeToolResultPart {
91+
type: "tool-result";
92+
toolCallId: string;
93+
toolName: string;
94+
output: { type: "text" | "error-text"; value: string };
95+
}
96+
97+
export type CommandCodeContentPart =
98+
| OpenAITextContentPart
99+
| CommandCodeToolCallPart
100+
| CommandCodeToolResultPart;
101+
83102
export interface CommandCodeMessage {
84-
role: "user" | "assistant";
85-
content: OpenAITextContentPart[];
103+
role: "user" | "assistant" | "tool";
104+
content: CommandCodeContentPart[];
86105
}
87106

88107
export interface CommandCodeGenerateBody {

tests/converter.test.ts

Lines changed: 99 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -154,21 +154,112 @@ describe("OpenAI to CommandCode conversion", () => {
154154
});
155155

156156
expect(body.params.tools).toHaveLength(1);
157-
expect(body.params.messages.map((message) => message.role)).toEqual(["user", "user", "user"]);
157+
expect(body.params.messages.map((message) => message.role)).toEqual([
158+
"user",
159+
"assistant",
160+
"tool",
161+
"user",
162+
]);
163+
expect(body.params.messages[1]).toEqual({
164+
role: "assistant",
165+
content: [
166+
{
167+
type: "tool-call",
168+
toolCallId: "call_weather",
169+
toolName: "get_weather",
170+
input: { city: "Seoul" },
171+
},
172+
],
173+
});
174+
expect(body.params.messages[2]).toEqual({
175+
role: "tool",
176+
content: [
177+
{
178+
type: "tool-result",
179+
toolCallId: "call_weather",
180+
toolName: "get_weather",
181+
output: { type: "text", value: '{"temperature":"12C"}' },
182+
},
183+
],
184+
});
158185

159186
const serializedMessages = JSON.stringify(body.params.messages);
160187
expect(serializedMessages).not.toContain("Assistant requested tool calls");
161188
expect(serializedMessages).not.toContain("Tool result for");
162-
expect(serializedMessages).not.toContain('"role":"tool"');
163189
expect(serializedMessages).not.toContain("tool_calls");
164190
expect(serializedMessages).not.toContain("tool_call_id");
165-
expect(serializedMessages).not.toContain("call_weather");
191+
expect(serializedMessages).not.toContain("Prior function execution context");
192+
expect(body.params.system).not.toMatch(/internal bridge context/i);
193+
});
166194

167-
expect(serializedMessages).toContain("get_weather");
168-
expect(serializedMessages).toContain("Seoul");
169-
expect(serializedMessages).toContain("12C");
170-
expect(body.params.system).toMatch(/internal bridge context/i);
171-
expect(body.params.system).toMatch(/do not quote|do not expose|do not mention/i);
195+
it("merges consecutive tool results into one native tool message", () => {
196+
const body = buildCommandCodeGenerateBody({
197+
request: {
198+
model: "deepseek/deepseek-v4-pro",
199+
messages: [
200+
{ role: "user", content: "Need both." },
201+
{
202+
role: "assistant",
203+
content: "Checking.",
204+
tool_calls: [
205+
{
206+
id: "call_weather",
207+
type: "function",
208+
function: { name: "get_weather", arguments: '{"city":"Seoul"}' },
209+
},
210+
{
211+
id: "call_time",
212+
type: "function",
213+
function: { name: "get_time", arguments: '{"city":"Seoul"}' },
214+
},
215+
],
216+
},
217+
{ role: "tool", tool_call_id: "call_weather", content: "12C" },
218+
{ role: "tool", tool_call_id: "call_time", content: "09:00" },
219+
],
220+
},
221+
upstreamModel: "deepseek/deepseek-v4-pro",
222+
now: () => new Date("2026-05-11T00:00:00Z"),
223+
cwd: () => "/tmp/project",
224+
environment: "linux-x64, Node.js test",
225+
threadId: "00000000-0000-4000-8000-000000000000",
226+
});
227+
228+
expect(body.params.messages.map((message) => message.role)).toEqual([
229+
"user",
230+
"assistant",
231+
"tool",
232+
]);
233+
expect(body.params.messages[1]?.content).toEqual([
234+
{ type: "text", text: "Checking." },
235+
{
236+
type: "tool-call",
237+
toolCallId: "call_weather",
238+
toolName: "get_weather",
239+
input: { city: "Seoul" },
240+
},
241+
{
242+
type: "tool-call",
243+
toolCallId: "call_time",
244+
toolName: "get_time",
245+
input: { city: "Seoul" },
246+
},
247+
]);
248+
expect(body.params.messages[2]?.content).toHaveLength(2);
249+
expect(body.params.messages[2]?.content).toEqual([
250+
{
251+
type: "tool-result",
252+
toolCallId: "call_weather",
253+
toolName: "get_weather",
254+
output: { type: "text", value: "12C" },
255+
},
256+
{
257+
type: "tool-result",
258+
toolCallId: "call_time",
259+
toolName: "get_time",
260+
output: { type: "text", value: "09:00" },
261+
},
262+
]);
172263
});
173264

174265
it("injects JSON-only guidance for OpenAI response_format", () => {

tests/server.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -426,20 +426,23 @@ describe("Fastify OpenAI-compatible server", () => {
426426
expect(fake.seenBodies[0]?.params.tools).toHaveLength(1);
427427
expect(fake.seenBodies[0]?.params.messages.map((message) => message.role)).toEqual([
428428
"user",
429-
"user",
429+
"assistant",
430+
"tool",
430431
"user",
431432
]);
432433
const serializedMessages = JSON.stringify(fake.seenBodies[0]?.params.messages);
433434
expect(serializedMessages).not.toContain("Assistant requested tool calls");
434435
expect(serializedMessages).not.toContain("Tool result for");
435-
expect(serializedMessages).not.toContain('"role":"tool"');
436436
expect(serializedMessages).not.toContain("tool_calls");
437437
expect(serializedMessages).not.toContain("tool_call_id");
438-
expect(serializedMessages).not.toContain("call_weather");
438+
expect(serializedMessages).toContain('"role":"tool"');
439+
expect(serializedMessages).toContain("call_weather");
440+
expect(serializedMessages).toContain("tool-call");
441+
expect(serializedMessages).toContain("tool-result");
439442
expect(serializedMessages).toContain("get_weather");
440443
expect(serializedMessages).toContain("Seoul");
441444
expect(serializedMessages).toContain("12C");
442-
expect(fake.seenBodies[0]?.params.system).toMatch(/internal bridge context/i);
445+
expect(fake.seenBodies[0]?.params.system).not.toMatch(/internal bridge context/i);
443446
await app.close();
444447
});
445448

0 commit comments

Comments
 (0)