From cb192d1bfe52f814b35764810152f911e0a06606 Mon Sep 17 00:00:00 2001 From: lellansin Date: Fri, 29 May 2026 15:06:29 +0800 Subject: [PATCH 1/2] refactor: extract OpenAI message converter from SessionManager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move buildOpenAIMessages, sessionMessageToOpenAIMessage, pairToolMessages, getTrailingPendingToolCallMessage, and related helpers (~240 lines) into a dedicated OpenAIMessageConverter class under src/common/. SessionManager now delegates to the converter while retaining a deprecated buildOpenAIMessages pass-through for test compatibility. Net reduction: 2884 → 2644 lines in session.ts --- src/common/openai-message-converter.ts | 278 +++++++++++++++++++++++ src/session.ts | 291 +++---------------------- 2 files changed, 309 insertions(+), 260 deletions(-) create mode 100644 src/common/openai-message-converter.ts diff --git a/src/common/openai-message-converter.ts b/src/common/openai-message-converter.ts new file mode 100644 index 0000000..9799904 --- /dev/null +++ b/src/common/openai-message-converter.ts @@ -0,0 +1,278 @@ +import type { ChatCompletionMessageParam, ChatCompletionContentPart } from "openai/resources/chat/completions"; +import { supportsMultimodal } from "./model-capabilities"; +import type { SessionMessage } from "../session"; + +export type OpenAIMessageConverterOptions = { + /** Optional callback to render the /init command prompt template. */ + renderInitPrompt?: () => string; +}; + +/** + * Converts internal SessionMessage arrays into OpenAI ChatCompletionMessageParam arrays. + * + * Handles: + * - Tool-call / tool-result pairing with interrupt backfill + * - Thinking-mode reasoning_content injection + * - Multimodal content (images) filtering by model capability + * - Compaction filtering + */ +export class OpenAIMessageConverter { + constructor(private readonly options: OpenAIMessageConverterOptions = {}) {} + + /** + * Build the OpenAI messages array from session messages, applying compaction + * filtering, tool pairing, and format conversion. + */ + buildMessages(messages: SessionMessage[], thinkingEnabled: boolean, model: string): ChatCompletionMessageParam[] { + const activeMessages = messages.filter((message) => !message.compacted); + const toolPairings = this.pairToolMessages(activeMessages); + const openAIMessages: ChatCompletionMessageParam[] = []; + + for (let index = 0; index < activeMessages.length; index += 1) { + const message = activeMessages[index]; + if (message.role === "tool") { + continue; + } + + openAIMessages.push(this.convertMessage(message, thinkingEnabled, model)); + + const toolCalls = this.getAssistantToolCalls(message); + if (toolCalls.length === 0) { + continue; + } + + for (let toolCallIndex = 0; toolCallIndex < toolCalls.length; toolCallIndex += 1) { + const toolCallId = this.getToolCallId(toolCalls[toolCallIndex]); + if (!toolCallId) { + continue; + } + + const pairedToolIndex = toolPairings.get(this.buildToolPairingKey(index, toolCallIndex)); + if (pairedToolIndex != null) { + openAIMessages.push(this.convertMessage(activeMessages[pairedToolIndex], thinkingEnabled, model)); + continue; + } + + openAIMessages.push(this.buildInterruptedOpenAIToolMessage(toolCalls, toolCallId)); + } + } + + return openAIMessages; + } + + /** + * Returns the trailing assistant message with pending (unexecuted) tool calls, + * if one exists at the end of the conversation. + */ + getTrailingPendingToolCallMessage( + messages: SessionMessage[] + ): { message: SessionMessage; toolCalls: unknown[] } | { message: null; toolCalls: [] } { + const activeMessages = messages.filter((message) => !message.compacted); + const latestMessage = activeMessages[activeMessages.length - 1]; + if (!latestMessage || latestMessage.role !== "assistant") { + return { message: null, toolCalls: [] }; + } + + const toolCalls = this.getAssistantToolCalls(latestMessage); + if (toolCalls.length === 0) { + return { message: null, toolCalls: [] }; + } + return { + message: latestMessage, + toolCalls: toolCalls.filter((toolCall) => Boolean(this.getToolCallId(toolCall))), + }; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private convertMessage(message: SessionMessage, thinkingEnabled: boolean, model: string): ChatCompletionMessageParam { + const content = this.renderContent(message); + const base: ChatCompletionMessageParam = { + role: message.role, + content, + } as ChatCompletionMessageParam; + + const messageParams = message.messageParams as + | { tool_calls?: unknown[]; tool_call_id?: string; reasoning_content?: string } + | null + | undefined; + if (messageParams?.tool_calls) { + (base as { tool_calls?: unknown[] }).tool_calls = messageParams.tool_calls; + } + if (messageParams?.tool_call_id) { + (base as { tool_call_id?: string }).tool_call_id = messageParams.tool_call_id; + } + if (typeof messageParams?.reasoning_content === "string") { + (base as { reasoning_content?: string }).reasoning_content = messageParams.reasoning_content; + } else if (thinkingEnabled && message.role === "assistant") { + // Thinking-mode providers require every replayed assistant message + // to include the reasoning_content field, even when it is empty. + (base as { reasoning_content?: string }).reasoning_content = ""; + } + + if ((message.role === "user" || message.role === "system") && message.contentParams) { + const contentParts: ChatCompletionContentPart[] = []; + if (content) { + contentParts.push({ type: "text", text: content }); + } + const params = Array.isArray(message.contentParams) ? message.contentParams : [message.contentParams]; + for (const param of params) { + const part = param as ChatCompletionContentPart; + if (part && (part.type !== "image_url" || supportsMultimodal(model))) { + contentParts.push(part); + } + } + const contentValue: string | ChatCompletionContentPart[] = contentParts.length > 0 ? contentParts : content; + (base as { content: string | ChatCompletionContentPart[] }).content = contentValue; + } + + return base; + } + + private renderContent(message: SessionMessage): string { + if (message.role === "user" && message.content === "/init") { + return this.options.renderInitPrompt?.() ?? ""; + } + return message.content ?? ""; + } + + private pairToolMessages(messages: SessionMessage[]): Map { + const pairings = new Map(); + const usedToolMessageIndexes = new Set(); + + for (let assistantIndex = 0; assistantIndex < messages.length; assistantIndex += 1) { + const toolCalls = this.getAssistantToolCalls(messages[assistantIndex]); + for (let toolCallIndex = 0; toolCallIndex < toolCalls.length; toolCallIndex += 1) { + const toolCallId = this.getToolCallId(toolCalls[toolCallIndex]); + if (!toolCallId) { + continue; + } + + const toolIndex = this.findPairableToolMessageIndex( + messages, + assistantIndex, + toolCallId, + usedToolMessageIndexes + ); + if (toolIndex == null) { + continue; + } + + usedToolMessageIndexes.add(toolIndex); + pairings.set(this.buildToolPairingKey(assistantIndex, toolCallIndex), toolIndex); + } + } + + return pairings; + } + + private findPairableToolMessageIndex( + messages: SessionMessage[], + assistantIndex: number, + toolCallId: string, + usedToolMessageIndexes: Set + ): number | null { + let firstMatchingIndex: number | null = null; + for (let index = assistantIndex + 1; index < messages.length; index += 1) { + const message = messages[index]; + if (message.role !== "tool" || usedToolMessageIndexes.has(index)) { + continue; + } + + const candidateToolCallId = this.getToolMessageCallId(message); + if (candidateToolCallId !== toolCallId) { + continue; + } + + if (firstMatchingIndex == null) { + firstMatchingIndex = index; + } + if (!this.isInterruptedToolMessage(message)) { + return index; + } + } + return firstMatchingIndex; + } + + private getAssistantToolCalls(message: SessionMessage): unknown[] { + if (message.role !== "assistant") { + return []; + } + const messageParams = message.messageParams as { tool_calls?: unknown[] } | null; + return Array.isArray(messageParams?.tool_calls) ? messageParams.tool_calls : []; + } + + private getToolCallId(toolCall: unknown): string | null { + if (!toolCall || typeof toolCall !== "object") { + return null; + } + const id = (toolCall as { id?: unknown }).id; + return typeof id === "string" && id ? id : null; + } + + private getToolMessageCallId(message: SessionMessage): string | null { + const messageParams = message.messageParams as { tool_call_id?: unknown } | null; + const toolCallId = messageParams?.tool_call_id; + return typeof toolCallId === "string" && toolCallId ? toolCallId : null; + } + + private buildToolPairingKey(assistantIndex: number, toolCallIndex: number): string { + return `${assistantIndex}:${toolCallIndex}`; + } + + private isInterruptedToolMessage(message: SessionMessage): boolean { + if (typeof message.content !== "string" || !message.content.trim()) { + return false; + } + try { + const parsed = JSON.parse(message.content) as { metadata?: { interrupted?: unknown } }; + return parsed.metadata?.interrupted === true; + } catch { + return false; + } + } + + private buildInterruptedOpenAIToolMessage(toolCalls: unknown[], toolCallId: string): ChatCompletionMessageParam { + const toolFunction = this.findToolFunction(toolCalls, toolCallId); + return { + role: "tool", + content: this.buildInterruptedToolResult(toolFunction, "Previous tool call did not complete."), + tool_call_id: toolCallId, + } as ChatCompletionMessageParam; + } + + /** Exposed for use by appendToolMessages in SessionManager. */ + findToolFunction(toolCalls: unknown[], toolCallId: string): unknown | null { + for (const toolCall of toolCalls) { + if (!toolCall || typeof toolCall !== "object") { + continue; + } + const record = toolCall as { id?: unknown; function?: unknown }; + if (record.id === toolCallId) { + return record.function ?? null; + } + } + return null; + } + + private buildInterruptedToolResult(toolFunction: unknown | null, reason: string): string { + const toolName = + toolFunction && typeof toolFunction === "object" && typeof (toolFunction as { name?: unknown }).name === "string" + ? (toolFunction as { name: string }).name + : "tool"; + return JSON.stringify( + { + ok: false, + name: toolName, + error: reason, + metadata: { + interrupted: true, + }, + }, + null, + 2 + ); + } +} diff --git a/src/session.ts b/src/session.ts index 358789e..343939b 100644 --- a/src/session.ts +++ b/src/session.ts @@ -4,10 +4,10 @@ import * as os from "os"; import * as crypto from "crypto"; import matter from "gray-matter"; import ejs from "ejs"; -import type { ChatCompletionMessageParam, ChatCompletionContentPart } from "openai/resources/chat/completions"; +import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; import { launchNotifyScript } from "./common/notify"; import { buildThinkingRequestOptions } from "./common/openai-thinking"; -import { DEEPSEEK_V4_MODELS, supportsMultimodal } from "./common/model-capabilities"; +import { DEEPSEEK_V4_MODELS } from "./common/model-capabilities"; import { getCompactPrompt, getDefaultSkillPrompt, @@ -46,6 +46,7 @@ import { } from "./common/permissions"; import { clearSessionWorkingDir } from "./tools/bash-handler"; import { reportNewPrompt } from "./common/telemetry"; +import { OpenAIMessageConverter } from "./common/openai-message-converter"; export type { PermissionScope } from "./settings"; export type { @@ -333,6 +334,7 @@ export class SessionManager { private readonly toolExecutor: ToolExecutor; private readonly mcpManager = new McpManager(); private mcpToolDefinitions: ToolDefinition[] = []; + private readonly messageConverter: OpenAIMessageConverter; constructor(options: SessionManagerOptions) { this.projectRoot = options.projectRoot; @@ -345,6 +347,21 @@ export class SessionManager { this.onProcessStdout = options.onProcessStdout; this.toolExecutor = new ToolExecutor(this.projectRoot, this.createOpenAIClient, this.mcpManager); this.mcpManager.prepare(this.getResolvedSettings().mcpServers); + this.messageConverter = new OpenAIMessageConverter({ + renderInitPrompt: () => this.renderInitCommandPrompt(), + }); + } + + /** + * @deprecated Use messageConverter.buildMessages directly. + * Kept for test compatibility. + */ + buildOpenAIMessages( + messages: SessionMessage[], + thinkingEnabled: boolean, + model: string + ): ChatCompletionMessageParam[] { + return this.messageConverter.buildMessages(messages, thinkingEnabled, model); } async initMcpServers(servers?: Record): Promise { @@ -1234,7 +1251,9 @@ ${skillMd} return; } - const pendingToolCallMessage = this.getTrailingPendingToolCallMessage(this.listSessionMessages(sessionId)); + const pendingToolCallMessage = this.messageConverter.getTrailingPendingToolCallMessage( + this.listSessionMessages(sessionId) + ); if (pendingToolCallMessage.toolCalls.length > 0) { const toolAppendResult = await this.appendToolMessages(sessionId, pendingToolCallMessage.toolCalls, { permissionOverrides: permissionPrompt?.permissions, @@ -1267,7 +1286,11 @@ ${skillMd} await this.compactSession(sessionId, sessionController.signal); } - const messages = this.buildOpenAIMessages(this.listSessionMessages(sessionId), thinkingEnabled, model); + const messages = this.messageConverter.buildMessages( + this.listSessionMessages(sessionId), + thinkingEnabled, + model + ); const thinkingOptions = buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort); const response = await this.createChatCompletionStream( client, @@ -2201,7 +2224,7 @@ ${skillMd} if (execution.result.awaitUserResponse === true) { waitingForUser = true; } - const toolFunction = this.findToolFunction(toolCalls, execution.toolCallId); + const toolFunction = this.messageConverter.findToolFunction(toolCalls, execution.toolCallId); const toolMessage = this.buildToolMessage(sessionId, execution.toolCallId, execution.content, toolFunction); this.appendSessionMessage(sessionId, toolMessage); this.onAssistantMessage(toolMessage, true); @@ -2233,7 +2256,9 @@ ${skillMd} } private hasTrailingPendingToolCalls(sessionId: string): boolean { - return this.getTrailingPendingToolCallMessage(this.listSessionMessages(sessionId)).toolCalls.length > 0; + return ( + this.messageConverter.getTrailingPendingToolCallMessage(this.listSessionMessages(sessionId)).toolCalls.length > 0 + ); } private async appendDeferredPermissionPrompt( @@ -2288,241 +2313,6 @@ ${skillMd} return undefined; } - private buildOpenAIMessages( - messages: SessionMessage[], - thinkingEnabled: boolean, - model: string - ): ChatCompletionMessageParam[] { - const activeMessages = messages.filter((message) => !message.compacted); - const toolPairings = this.pairToolMessages(activeMessages); - const openAIMessages: ChatCompletionMessageParam[] = []; - - for (let index = 0; index < activeMessages.length; index += 1) { - const message = activeMessages[index]; - if (message.role === "tool") { - continue; - } - - openAIMessages.push(this.sessionMessageToOpenAIMessage(message, thinkingEnabled, model)); - - const toolCalls = this.getAssistantToolCalls(message); - if (toolCalls.length === 0) { - continue; - } - - for (let toolCallIndex = 0; toolCallIndex < toolCalls.length; toolCallIndex += 1) { - const toolCallId = this.getToolCallId(toolCalls[toolCallIndex]); - if (!toolCallId) { - continue; - } - - const pairedToolIndex = toolPairings.get(this.buildToolPairingKey(index, toolCallIndex)); - if (pairedToolIndex != null) { - openAIMessages.push( - this.sessionMessageToOpenAIMessage(activeMessages[pairedToolIndex], thinkingEnabled, model) - ); - continue; - } - - openAIMessages.push(this.buildInterruptedOpenAIToolMessage(toolCalls, toolCallId)); - } - } - - return openAIMessages; - } - - private sessionMessageToOpenAIMessage( - message: SessionMessage, - thinkingEnabled: boolean, - model: string - ): ChatCompletionMessageParam { - const content = this.renderOpenAIMessageContent(message); - const base: ChatCompletionMessageParam = { - role: message.role, - content, - } as ChatCompletionMessageParam; - - const messageParams = message.messageParams as - | { tool_calls?: unknown[]; tool_call_id?: string; reasoning_content?: string } - | null - | undefined; - if (messageParams?.tool_calls) { - (base as { tool_calls?: unknown[] }).tool_calls = messageParams.tool_calls; - } - if (messageParams?.tool_call_id) { - (base as { tool_call_id?: string }).tool_call_id = messageParams.tool_call_id; - } - if (typeof messageParams?.reasoning_content === "string") { - (base as { reasoning_content?: string }).reasoning_content = messageParams.reasoning_content; - } else if (thinkingEnabled && message.role === "assistant") { - // Thinking-mode providers require every replayed assistant message - // to include the reasoning_content field, even when it is empty. - (base as { reasoning_content?: string }).reasoning_content = ""; - } - - if ((message.role === "user" || message.role === "system") && message.contentParams) { - const contentParts: ChatCompletionContentPart[] = []; - if (content) { - contentParts.push({ type: "text", text: content }); - } - const params = Array.isArray(message.contentParams) ? message.contentParams : [message.contentParams]; - for (const param of params) { - const part = param as ChatCompletionContentPart; - if (part && (part.type !== "image_url" || supportsMultimodal(model))) { - contentParts.push(part); - } - } - const contentValue: string | ChatCompletionContentPart[] = contentParts.length > 0 ? contentParts : content; - (base as { content: string | ChatCompletionContentPart[] }).content = contentValue; - } - - return base; - } - - private renderOpenAIMessageContent(message: SessionMessage): string { - if (message.role === "user" && message.content === "/init") { - return this.renderInitCommandPrompt(); - } - return message.content ?? ""; - } - - private pairToolMessages(messages: SessionMessage[]): Map { - const pairings = new Map(); - const usedToolMessageIndexes = new Set(); - - for (let assistantIndex = 0; assistantIndex < messages.length; assistantIndex += 1) { - const toolCalls = this.getAssistantToolCalls(messages[assistantIndex]); - for (let toolCallIndex = 0; toolCallIndex < toolCalls.length; toolCallIndex += 1) { - const toolCallId = this.getToolCallId(toolCalls[toolCallIndex]); - if (!toolCallId) { - continue; - } - - const toolIndex = this.findPairableToolMessageIndex( - messages, - assistantIndex, - toolCallId, - usedToolMessageIndexes - ); - if (toolIndex == null) { - continue; - } - - usedToolMessageIndexes.add(toolIndex); - pairings.set(this.buildToolPairingKey(assistantIndex, toolCallIndex), toolIndex); - } - } - - return pairings; - } - - private getTrailingPendingToolCallMessage( - messages: SessionMessage[] - ): { message: SessionMessage; toolCalls: unknown[] } | { message: null; toolCalls: [] } { - const activeMessages = messages.filter((message) => !message.compacted); - const latestMessage = activeMessages[activeMessages.length - 1]; - if (!latestMessage || latestMessage.role !== "assistant") { - return { message: null, toolCalls: [] }; - } - - const toolCalls = this.getAssistantToolCalls(latestMessage); - if (toolCalls.length === 0) { - return { message: null, toolCalls: [] }; - } - return { - message: latestMessage, - toolCalls: toolCalls.filter((toolCall) => Boolean(this.getToolCallId(toolCall))), - }; - } - - private findPairableToolMessageIndex( - messages: SessionMessage[], - assistantIndex: number, - toolCallId: string, - usedToolMessageIndexes: Set - ): number | null { - let firstMatchingIndex: number | null = null; - for (let index = assistantIndex + 1; index < messages.length; index += 1) { - const message = messages[index]; - if (message.role !== "tool" || usedToolMessageIndexes.has(index)) { - continue; - } - - const candidateToolCallId = this.getToolMessageCallId(message); - if (candidateToolCallId !== toolCallId) { - continue; - } - - if (firstMatchingIndex == null) { - firstMatchingIndex = index; - } - if (!this.isInterruptedToolMessage(message)) { - return index; - } - } - return firstMatchingIndex; - } - - private getAssistantToolCalls(message: SessionMessage): unknown[] { - if (message.role !== "assistant") { - return []; - } - const messageParams = message.messageParams as { tool_calls?: unknown[] } | null; - return Array.isArray(messageParams?.tool_calls) ? messageParams.tool_calls : []; - } - - private getToolCallId(toolCall: unknown): string | null { - if (!toolCall || typeof toolCall !== "object") { - return null; - } - const id = (toolCall as { id?: unknown }).id; - return typeof id === "string" && id ? id : null; - } - - private getToolMessageCallId(message: SessionMessage): string | null { - const messageParams = message.messageParams as { tool_call_id?: unknown } | null; - const toolCallId = messageParams?.tool_call_id; - return typeof toolCallId === "string" && toolCallId ? toolCallId : null; - } - - private buildToolPairingKey(assistantIndex: number, toolCallIndex: number): string { - return `${assistantIndex}:${toolCallIndex}`; - } - - private isInterruptedToolMessage(message: SessionMessage): boolean { - if (typeof message.content !== "string" || !message.content.trim()) { - return false; - } - try { - const parsed = JSON.parse(message.content) as { metadata?: { interrupted?: unknown } }; - return parsed.metadata?.interrupted === true; - } catch { - return false; - } - } - - private buildInterruptedOpenAIToolMessage(toolCalls: unknown[], toolCallId: string): ChatCompletionMessageParam { - const toolFunction = this.findToolFunction(toolCalls, toolCallId); - return { - role: "tool", - content: this.buildInterruptedToolResult(toolFunction, "Previous tool call did not complete."), - tool_call_id: toolCallId, - } as ChatCompletionMessageParam; - } - - private findToolFunction(toolCalls: unknown[], toolCallId: string): unknown | null { - for (const toolCall of toolCalls) { - if (!toolCall || typeof toolCall !== "object") { - continue; - } - const record = toolCall as { id?: unknown; function?: unknown }; - if (record.id === toolCallId) { - return record.function ?? null; - } - } - return null; - } - private buildToolParamsSnippet(toolFunction: unknown | null): string { if (!toolFunction || typeof toolFunction !== "object") { return ""; @@ -2756,25 +2546,6 @@ ${skillMd} return ids; } - private buildInterruptedToolResult(toolFunction: unknown | null, reason: string): string { - const toolName = - toolFunction && typeof toolFunction === "object" && typeof (toolFunction as { name?: unknown }).name === "string" - ? (toolFunction as { name: string }).name - : "tool"; - return JSON.stringify( - { - ok: false, - name: toolName, - error: reason, - metadata: { - interrupted: true, - }, - }, - null, - 2 - ); - } - private normalizeSessionEntry(entry: unknown): SessionEntry { const value = entry && typeof entry === "object" ? (entry as Record) : {}; return { From 90fd57ce43139c9f2651bd478886fe52f3d66735 Mon Sep 17 00:00:00 2001 From: lellansin Date: Sat, 30 May 2026 17:06:37 +0800 Subject: [PATCH 2/2] test: add unit tests for OpenAIMessageConverter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 20 tests covering buildMessages (content handling, tool-call pairing, interrupted backfill, compaction filtering), getTrailingPendingToolCallMessage, and findToolFunction. No dependency on SessionManager — pure data-in / data-out. --- src/tests/openai-message-converter.test.ts | 508 +++++++++++++++++++++ 1 file changed, 508 insertions(+) create mode 100644 src/tests/openai-message-converter.test.ts diff --git a/src/tests/openai-message-converter.test.ts b/src/tests/openai-message-converter.test.ts new file mode 100644 index 0000000..a54c213 --- /dev/null +++ b/src/tests/openai-message-converter.test.ts @@ -0,0 +1,508 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { OpenAIMessageConverter } from "../common/openai-message-converter"; +import type { SessionMessage } from "../session"; + +// --------------------------------------------------------------------------- +// Test helpers — build SessionMessage objects without needing SessionManager +// --------------------------------------------------------------------------- + +function msg(overrides: Partial & { role: SessionMessage["role"] }): SessionMessage { + const now = "2026-01-01T00:00:00.000Z"; + return { + id: overrides.id ?? "msg-1", + sessionId: overrides.sessionId ?? "session-1", + role: overrides.role, + content: overrides.content ?? null, + contentParams: overrides.contentParams ?? null, + messageParams: overrides.messageParams ?? null, + compacted: overrides.compacted ?? false, + visible: overrides.visible ?? true, + createTime: overrides.createTime ?? now, + updateTime: overrides.updateTime ?? now, + meta: overrides.meta, + }; +} + +function assistantMsg( + id: string, + toolCalls?: Array<{ id: string; type?: string; function: { name: string; arguments: string } }>, + reasoningContent?: string | null +): SessionMessage { + const hasTcs = toolCalls && toolCalls.length > 0; + const hasReasoning = reasoningContent !== undefined && reasoningContent !== null; + const messageParams: Record | null = hasTcs || hasReasoning ? {} : null; + if (hasTcs) (messageParams as Record).tool_calls = toolCalls; + if (hasReasoning) (messageParams as Record).reasoning_content = reasoningContent; + return msg({ + id, + role: "assistant", + content: "", + messageParams, + visible: false, + }); +} + +function toolMsg( + id: string, + toolCallId: string, + content: string, + toolFunction?: { name: string; arguments: string } +): SessionMessage { + return msg({ + id, + role: "tool", + content, + messageParams: { tool_call_id: toolCallId }, + meta: toolFunction ? { function: toolFunction } : undefined, + }); +} + +function userMsg(id: string, content: string): SessionMessage { + return msg({ id, role: "user", content }); +} + +// --------------------------------------------------------------------------- +// Converter fixtures +// --------------------------------------------------------------------------- + +function converter(opts?: { renderInitPrompt?: () => string }) { + return new OpenAIMessageConverter(opts); +} + +// --------------------------------------------------------------------------- +// buildMessages — content handling +// --------------------------------------------------------------------------- + +test("OpenAIMessageConverter preserves image content for multimodal models", () => { + const c = converter(); + const messages: SessionMessage[] = [ + msg({ + role: "system", + content: "Loaded pixel.png", + contentParams: [{ type: "image_url", image_url: { url: "data:image/png;base64,abc" } }], + }), + ]; + + const result = c.buildMessages(messages, false, "gpt-4o") as Array<{ role: string; content: unknown }>; + + assert.equal(result.length, 1); + assert.equal(result[0]?.role, "system"); + assert.deepEqual(result[0]?.content, [ + { type: "text", text: "Loaded pixel.png" }, + { type: "image_url", image_url: { url: "data:image/png;base64,abc" } }, + ]); +}); + +test("OpenAIMessageConverter filters image content for non-multimodal models", () => { + const c = converter(); + const messages: SessionMessage[] = [ + msg({ + role: "system", + content: "Loaded pixel.png", + contentParams: [{ type: "image_url", image_url: { url: "data:image/png;base64,abc" } }], + }), + ]; + + const result = c.buildMessages(messages, false, "deepseek-chat") as Array<{ role: string; content: unknown }>; + + assert.equal(result.length, 1); + assert.deepEqual(result[0]?.content, [{ type: "text", text: "Loaded pixel.png" }]); +}); + +test("OpenAIMessageConverter injects reasoning_content in thinking mode", () => { + const c = converter(); + const messages: SessionMessage[] = [msg({ role: "assistant", content: "Final answer", messageParams: null })]; + + const thinking = c.buildMessages(messages, true, "test-model") as Array<{ reasoning_content?: string }>; + const nonThinking = c.buildMessages(messages, false, "test-model") as Array<{ reasoning_content?: string }>; + + assert.equal(thinking[0]?.reasoning_content, ""); + assert.equal(Object.prototype.hasOwnProperty.call(nonThinking[0] ?? {}, "reasoning_content"), false); +}); + +test("OpenAIMessageConverter preserves existing reasoning_content from messageParams", () => { + const c = converter(); + const messages: SessionMessage[] = [ + msg({ + role: "assistant", + content: "answer", + messageParams: { reasoning_content: "deep thought" }, + }), + ]; + + const result = c.buildMessages(messages, false, "test-model") as Array<{ reasoning_content?: string }>; + + assert.equal(result[0]?.reasoning_content, "deep thought"); +}); + +test("OpenAIMessageConverter uses /init prompt via renderInitPrompt callback", () => { + const c = converter({ renderInitPrompt: () => "EXPANDED INIT PROMPT" }); + const messages: SessionMessage[] = [msg({ role: "user", content: "/init" })]; + + const result = c.buildMessages(messages, false, "test-model") as Array<{ content: string }>; + + assert.equal(result[0]?.content, "EXPANDED INIT PROMPT"); +}); + +test("OpenAIMessageConverter skips compacted messages", () => { + const c = converter(); + const messages: SessionMessage[] = [ + userMsg("u1", "hello"), + msg({ id: "a1", role: "assistant", content: "hi", compacted: true }), + userMsg("u2", "still here?"), + msg({ id: "a2", role: "assistant", content: "yes" }), + ]; + + const result = c.buildMessages(messages, false, "test-model") as Array<{ role: string }>; + + assert.deepEqual( + result.map((m) => m.role), + ["user", "user", "assistant"] + ); +}); + +// --------------------------------------------------------------------------- +// buildMessages — tool-call pairing +// --------------------------------------------------------------------------- + +test("OpenAIMessageConverter preserves a complete multi-tool happy path", () => { + const c = converter(); + const messages: SessionMessage[] = [ + assistantMsg("a1", [ + { id: "call-1", type: "function", function: { name: "read", arguments: '{"file_path":"/tmp/a.txt"}' } }, + { id: "call-2", type: "function", function: { name: "bash", arguments: '{"command":"pwd"}' } }, + ]), + toolMsg("t1", "call-1", JSON.stringify({ ok: true, name: "read", content: "file content" }), { + name: "read", + arguments: '{"file_path":"/tmp/a.txt"}', + }), + toolMsg("t2", "call-2", JSON.stringify({ ok: true, name: "bash", output: "/tmp\n" }), { + name: "bash", + arguments: '{"command":"pwd"}', + }), + userMsg("u1", "thanks"), + ]; + + const result = c.buildMessages(messages, false, "test-model") as Array<{ + role: string; + tool_call_id?: string; + content: string; + }>; + + assert.deepEqual( + result.map((m) => m.role), + ["assistant", "tool", "tool", "user"] + ); + assert.deepEqual( + result.filter((m) => m.role === "tool").map((m) => m.tool_call_id), + ["call-1", "call-2"] + ); + const hasInterrupted = result.some((m) => m.content.includes("Previous tool call did not complete")); + assert.equal(hasInterrupted, false); +}); + +test("OpenAIMessageConverter inserts interrupted backfill for missing tool messages", () => { + const c = converter(); + const messages: SessionMessage[] = [ + assistantMsg("a1", [ + { id: "call-1", type: "function", function: { name: "bash", arguments: '{"command":"sleep 100"}' } }, + ]), + userMsg("u1", "continue"), + ]; + + const result = c.buildMessages(messages, false, "test-model") as Array<{ + role: string; + content: string; + tool_call_id?: string; + }>; + + assert.equal(result.length, 3); + assert.equal(result[0]?.role, "assistant"); + assert.equal(result[1]?.role, "tool"); + assert.equal(result[1]?.tool_call_id, "call-1"); + assert.match(result[1]?.content ?? "", /Previous tool call did not complete/); + assert.equal(result[2]?.role, "user"); +}); + +test("OpenAIMessageConverter ignores orphan tool messages", () => { + const c = converter(); + const messages: SessionMessage[] = [ + userMsg("u1", "hello"), + toolMsg("t1", "call-orphan", JSON.stringify({ ok: true, name: "bash", output: "orphan" }), { + name: "bash", + arguments: '{"command":"echo orphan"}', + }), + ]; + + const result = c.buildMessages(messages, false, "test-model") as Array<{ role: string }>; + + assert.deepEqual( + result.map((m) => m.role), + ["user"] + ); +}); + +test("OpenAIMessageConverter prefers first non-interrupted tool result for a tool call", () => { + const c = converter(); + const messages: SessionMessage[] = [ + assistantMsg("a1", [ + { id: "call-1", type: "function", function: { name: "bash", arguments: '{"command":"date"}' } }, + ]), + toolMsg("t1", "call-1", JSON.stringify({ ok: true, name: "bash", output: "2026-05-07\n" }), { + name: "bash", + arguments: '{"command":"date"}', + }), + toolMsg( + "t2", + "call-1", + JSON.stringify({ + ok: false, + name: "bash", + error: "Previous tool call did not complete.", + metadata: { interrupted: true }, + }), + { name: "bash", arguments: '{"command":"date"}' } + ), + ]; + + const result = c.buildMessages(messages, false, "test-model") as Array<{ + role: string; + content: string; + tool_call_id?: string; + }>; + const toolResults = result.filter((m) => m.role === "tool"); + + assert.equal(toolResults.length, 1); + assert.equal(toolResults[0]?.tool_call_id, "call-1"); + assert.match(toolResults[0]?.content ?? "", /2026-05-07/); + assert.doesNotMatch(toolResults[0]?.content ?? "", /Previous tool call did not complete/); +}); + +test("OpenAIMessageConverter prefers later real result over earlier interrupted placeholder", () => { + const c = converter(); + const messages: SessionMessage[] = [ + assistantMsg("a1", [ + { id: "call-1", type: "function", function: { name: "bash", arguments: '{"command":"date"}' } }, + ]), + toolMsg( + "t1", + "call-1", + JSON.stringify({ + ok: false, + name: "bash", + error: "Previous tool call did not complete.", + metadata: { interrupted: true }, + }), + { name: "bash", arguments: '{"command":"date"}' } + ), + toolMsg("t2", "call-1", JSON.stringify({ ok: true, name: "bash", output: "real result" }), { + name: "bash", + arguments: '{"command":"date"}', + }), + ]; + + const result = c.buildMessages(messages, false, "test-model") as Array<{ + role: string; + content: string; + tool_call_id?: string; + }>; + const toolResults = result.filter((m) => m.role === "tool"); + + assert.equal(toolResults.length, 1); + assert.match(toolResults[0]?.content ?? "", /real result/); +}); + +test("OpenAIMessageConverter preserves a real failed tool result", () => { + const c = converter(); + const messages: SessionMessage[] = [ + assistantMsg("a1", [ + { id: "call-1", type: "function", function: { name: "bash", arguments: '{"command":"false"}' } }, + ]), + toolMsg( + "t1", + "call-1", + JSON.stringify({ ok: false, name: "bash", error: "Command failed", metadata: { exitCode: 1 } }), + { name: "bash", arguments: '{"command":"false"}' } + ), + ]; + + const result = c.buildMessages(messages, false, "test-model") as Array<{ + role: string; + content: string; + tool_call_id?: string; + }>; + + assert.deepEqual( + result.map((m) => m.role), + ["assistant", "tool"] + ); + assert.match(result[1]?.content ?? "", /Command failed/); + assert.doesNotMatch(result[1]?.content ?? "", /Previous tool call did not complete/); +}); + +test("OpenAIMessageConverter repairs mixed missing/duplicate/orphan tool messages", () => { + const c = converter(); + const messages: SessionMessage[] = [ + assistantMsg("a1", [ + { id: "call-1", type: "function", function: { name: "read", arguments: '{"file_path":"/tmp/missing.txt"}' } }, + { id: "call-2", type: "function", function: { name: "bash", arguments: '{"command":"pwd"}' } }, + ]), + toolMsg("t-orphan", "call-orphan", JSON.stringify({ ok: true, name: "bash", output: "orphan" }), { + name: "bash", + arguments: '{"command":"echo orphan"}', + }), + toolMsg("t1", "call-2", JSON.stringify({ ok: true, name: "bash", output: "/tmp\n" }), { + name: "bash", + arguments: '{"command":"pwd"}', + }), + toolMsg("t2", "call-2", JSON.stringify({ ok: true, name: "bash", output: "duplicate" }), { + name: "bash", + arguments: '{"command":"pwd"}', + }), + userMsg("u1", "continue"), + ]; + + const result = c.buildMessages(messages, false, "test-model") as Array<{ + role: string; + content: string; + tool_call_id?: string; + }>; + const toolResults = result.filter((m) => m.role === "tool"); + + assert.deepEqual( + result.map((m) => m.role), + ["assistant", "tool", "tool", "user"] + ); + assert.deepEqual( + toolResults.map((m) => m.tool_call_id), + ["call-1", "call-2"] + ); + assert.match(toolResults[0]?.content ?? "", /Previous tool call did not complete/); + assert.match(toolResults[1]?.content ?? "", /\/tmp/); + assert.equal( + result.some((m) => m.content.includes("orphan")), + false + ); + assert.equal( + result.some((m) => m.content.includes("duplicate")), + false + ); +}); + +test("OpenAIMessageConverter ignores tool messages before their assistant", () => { + const c = converter(); + const messages: SessionMessage[] = [ + toolMsg("t1", "call-1", JSON.stringify({ ok: true, name: "bash", output: "too early" }), { + name: "bash", + arguments: '{"command":"date"}', + }), + assistantMsg("a1", [ + { id: "call-1", type: "function", function: { name: "bash", arguments: '{"command":"date"}' } }, + ]), + ]; + + const result = c.buildMessages(messages, false, "test-model") as Array<{ + role: string; + content: string; + tool_call_id?: string; + }>; + + assert.deepEqual( + result.map((m) => m.role), + ["assistant", "tool"] + ); + assert.match(result[1]?.content ?? "", /Previous tool call did not complete/); + assert.doesNotMatch(result[1]?.content ?? "", /too early/); +}); + +// --------------------------------------------------------------------------- +// getTrailingPendingToolCallMessage +// --------------------------------------------------------------------------- + +test("OpenAIMessageConverter.getTrailingPendingToolCallMessage finds pending tools", () => { + const c = converter(); + const messages: SessionMessage[] = [ + userMsg("u1", "hello"), + assistantMsg("a1", [ + { id: "call-1", type: "function", function: { name: "bash", arguments: '{"command":"date"}' } }, + ]), + ]; + + const result = c.getTrailingPendingToolCallMessage(messages); + + assert.notEqual(result.message, null); + assert.deepEqual( + result.toolCalls.map((tc) => (tc as { id: string }).id), + ["call-1"] + ); +}); + +test("OpenAIMessageConverter.getTrailingPendingToolCallMessage returns empty when latest is user", () => { + const c = converter(); + const messages: SessionMessage[] = [userMsg("u1", "hello")]; + + const result = c.getTrailingPendingToolCallMessage(messages); + + assert.equal(result.message, null); + assert.deepEqual(result.toolCalls, []); +}); + +test("OpenAIMessageConverter.getTrailingPendingToolCallMessage returns empty when no tool calls", () => { + const c = converter(); + const messages: SessionMessage[] = [msg({ id: "a1", role: "assistant", content: "done" })]; + + const result = c.getTrailingPendingToolCallMessage(messages); + + assert.equal(result.message, null); + assert.deepEqual(result.toolCalls, []); +}); + +test("OpenAIMessageConverter.getTrailingPendingToolCallMessage skips compacted messages", () => { + const c = converter(); + const messages: SessionMessage[] = [ + userMsg("u1", "hello"), + msg({ + id: "a1", + role: "assistant", + content: "", + messageParams: { + tool_calls: [{ id: "call-1", type: "function", function: { name: "bash", arguments: "{}" } }], + }, + compacted: true, + }), + msg({ id: "a2", role: "assistant", content: "done" }), + ]; + + const result = c.getTrailingPendingToolCallMessage(messages); + + assert.equal(result.message, null); + assert.deepEqual(result.toolCalls, []); +}); + +// --------------------------------------------------------------------------- +// findToolFunction +// --------------------------------------------------------------------------- + +test("OpenAIMessageConverter.findToolFunction finds matching tool function", () => { + const c = converter(); + const toolCalls = [ + { id: "call-1", type: "function", function: { name: "read", arguments: '{"file_path":"/tmp/a.txt"}' } }, + { id: "call-2", type: "function", function: { name: "bash", arguments: '{"command":"pwd"}' } }, + ]; + + const found = c.findToolFunction(toolCalls, "call-1") as { name: string }; + assert.equal(found?.name, "read"); + + const notFound = c.findToolFunction(toolCalls, "call-3"); + assert.equal(notFound, null); +}); + +test("OpenAIMessageConverter.findToolFunction handles null/empty toolCalls", () => { + const c = converter(); + + assert.equal(c.findToolFunction([], "call-1"), null); + + const toolCalls = [null, undefined, { noId: true }]; + assert.equal(c.findToolFunction(toolCalls as unknown[], "call-1"), null); +});