Skip to content

Commit cdee210

Browse files
authored
Merge pull request #140 from Lellansin/refactor/openai-message-converter
refactor: extract OpenAI message converter from SessionManager
2 parents bdfccca + 9044b6c commit cdee210

3 files changed

Lines changed: 817 additions & 260 deletions

File tree

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import type { ChatCompletionMessageParam, ChatCompletionContentPart } from "openai/resources/chat/completions";
2+
import { supportsMultimodal } from "./model-capabilities";
3+
import type { SessionMessage } from "../session";
4+
5+
export type OpenAIMessageConverterOptions = {
6+
/** Optional callback to render the /init command prompt template. */
7+
renderInitPrompt?: () => string;
8+
};
9+
10+
/**
11+
* Converts internal SessionMessage arrays into OpenAI ChatCompletionMessageParam arrays.
12+
*
13+
* Handles:
14+
* - Tool-call / tool-result pairing with interrupt backfill
15+
* - Thinking-mode reasoning_content injection
16+
* - Multimodal content (images) filtering by model capability
17+
* - Compaction filtering
18+
*/
19+
export class OpenAIMessageConverter {
20+
constructor(private readonly options: OpenAIMessageConverterOptions = {}) {}
21+
22+
/**
23+
* Build the OpenAI messages array from session messages, applying compaction
24+
* filtering, tool pairing, and format conversion.
25+
*/
26+
buildMessages(messages: SessionMessage[], thinkingEnabled: boolean, model: string): ChatCompletionMessageParam[] {
27+
const activeMessages = messages.filter((message) => !message.compacted);
28+
const toolPairings = this.pairToolMessages(activeMessages);
29+
const openAIMessages: ChatCompletionMessageParam[] = [];
30+
31+
for (let index = 0; index < activeMessages.length; index += 1) {
32+
const message = activeMessages[index];
33+
if (message.role === "tool") {
34+
continue;
35+
}
36+
37+
openAIMessages.push(this.convertMessage(message, thinkingEnabled, model));
38+
39+
const toolCalls = this.getAssistantToolCalls(message);
40+
if (toolCalls.length === 0) {
41+
continue;
42+
}
43+
44+
for (let toolCallIndex = 0; toolCallIndex < toolCalls.length; toolCallIndex += 1) {
45+
const toolCallId = this.getToolCallId(toolCalls[toolCallIndex]);
46+
if (!toolCallId) {
47+
continue;
48+
}
49+
50+
const pairedToolIndex = toolPairings.get(this.buildToolPairingKey(index, toolCallIndex));
51+
if (pairedToolIndex != null) {
52+
openAIMessages.push(this.convertMessage(activeMessages[pairedToolIndex], thinkingEnabled, model));
53+
continue;
54+
}
55+
56+
openAIMessages.push(this.buildInterruptedOpenAIToolMessage(toolCalls, toolCallId));
57+
}
58+
}
59+
60+
return openAIMessages;
61+
}
62+
63+
/**
64+
* Returns the trailing assistant message with pending (unexecuted) tool calls,
65+
* if one exists at the end of the conversation.
66+
*/
67+
getTrailingPendingToolCallMessage(
68+
messages: SessionMessage[]
69+
): { message: SessionMessage; toolCalls: unknown[] } | { message: null; toolCalls: [] } {
70+
const activeMessages = messages.filter((message) => !message.compacted);
71+
const latestMessage = activeMessages[activeMessages.length - 1];
72+
if (!latestMessage || latestMessage.role !== "assistant") {
73+
return { message: null, toolCalls: [] };
74+
}
75+
76+
const toolCalls = this.getAssistantToolCalls(latestMessage);
77+
if (toolCalls.length === 0) {
78+
return { message: null, toolCalls: [] };
79+
}
80+
return {
81+
message: latestMessage,
82+
toolCalls: toolCalls.filter((toolCall) => Boolean(this.getToolCallId(toolCall))),
83+
};
84+
}
85+
86+
// ---------------------------------------------------------------------------
87+
// Private helpers
88+
// ---------------------------------------------------------------------------
89+
90+
private convertMessage(message: SessionMessage, thinkingEnabled: boolean, model: string): ChatCompletionMessageParam {
91+
const content = this.renderContent(message);
92+
const base: ChatCompletionMessageParam = {
93+
role: message.role,
94+
content,
95+
} as ChatCompletionMessageParam;
96+
97+
const messageParams = message.messageParams as
98+
| { tool_calls?: unknown[]; tool_call_id?: string; reasoning_content?: string }
99+
| null
100+
| undefined;
101+
if (messageParams?.tool_calls) {
102+
(base as { tool_calls?: unknown[] }).tool_calls = messageParams.tool_calls;
103+
}
104+
if (messageParams?.tool_call_id) {
105+
(base as { tool_call_id?: string }).tool_call_id = messageParams.tool_call_id;
106+
}
107+
if (typeof messageParams?.reasoning_content === "string") {
108+
(base as { reasoning_content?: string }).reasoning_content = messageParams.reasoning_content;
109+
} else if (thinkingEnabled && message.role === "assistant") {
110+
// Thinking-mode providers require every replayed assistant message
111+
// to include the reasoning_content field, even when it is empty.
112+
(base as { reasoning_content?: string }).reasoning_content = "";
113+
}
114+
115+
if ((message.role === "user" || message.role === "system") && message.contentParams) {
116+
const contentParts: ChatCompletionContentPart[] = [];
117+
if (content) {
118+
contentParts.push({ type: "text", text: content });
119+
}
120+
const params = Array.isArray(message.contentParams) ? message.contentParams : [message.contentParams];
121+
for (const param of params) {
122+
const part = param as ChatCompletionContentPart;
123+
if (part && (part.type !== "image_url" || supportsMultimodal(model))) {
124+
contentParts.push(part);
125+
}
126+
}
127+
const contentValue: string | ChatCompletionContentPart[] = contentParts.length > 0 ? contentParts : content;
128+
(base as { content: string | ChatCompletionContentPart[] }).content = contentValue;
129+
}
130+
131+
return base;
132+
}
133+
134+
private renderContent(message: SessionMessage): string {
135+
if (message.role === "user" && message.content === "/init") {
136+
return this.options.renderInitPrompt?.() ?? "";
137+
}
138+
return message.content ?? "";
139+
}
140+
141+
private pairToolMessages(messages: SessionMessage[]): Map<string, number> {
142+
const pairings = new Map<string, number>();
143+
const usedToolMessageIndexes = new Set<number>();
144+
145+
for (let assistantIndex = 0; assistantIndex < messages.length; assistantIndex += 1) {
146+
const toolCalls = this.getAssistantToolCalls(messages[assistantIndex]);
147+
for (let toolCallIndex = 0; toolCallIndex < toolCalls.length; toolCallIndex += 1) {
148+
const toolCallId = this.getToolCallId(toolCalls[toolCallIndex]);
149+
if (!toolCallId) {
150+
continue;
151+
}
152+
153+
const toolIndex = this.findPairableToolMessageIndex(
154+
messages,
155+
assistantIndex,
156+
toolCallId,
157+
usedToolMessageIndexes
158+
);
159+
if (toolIndex == null) {
160+
continue;
161+
}
162+
163+
usedToolMessageIndexes.add(toolIndex);
164+
pairings.set(this.buildToolPairingKey(assistantIndex, toolCallIndex), toolIndex);
165+
}
166+
}
167+
168+
return pairings;
169+
}
170+
171+
private findPairableToolMessageIndex(
172+
messages: SessionMessage[],
173+
assistantIndex: number,
174+
toolCallId: string,
175+
usedToolMessageIndexes: Set<number>
176+
): number | null {
177+
let firstMatchingIndex: number | null = null;
178+
for (let index = assistantIndex + 1; index < messages.length; index += 1) {
179+
const message = messages[index];
180+
if (message.role !== "tool" || usedToolMessageIndexes.has(index)) {
181+
continue;
182+
}
183+
184+
const candidateToolCallId = this.getToolMessageCallId(message);
185+
if (candidateToolCallId !== toolCallId) {
186+
continue;
187+
}
188+
189+
if (firstMatchingIndex == null) {
190+
firstMatchingIndex = index;
191+
}
192+
if (!this.isInterruptedToolMessage(message)) {
193+
return index;
194+
}
195+
}
196+
return firstMatchingIndex;
197+
}
198+
199+
private getAssistantToolCalls(message: SessionMessage): unknown[] {
200+
if (message.role !== "assistant") {
201+
return [];
202+
}
203+
const messageParams = message.messageParams as { tool_calls?: unknown[] } | null;
204+
return Array.isArray(messageParams?.tool_calls) ? messageParams.tool_calls : [];
205+
}
206+
207+
private getToolCallId(toolCall: unknown): string | null {
208+
if (!toolCall || typeof toolCall !== "object") {
209+
return null;
210+
}
211+
const id = (toolCall as { id?: unknown }).id;
212+
return typeof id === "string" && id ? id : null;
213+
}
214+
215+
private getToolMessageCallId(message: SessionMessage): string | null {
216+
const messageParams = message.messageParams as { tool_call_id?: unknown } | null;
217+
const toolCallId = messageParams?.tool_call_id;
218+
return typeof toolCallId === "string" && toolCallId ? toolCallId : null;
219+
}
220+
221+
private buildToolPairingKey(assistantIndex: number, toolCallIndex: number): string {
222+
return `${assistantIndex}:${toolCallIndex}`;
223+
}
224+
225+
private isInterruptedToolMessage(message: SessionMessage): boolean {
226+
if (typeof message.content !== "string" || !message.content.trim()) {
227+
return false;
228+
}
229+
try {
230+
const parsed = JSON.parse(message.content) as { metadata?: { interrupted?: unknown } };
231+
return parsed.metadata?.interrupted === true;
232+
} catch {
233+
return false;
234+
}
235+
}
236+
237+
private buildInterruptedOpenAIToolMessage(toolCalls: unknown[], toolCallId: string): ChatCompletionMessageParam {
238+
const toolFunction = this.findToolFunction(toolCalls, toolCallId);
239+
return {
240+
role: "tool",
241+
content: this.buildInterruptedToolResult(toolFunction, "Previous tool call did not complete."),
242+
tool_call_id: toolCallId,
243+
} as ChatCompletionMessageParam;
244+
}
245+
246+
/** Exposed for use by appendToolMessages in SessionManager. */
247+
findToolFunction(toolCalls: unknown[], toolCallId: string): unknown | null {
248+
for (const toolCall of toolCalls) {
249+
if (!toolCall || typeof toolCall !== "object") {
250+
continue;
251+
}
252+
const record = toolCall as { id?: unknown; function?: unknown };
253+
if (record.id === toolCallId) {
254+
return record.function ?? null;
255+
}
256+
}
257+
return null;
258+
}
259+
260+
private buildInterruptedToolResult(toolFunction: unknown | null, reason: string): string {
261+
const toolName =
262+
toolFunction && typeof toolFunction === "object" && typeof (toolFunction as { name?: unknown }).name === "string"
263+
? (toolFunction as { name: string }).name
264+
: "tool";
265+
return JSON.stringify(
266+
{
267+
ok: false,
268+
name: toolName,
269+
error: reason,
270+
metadata: {
271+
interrupted: true,
272+
},
273+
},
274+
null,
275+
2
276+
);
277+
}
278+
}

0 commit comments

Comments
 (0)