Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/from-chat-messages-tool-calls.md
Original file line number Diff line number Diff line change
@@ -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<Item[]> | 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.
2 changes: 2 additions & 0 deletions packages/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ export type {
FunctionProgressItem,
FunctionResultItem,
Item,
NewAssistantMessageItem,
NewSystemMessageItem,
NewUserMessageItem,
ReasoningItem,
SystemMessageItem,
Expand Down
37 changes: 16 additions & 21 deletions packages/agent/src/lib/anthropic-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand Down Expand Up @@ -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;
Expand Down
228 changes: 228 additions & 0 deletions packages/agent/src/lib/chat-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Item[]> | 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', () => {
Expand Down
Loading