Skip to content
Open
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
28 changes: 28 additions & 0 deletions .changeset/from-chat-messages-tool-calls.md
Original file line number Diff line number Diff line change
@@ -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 });
```
25 changes: 18 additions & 7 deletions packages/agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 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 All @@ -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,
Expand All @@ -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';
Expand Down
43 changes: 23 additions & 20 deletions packages/agent/src/lib/anthropic-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand Down Expand Up @@ -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;
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
Loading