diff --git a/.changeset/from-chat-messages-tool-calls.md b/.changeset/from-chat-messages-tool-calls.md new file mode 100644 index 00000000..a102cbc7 --- /dev/null +++ b/.changeset/from-chat-messages-tool-calls.md @@ -0,0 +1,28 @@ +--- +'@openrouter/agent': minor +--- + +Fix `fromChatMessages` dropping assistant tool calls and give both message converters precise array return types that work with `callModel`, `Item[]`, and the SDK's `InputsUnion`. + +Assistant `toolCalls` now become `function_call` items, preserving their already-serialized `arguments`. A message containing both text and tool calls emits both items; an empty assistant message is omitted only when tool calls replace it. + +```ts +import { callModel, fromChatMessages, type ChatMessages, type Item } from '@openrouter/agent'; + +const messages: ChatMessages[] = [ + { + role: 'assistant', + content: null, + toolCalls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'get_weather', arguments: '{"city":"Austin"}' }, + }, + ], + }, +]; + +const input: Item[] = fromChatMessages(messages); +const result = callModel(client, { model: 'openai/gpt-4o-mini', input }); +``` diff --git a/packages/agent/README.md b/packages/agent/README.md index 32f7abfe..7db51b16 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -1017,16 +1017,27 @@ const searchTool = tool({ Convert between OpenRouter and other message formats: ```typescript -import { toClaudeMessage, fromClaudeMessages } from '@openrouter/agent'; -import { toChatMessage, fromChatMessages } from '@openrouter/agent'; +import { + callModel, + fromChatMessages, + fromClaudeMessages, + toChatMessage, + toClaudeMessage, + type Item, +} from '@openrouter/agent'; + +// Both converters return Item-compatible input arrays accepted by callModel. +const claudeInput: Item[] = fromClaudeMessages(claudeMessages); +const chatInput: Item[] = fromChatMessages(chatMessages); -// Anthropic Claude format -const claudeMsg = toClaudeMessage(openRouterMessage); -const orMessages = fromClaudeMessages(claudeMessages); +const result = callModel(client, { + model: 'openai/gpt-4o-mini', + input: chatInput, +}); -// Standard Chat format +// Assistant toolCalls become function_call items in chatInput. +const claudeMsg = toClaudeMessage(openRouterMessage); const chatMsg = toChatMessage(openRouterMessage); -const orMessages2 = fromChatMessages(chatMessages); ``` ## Subpath Exports diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 3b612660..2ca87d3a 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, @@ -94,6 +96,7 @@ export { // Agent tools (tool.agent) export type { AgentRunSpec, AgentToolConfig } from './lib/agent-tool.js'; export { AgentTranscriptSource } from './lib/agent-tool.js'; +export type { ClaudeMessageInputItem } from './lib/anthropic-compat.js'; export { fromClaudeMessages, toClaudeMessage } from './lib/anthropic-compat.js'; export type { CallModelInput, @@ -104,6 +107,7 @@ export { hasAsyncFunctions, resolveAsyncFunctions } from './lib/async-params.js' // Async tool task registry types export type { SettledToolTask } from './lib/async-tool-registry.js'; export { AsyncToolRegistry } from './lib/async-tool-registry.js'; +export type { ChatMessageInputItem } from './lib/chat-compat.js'; export { fromChatMessages, toChatMessage } from './lib/chat-compat.js'; // Claude constants and type guards export { ClaudeContentBlockType, NonClaudeMessageRole } from './lib/claude-constants.js'; diff --git a/packages/agent/src/lib/anthropic-compat.ts b/packages/agent/src/lib/anthropic-compat.ts index a9b6846c..7fa86ff0 100644 --- a/packages/agent/src/lib/anthropic-compat.ts +++ b/packages/agent/src/lib/anthropic-compat.ts @@ -10,27 +10,36 @@ import type { ClaudeToolResultBlockParam, ClaudeToolUseBlockParam, } from '../api-shape-helpers/claude-message.js'; +import type { 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; -} +/** An OpenResponses input item emitted by {@link fromClaudeMessages}. */ +export type ClaudeMessageInputItem = + | NewUserMessageItem + | NewAssistantMessageItem + | models.FunctionCallOutputItem + | models.OutputFunctionCallItem + | models.OutputImageGenerationCallItem; /** - * 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 +80,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[]): ClaudeMessageInputItem[] { + const result: ClaudeMessageInputItem[] = []; 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..c99460b7 100644 --- a/packages/agent/src/lib/chat-compat.ts +++ b/packages/agent/src/lib/chat-compat.ts @@ -6,8 +6,23 @@ import { EasyInputMessageRoleSystem, EasyInputMessageRoleUser, } from '@openrouter/sdk/models/easyinputmessage'; +import type { + NewAssistantMessageItem, + NewDeveloperMessageItem, + NewSystemMessageItem, + NewUserMessageItem, +} from './item-types.js'; import { extractMessageFromResponse } from './stream-transformers.js'; +/** An OpenResponses input item emitted by {@link fromChatMessages}. */ +export type ChatMessageInputItem = + | NewUserMessageItem + | NewSystemMessageItem + | NewAssistantMessageItem + | NewDeveloperMessageItem + | models.FunctionCallOutputItem + | models.OutputFunctionCallItem; + /** * Type guard for ChatToolMessage */ @@ -23,20 +38,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 +111,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[]): ChatMessageInputItem[] { + const result: ChatMessageInputItem[] = []; + + 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 diff --git a/packages/agent/tests/unit/message-compat-types.test-d.ts b/packages/agent/tests/unit/message-compat-types.test-d.ts new file mode 100644 index 00000000..1037b228 --- /dev/null +++ b/packages/agent/tests/unit/message-compat-types.test-d.ts @@ -0,0 +1,23 @@ +import type { InputsUnion } from '@openrouter/sdk/models'; +import { describe, expectTypeOf, it } from 'vitest'; +import type { ChatMessages, Item } from '../../src/index.js'; +import { fromChatMessages, fromClaudeMessages } from '../../src/index.js'; + +type ClaudeMessages = Parameters[0]; + +describe('message compatibility declarations', () => { + it('returns input arrays accepted by both public input types', () => { + const chatMessages = [] as ChatMessages[]; + const claudeMessages = [] as ClaudeMessages; + + const chatItems: Item[] = fromChatMessages(chatMessages); + const chatInput: InputsUnion = fromChatMessages(chatMessages); + const claudeItems: Item[] = fromClaudeMessages(claudeMessages); + const claudeInput: InputsUnion = fromClaudeMessages(claudeMessages); + + expectTypeOf(chatItems).toMatchTypeOf(); + expectTypeOf(chatInput).toMatchTypeOf(); + expectTypeOf(claudeItems).toMatchTypeOf(); + expectTypeOf(claudeInput).toMatchTypeOf(); + }); +}); diff --git a/packages/agent/tsconfig.typecheck.json b/packages/agent/tsconfig.typecheck.json index 543147e6..d43845e8 100644 --- a/packages/agent/tsconfig.typecheck.json +++ b/packages/agent/tsconfig.typecheck.json @@ -1,6 +1,10 @@ { "extends": "./tsconfig.json", "compilerOptions": { "noEmit": true, "rootDir": "." }, - "include": ["src/**/*.ts", "tests/unit/context-schema-inference.test-d.ts"], + "include": [ + "src/**/*.ts", + "tests/unit/context-schema-inference.test-d.ts", + "tests/unit/message-compat-types.test-d.ts" + ], "exclude": ["node_modules", "esm"] }