From aca68024610fbc9432593babd70e3d52b291f031 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:57:12 -0500 Subject: [PATCH] fix(agent): emit function_call items from fromChatMessages and return Item[] (#11, #41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assistant branch of `fromChatMessages` never read `msg.toolCalls`, so a tool-calling assistant message (conventionally `content: null`) converted to `{ role: 'assistant', content: '' }` — the tool call vanished and the following `function_call_output` items were left orphaned, breaking agentic loops replayed through the helper. The `.map()` callback's return type also structurally prevented emitting function_call items at all. Converted the conversion to an accumulator loop that emits one `function_call` item per `toolCalls` entry, mirroring `fromClaudeMessages`. `arguments` is already a JSON string in the chat format, so it is forwarded as-is rather than re-stringified. The message item is skipped only when content is empty *and* tool calls take its place, keeping the pre-existing empty-message behavior for content-less assistant messages with no tool calls. Separately, both `fromChatMessages` and `fromClaudeMessages` declared `models.InputsUnion`, which is not assignable to callModel's `FieldOrAsyncFunction | string` input — so the documented `callModel({ input: fromChatMessages(msgs) })` usage did not typecheck. Both now return `Item[]`, and the `Item` union gains `NewAssistantMessageItem` and `NewSystemMessageItem` since it had no id-less member for either role. Role construction moved to per-role narrowed helpers: TypeScript will not distribute a union-typed `role` across the per-role members of `Item`, so mapping to the wide `EasyInputMessageRoleUnion` cannot produce an assignable value. Co-Authored-By: Claude Fable 5 --- .changeset/from-chat-messages-tool-calls.md | 9 + packages/agent/src/index.ts | 2 + packages/agent/src/lib/anthropic-compat.ts | 37 ++-- packages/agent/src/lib/chat-compat.test.ts | 228 ++++++++++++++++++++ packages/agent/src/lib/chat-compat.ts | 88 ++++++-- packages/agent/src/lib/item-types.ts | 19 ++ 6 files changed, 342 insertions(+), 41 deletions(-) create mode 100644 .changeset/from-chat-messages-tool-calls.md diff --git a/.changeset/from-chat-messages-tool-calls.md b/.changeset/from-chat-messages-tool-calls.md new file mode 100644 index 00000000..603874da --- /dev/null +++ b/.changeset/from-chat-messages-tool-calls.md @@ -0,0 +1,9 @@ +--- +'@openrouter/agent': patch +--- + +Fix `fromChatMessages` dropping assistant tool calls, and make the message-conversion helpers' return type usable as `callModel` input. + +Runtime (#11): the assistant branch of `fromChatMessages` only read `msg.content` and never `msg.toolCalls`, so a tool-calling assistant message — which conventionally carries `content: null` — converted to `{ role: 'assistant', content: '' }` and the tool call vanished. Any agentic loop replayed through this helper lost its tool calls and left the following `function_call_output` items orphaned. The conversion is now an accumulator loop that emits one `function_call` item per entry in `toolCalls` (mirroring `fromClaudeMessages`), so a single chat message can fan out to multiple items. `ChatToolCall.function.arguments` is already a JSON string in the chat format and is forwarded as-is — not re-stringified. The message item itself is skipped only when content is empty *and* tool calls take its place; a content-less assistant message with no tool calls still round-trips as an empty message, as before. + +Types (#41): `fromChatMessages` and `fromClaudeMessages` declared `models.InputsUnion`, which is not assignable to `callModel`'s `input` (`FieldOrAsyncFunction | string`) — the documented usage `callModel({ input: fromChatMessages(msgs) })` did not typecheck. Both now return `Item[]`. The `Item` union gains `NewAssistantMessageItem` and `NewSystemMessageItem` (id-less input messages for the `assistant` and `system` roles, following the existing `New*` pattern), since it previously had no id-less member for either role — `AssistantMessageItem` is the model's `OutputMessage` and requires an `id` plus structured content. Both new types are exported. diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 6f791d22..50e5f7eb 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -71,6 +71,8 @@ export type { FunctionProgressItem, FunctionResultItem, Item, + NewAssistantMessageItem, + NewSystemMessageItem, NewUserMessageItem, ReasoningItem, SystemMessageItem, diff --git a/packages/agent/src/lib/anthropic-compat.ts b/packages/agent/src/lib/anthropic-compat.ts index a9b6846c..7bb03b38 100644 --- a/packages/agent/src/lib/anthropic-compat.ts +++ b/packages/agent/src/lib/anthropic-compat.ts @@ -10,27 +10,28 @@ import type { ClaudeToolResultBlockParam, ClaudeToolUseBlockParam, } from '../api-shape-helpers/claude-message.js'; +import type { Item, NewAssistantMessageItem, NewUserMessageItem } from './item-types.js'; import { convertToClaudeMessage } from './stream-transformers.js'; /** - * Maps Claude role strings to OpenResponses role types - */ -function mapClaudeRole(role: 'user' | 'assistant'): models.EasyInputMessageRoleUnion { - if (role === 'user') { - return EasyInputMessageRoleUser.User; - } - return EasyInputMessageRoleAssistant.Assistant; -} - -/** - * Creates a properly typed EasyInputMessage with string or structured content. + * Creates a properly typed message item with string or structured content. + * + * The `role` is narrowed to a single literal per branch so the result is + * assignable to a concrete member of the `Item` union — TypeScript will not + * distribute a union-typed `role` across the per-role members of `Item`. */ function createEasyInputMessage( role: 'user' | 'assistant', content: string | models.EasyInputMessageContentUnion1[], -): models.EasyInputMessage { +): NewUserMessageItem | NewAssistantMessageItem { + if (role === 'user') { + return { + role: EasyInputMessageRoleUser.User, + content, + }; + } return { - role: mapClaudeRole(role), + role: EasyInputMessageRoleAssistant.Assistant, content, }; } @@ -71,14 +72,8 @@ function createFunctionCallOutput(callId: string, output: string): models.Functi * }); * ``` */ -export function fromClaudeMessages(messages: ClaudeMessageParam[]): models.InputsUnion { - const result: ( - | models.EasyInputMessage - | models.InputMessageItem - | models.FunctionCallOutputItem - | models.FunctionCallItem - | models.OutputImageGenerationCallItem - )[] = []; +export function fromClaudeMessages(messages: ClaudeMessageParam[]): Item[] { + const result: Item[] = []; for (const msg of messages) { const { role, content } = msg; diff --git a/packages/agent/src/lib/chat-compat.test.ts b/packages/agent/src/lib/chat-compat.test.ts index e35d480b..2dfa8a87 100644 --- a/packages/agent/src/lib/chat-compat.test.ts +++ b/packages/agent/src/lib/chat-compat.test.ts @@ -2,6 +2,7 @@ import type * as models from '@openrouter/sdk/models'; import { describe, expect, it } from 'vitest'; import { fromChatMessages, toChatMessage } from './chat-compat.js'; +import type { Item } from './item-types.js'; /** * Creates a properly typed mock OpenResponsesResult for testing. @@ -303,6 +304,233 @@ describe('fromChatMessages', () => { expect(result).toEqual([]); }); }); + + // Regression tests for https://github.com/OpenRouterTeam/typescript-agent/issues/11 + describe('assistant tool call conversion (#11)', () => { + it('emits a function_call item for an assistant message with null content and one toolCall', () => { + const messages: models.ChatMessages[] = [ + { + role: 'user', + content: 'What is the weather in Paris?', + }, + { + role: 'assistant', + content: null, + toolCalls: [ + { + id: 'call_123', + type: 'function', + function: { + name: 'get_weather', + arguments: '{"location":"Paris"}', + }, + }, + ], + }, + { + role: 'tool', + content: 'Sunny, 22C', + toolCallId: 'call_123', + }, + ]; + + const result = fromChatMessages(messages); + + expect(result).toEqual([ + { + role: 'user', + content: 'What is the weather in Paris?', + }, + { + type: 'function_call', + callId: 'call_123', + id: 'call_123', + name: 'get_weather', + arguments: '{"location":"Paris"}', + status: 'completed', + }, + { + type: 'function_call_output', + callId: 'call_123', + output: 'Sunny, 22C', + }, + ]); + }); + + it('emits both a message item and a function_call item when assistant has text and toolCalls', () => { + const messages: models.ChatMessages[] = [ + { + role: 'assistant', + content: 'Let me check the weather for you.', + toolCalls: [ + { + id: 'call_456', + type: 'function', + function: { + name: 'get_weather', + arguments: '{"location":"London"}', + }, + }, + ], + }, + ]; + + const result = fromChatMessages(messages); + + expect(result).toEqual([ + { + role: 'assistant', + content: 'Let me check the weather for you.', + }, + { + type: 'function_call', + callId: 'call_456', + id: 'call_456', + name: 'get_weather', + arguments: '{"location":"London"}', + status: 'completed', + }, + ]); + }); + + it('emits one function_call item per toolCall for parallel tool calls', () => { + const messages: models.ChatMessages[] = [ + { + role: 'assistant', + content: null, + toolCalls: [ + { + id: 'call_a', + type: 'function', + function: { + name: 'get_weather', + arguments: '{"location":"Paris"}', + }, + }, + { + id: 'call_b', + type: 'function', + function: { + name: 'get_time', + arguments: '{"tz":"UTC"}', + }, + }, + ], + }, + ]; + + const result = fromChatMessages(messages); + + expect(result).toEqual([ + { + type: 'function_call', + callId: 'call_a', + id: 'call_a', + name: 'get_weather', + arguments: '{"location":"Paris"}', + status: 'completed', + }, + { + type: 'function_call', + callId: 'call_b', + id: 'call_b', + name: 'get_time', + arguments: '{"tz":"UTC"}', + status: 'completed', + }, + ]); + }); + + it('does not re-stringify already-serialized tool call arguments', () => { + const messages: models.ChatMessages[] = [ + { + role: 'assistant', + content: null, + toolCalls: [ + { + id: 'call_raw', + type: 'function', + function: { + name: 'noop', + arguments: '{"a":1}', + }, + }, + ], + }, + ]; + + const result = fromChatMessages(messages); + const item = ( + result as Array<{ + arguments?: string; + }> + )[0]; + + // Would be '"{\\"a\\":1}"' if JSON.stringify were applied a second time. + expect(item?.arguments).toBe('{"a":1}'); + }); + + it('emits nothing extra for an assistant message with an empty toolCalls array', () => { + const messages: models.ChatMessages[] = [ + { + role: 'assistant', + content: 'No tools needed.', + toolCalls: [], + }, + ]; + + const result = fromChatMessages(messages); + + expect(result).toEqual([ + { + role: 'assistant', + content: 'No tools needed.', + }, + ]); + }); + }); + + // Regression test for https://github.com/OpenRouterTeam/typescript-agent/issues/41 + describe('return type is assignable to callModel input (#41)', () => { + it('returns a value assignable to Item[]', () => { + const messages: models.ChatMessages[] = [ + { + role: 'system', + content: 'You are helpful.', + }, + { + role: 'user', + content: 'Hi', + }, + { + role: 'assistant', + content: null, + toolCalls: [ + { + id: 'call_typed', + type: 'function', + function: { + name: 'get_weather', + arguments: '{}', + }, + }, + ], + }, + { + role: 'tool', + content: 'ok', + toolCallId: 'call_typed', + }, + ]; + + // Compile-level assertion: this is the shape `callModel({ input })` requires + // (`FieldOrAsyncFunction | string`). Before the #41 fix this line + // failed to typecheck because `fromChatMessages` returned `models.InputsUnion`. + const items: Item[] = fromChatMessages(messages); + + expect(Array.isArray(items)).toBe(true); + }); + }); }); describe('toChatMessage', () => { diff --git a/packages/agent/src/lib/chat-compat.ts b/packages/agent/src/lib/chat-compat.ts index e58caa4f..633ff603 100644 --- a/packages/agent/src/lib/chat-compat.ts +++ b/packages/agent/src/lib/chat-compat.ts @@ -6,6 +6,13 @@ import { EasyInputMessageRoleSystem, EasyInputMessageRoleUser, } from '@openrouter/sdk/models/easyinputmessage'; +import type { + Item, + NewAssistantMessageItem, + NewDeveloperMessageItem, + NewSystemMessageItem, + NewUserMessageItem, +} from './item-types.js'; import { extractMessageFromResponse } from './stream-transformers.js'; /** @@ -23,20 +30,37 @@ function isAssistantMessage(msg: models.ChatMessages): msg is models.ChatAssista } /** - * Maps chat role strings to OpenResponses role types + * Builds a new (id-less) message item with its `role` narrowed to a single + * literal, so the result is assignable to a concrete member of the `Item` + * union. Mapping to the wide `EasyInputMessageRoleUnion` is not enough: + * TypeScript will not distribute a union-typed `role` across the per-role + * members of `Item`. */ -function mapChatRole( +function createMessageItem( role: 'user' | 'system' | 'assistant' | 'developer', -): models.EasyInputMessageRoleUnion { + content: string, +): NewUserMessageItem | NewSystemMessageItem | NewAssistantMessageItem | NewDeveloperMessageItem { switch (role) { case 'user': - return EasyInputMessageRoleUser.User; + return { + role: EasyInputMessageRoleUser.User, + content, + }; case 'system': - return EasyInputMessageRoleSystem.System; + return { + role: EasyInputMessageRoleSystem.System, + content, + }; case 'assistant': - return EasyInputMessageRoleAssistant.Assistant; + return { + role: EasyInputMessageRoleAssistant.Assistant, + content, + }; case 'developer': - return EasyInputMessageRoleDeveloper.Developer; + return { + role: EasyInputMessageRoleDeveloper.Developer, + content, + }; default: { const exhaustiveCheck: never = role; throw new Error(`Unhandled role type: ${exhaustiveCheck}`); @@ -79,29 +103,53 @@ function contentToString(content: unknown): string { * }); * ``` */ -export function fromChatMessages(messages: models.ChatMessages[]): models.InputsUnion { - return messages.map((msg): models.EasyInputMessage | models.FunctionCallOutputItem => { +export function fromChatMessages(messages: models.ChatMessages[]): Item[] { + const result: Item[] = []; + + for (const msg of messages) { if (isToolResponseMessage(msg)) { - return { + result.push({ type: 'function_call_output' as const, callId: msg.toolCallId, output: contentToString(msg.content), - }; + }); + continue; } if (isAssistantMessage(msg)) { - return { - role: mapChatRole('assistant'), - content: contentToString(msg.content), - }; + const content = contentToString(msg.content); + const toolCalls = msg.toolCalls ?? []; + + // Skip the message item only when there is no content AND we have tool + // calls to emit in its place. A content-less assistant message with no + // tool calls still round-trips as an empty message (pre-existing + // behavior) rather than disappearing entirely. + if (content.length > 0 || toolCalls.length === 0) { + result.push(createMessageItem('assistant', content)); + } + + // One function_call item per tool call. `tc.function.arguments` is + // already a JSON string in the chat format, so it is forwarded as-is + // (unlike the Claude path, which stringifies a structured `input`). + for (const tc of toolCalls) { + result.push({ + type: 'function_call' as const, + callId: tc.id, + id: tc.id, + name: tc.function.name, + arguments: tc.function.arguments, + status: 'completed' as const, + }); + } + + continue; } // System, user, developer messages - return { - role: mapChatRole(msg.role), - content: contentToString(msg.content), - }; - }); + result.push(createMessageItem(msg.role, contentToString(msg.content))); + } + + return result; } /** diff --git a/packages/agent/src/lib/item-types.ts b/packages/agent/src/lib/item-types.ts index 0326e1ae..05d4857b 100644 --- a/packages/agent/src/lib/item-types.ts +++ b/packages/agent/src/lib/item-types.ts @@ -41,6 +41,23 @@ export type SystemMessageItem = WithID & { role: 'system'; }; +/** A new system message for input (not yet persisted, no id) */ +export type NewSystemMessageItem = EasyInputMessage & { + role: 'system'; +}; + +/** + * A new assistant message for input (not yet persisted, no id). + * + * Distinct from `AssistantMessageItem` (= `OutputMessage`), which is what the + * model produces and therefore requires an `id` and structured content. This + * member exists so caller-supplied conversation history — e.g. the output of + * `fromChatMessages` / `fromClaudeMessages` — is assignable to `Item[]`. + */ +export type NewAssistantMessageItem = EasyInputMessage & { + role: 'assistant'; +}; + /** A developer message from conversation history (has an assigned id) */ export type DeveloperMessageItem = WithID & { role: 'developer'; @@ -96,6 +113,8 @@ export type Item = | DeveloperMessageItem | NewDeveloperMessageItem | NewUserMessageItem + | NewSystemMessageItem + | NewAssistantMessageItem | CallFunctionToolItem | ReasoningItem | CallFileSearchItem