Skip to content

fix(agent): emit function_call items from fromChatMessages and return Item[] (#11, #41) - #93

Open
LukasParke wants to merge 1 commit into
mainfrom
fix/11-41-from-chat-messages
Open

fix(agent): emit function_call items from fromChatMessages and return Item[] (#11, #41)#93
LukasParke wants to merge 1 commit into
mainfrom
fix/11-41-from-chat-messages

Conversation

@LukasParke

@LukasParke LukasParke commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Two bugs in the same function, fromChatMessages — one runtime, one type-level.

Fixes #11
Fixes #41

#11 — assistant toolCalls were silently dropped

The assistant branch only read msg.content and never msg.toolCalls:

if (isAssistantMessage(msg)) {
  return { role: mapChatRole('assistant'), content: contentToString(msg.content) };
}

A tool-calling assistant message conventionally carries content: null, so this produced { role: 'assistant', content: '' } — the tool call vanished, and the following function_call_output items were left orphaned with no call to answer. Any agentic loop replayed through this helper lost its tool calls.

The .map() callback's declared return type (EasyInputMessage | FunctionCallOutputItem) also structurally prevented emitting function_call items at all, and .map() is 1:1 while one chat message can fan out to several items.

Converted to an accumulator loop mirroring fromClaudeMessages: push a message item, then one function_call item per toolCalls entry.

Two details worth flagging for review:

  • arguments is forwarded as-is. ChatToolCall.function.arguments is already a JSON string in the chat format, unlike the Claude path's structured input which needs JSON.stringify. Double-stringifying would send "{\"a\":1}" to the model. There is a dedicated regression test pinning this.
  • Minimal behavior change on empty content. The message item 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, so the pre-existing handles null content in assistant message test is unchanged.

#41 — return type was not assignable to callModel input

fromChatMessages declared models.InputsUnion, but callModel's input is FieldOrAsyncFunction<Item[]> | string. So the usage in the function's own doc comment did not typecheck:

error TS2322: Type 'InputsUnion' is not assignable to type 'Item[]'.
  Type 'string' is not assignable to type 'Item[]'.

Both fromChatMessages and fromClaudeMessages now return Item[]. fromClaudeMessages had the identical problem and is fixed here too, since this PR already touches the union.

The Item union had no id-less member for the assistant or system roles — AssistantMessageItem is the model's OutputMessage, which requires an id and structured content. Added NewAssistantMessageItem and NewSystemMessageItem following the existing New* pattern, and exported both. CallFunctionToolItem (= OutputFunctionCallItem, with id?: string) already covered the emitted function_call items as-is.

Role construction moved into per-role narrowed helpers (createMessageItem / createEasyInputMessage). This is load-bearing rather than cosmetic: TypeScript will not distribute a union-typed role across the per-role members of Item, so returning the wide EasyInputMessageRoleUnion from a shared mapChatRole cannot produce an assignable value. mapChatRole became dead and was removed.

API example

import { callModel, fromChatMessages, type ChatMessages, type Item } from '@openrouter/agent';

// Before: an assistant message's toolCalls were silently dropped here.
const messages: ChatMessages[] = [
  { role: 'user', content: 'What is the weather in Paris?' },
  {
    role: 'assistant',
    content: null,
    toolCalls: [{ id: 'call_1', type: 'function', function: { name: 'get_weather', arguments: '{"location":"Paris"}' } }],
  },
  { role: 'tool', toolCallId: 'call_1', content: 'Sunny, 22C' },
];

// After: toolCalls become function_call items, and the Item[] result is
// accepted directly by `input` — no cast needed.
const input: Item[] = fromChatMessages(messages);
const result = callModel(client, { model: 'openai/gpt-4o-mini', input });

Testing

Red-first. Both regressions were pinned and confirmed failing before the fix:

  • Runtime: 4 failures, each showing the tool call collapsing to { role: 'assistant', content: '' }.
  • Types: error TS2322: Type 'InputsUnion' is not assignable to type 'Item[]'.

New tests cover the exact #11 repro (user → assistant with content: null + toolCall → tool), assistant with both text and toolCalls, parallel tool calls, the no-double-stringify guarantee, an empty toolCalls array, and a compile-level const items: Item[] = fromChatMessages(msgs) assertion for #41.

After: 649 passed (649) for @openrouter/agent; build, typecheck, and lint green workspace-wide.

Follow-up: CI typecheck blind spot

packages/agent/tsconfig.typecheck.json includes only src/**/*.ts and one .test-d.ts, excluding tests/e2e/. That is why ~48 fromChatMessages / fromClaudeMessages call sites in tests/e2e/call-model.test.ts passed a wrong-typed value for so long without failing the build.

Measured, as evidence the type fix is real: typechecking that file against main's src yields 69 errors; against this branch, 21 — a reduction of exactly 48, matching the call-site count, with zero overlap between the remaining error lines and the call-site lines.

I deliberately did not widen the tsconfig include here — the remaining 21 errors are pre-existing and unrelated (implicit any, index-signature access, stale SDK module paths), so fixing them is its own PR. Worth doing as a follow-up so this class of bug fails CI.

🤖 Generated with Claude Code

perry-the-pr-reviewer[bot]

This comment was marked as outdated.

@LukasParke
LukasParke marked this pull request as ready for review August 6, 2026 21:31
@LukasParke
LukasParke force-pushed the fix/11-41-from-chat-messages branch from aca6802 to 2c54b3a Compare August 6, 2026 21:32
devin-ai-integration[bot]

This comment was marked as resolved.

perry-the-pr-reviewer[bot]

This comment was marked as resolved.

… Item[] (#11, #41)

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<Item[]> | 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 <noreply@anthropic.com>
@LukasParke
LukasParke force-pushed the fix/11-41-from-chat-messages branch from 2c54b3a to dacf4d1 Compare August 11, 2026 18:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fromChatMessages does not produce correct object for input in callModel TypeScript fromChatMessages does not properly interpret function_calls

1 participant