{"record":{"id":"ee0ffd0433d1c8fb","repo":"moeru-ai/airi","slug":"cannot-replay-an-unfinished-function-call","errorCode":null,"errorMessage":"Cannot replay an unfinished function call","messagePattern":"Cannot replay an unfinished function call","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/core-agent/src/messages/chat-completions.ts","lineNumber":60,"sourceCode":" * chatMessagesToProjectionEntries([{ role: 'user', content: 'Hello' }])[0].segments\n * // => [{ type: 'text', text: 'Hello' }]\n */\nexport function chatMessagesToProjectionEntries(messages: (ChatMessage | { role: 'error', content: string })[], idPrefix = 'message'): ProjectionEntry[] {\n  return messages.map((message, index) => {\n    const id = `${idPrefix}-${index}`\n    if (message.role === 'error')\n      return { id, role: 'user', segments: [{ type: 'text', text: `User encountered error: ${message.content}` }] }\n    if (message.role === 'tool')\n      return { id, role: 'tool', segments: [{ type: 'tool-result', callId: message.tool_call_id, content: chatContentToInputSegments(message.content) }] }\n    if (message.role === 'assistant') {\n      const segments: Extract<ProjectionEntry, { role: 'assistant' }>['segments'] = typeof message.content === 'string'\n        ? [{ type: 'text', text: message.content }]\n        : message.content?.map(part => part.type === 'text' ? { type: 'text', text: part.text } : { type: 'refusal', text: part.refusal }) ?? []\n      if (message.refusal)\n        segments.push({ type: 'refusal', text: message.refusal })\n      for (const call of message.tool_calls ?? []) {\n        if (call.function.name == null || call.function.arguments == null)\n          throw new Error('Cannot replay an unfinished function call')\n        segments.push({ type: 'tool-call', callId: call.id, name: call.function.name, arguments: call.function.arguments })\n      }\n      return { id, role: 'assistant', segments }\n    }\n    if (message.role === 'system' || message.role === 'developer')\n      return { id, role: message.role, segments: typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : message.content.map(part => ({ type: 'text', text: part.text })) }\n    return { id, role: message.role, segments: chatContentToInputSegments(message.content) }\n  })\n}\n\n/** Reads persisted Chat records; the storage identity scopes turn, round, and invocation ids. */\nexport function chatMessagesToTurns(messages: Parameters<typeof chatMessagesToProjectionEntries>[0], idPrefix?: string): Turn[] {\n  return readTurns(chatMessagesToProjectionEntries(messages, idPrefix))\n}\n\nfunction writeContent(segment: MessageSegment): CommonContentPart {\n  switch (segment.type) {\n    case 'image': return { type: 'image_url', image_url: { url: segment.url, detail: segment.detail } }","sourceCodeStart":42,"sourceCodeEnd":78,"githubUrl":"https://github.com/moeru-ai/airi/blob/438a067dde47aa0bdb46c2323d1fe293dc805218/packages/core-agent/src/messages/chat-completions.ts#L42-L78","documentation":"When replaying stored assistant messages, each tool call must have both function.name and function.arguments to be re-emitted as a portable tool-call segment. Streaming or interrupted generations can persist tool_calls entries with null fields; replaying those would produce an invalid call, so the library throws.","triggerScenarios":"Calling chatMessagesToProjectionEntries (via chatMessagesToTurns or generation) on a message history whose assistant message contains a tool_calls entry where call.function.name == null or call.function.arguments == null — typically a tool call captured mid-stream or truncated at the end of a generation.","commonSituations":"Persisting assistant output from a streamed response that was cut off before the tool call completed; saving partial chunks when a request errored or timed out; a provider returning an empty arguments string field omitted entirely; resuming a conversation after a crash mid-generation.","solutions":["Drop incomplete tool_calls entries from the stored message before replay (filter out calls with null name or arguments)","When persisting streamed assistant output, only save tool_calls after the stream finishes and both fields are complete","If the call was never executed, remove it from history rather than replaying it; if it was executed, reconstruct name/arguments from the tool result record"],"exampleFix":"// before\nmessages.push(assistantMessage) // assistantMessage.tool_calls[0].function.arguments === null\n// after\nassistantMessage.tool_calls = assistantMessage.tool_calls?.filter(c => c.function.name != null && c.function.arguments != null)\nmessages.push(assistantMessage)","handlingStrategy":"validation","validationCode":"for (const m of messages) {\n  if (m.role === 'assistant' && m.tool_calls?.some(c => c.function.name == null || c.function.arguments == null)) {\n    throw new Error('History contains an unfinished tool call; drop or complete it before replay')\n  }\n}","typeGuard":"function isReplayableToolCall(call) {\n  return call.function.name != null && call.function.arguments != null\n}\nconst replayable = messages.every(m => m.role !== 'assistant' || (m.tool_calls ?? []).every(isReplayableToolCall))","tryCatchPattern":"try {\n  const entries = chatMessagesToProjectionEntries(messages)\n} catch (err) {\n  if (err.message === 'Cannot replay an unfinished function call') {\n    const repaired = messages.map(m => m.role === 'assistant'\n      ? { ...m, tool_calls: m.tool_calls?.filter(isReplayableToolCall) }\n      : m)\n    return chatMessagesToProjectionEntries(repaired)\n  }\n  throw err\n}","preventionTips":["Only persist assistant tool_calls after the stream completes with name and arguments populated","Filter incomplete tool_calls when saving partial/streamed generations","Check stored history for incomplete calls before resuming a conversation","When truncating history mid-conversation, drop a trailing incomplete tool call"],"tags":["tool-calls","replay","assistant-message","streaming"],"backgroundTag":"invalid-state-transition","analyzedSha":"438a067dde47aa0bdb46c2323d1fe293dc805218","analyzedAt":"2026-09-17T01:14:42.644Z","contentChangedAt":"2026-09-17T01:14:42.644Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}