{"version":3,"file":"conversation.mjs","names":["ConversationToolCall","id","key","ConversationMessage","role","content","tool_calls","tool_call_id","Turn","CONTEXT_OPEN","CONTEXT_CLOSE","CONTEXT_PREAMBLE","withContext","text","context","trimmed","trim","buildRequestMessages","systemPrompt","history","userText","flat","trimTurns","turns","max","dropped","keep","Math","floor","length","slice","unansweredToolCalls","turn","answered","Set","filter","m","map","flatMap","call","has","answerUnansweredToolCalls","result","pending","c","inserted","size","i","message","insertAt","answers","JSON","stringify","splice","push"],"sources":["../../src/aiAssistant/conversation.ts"],"sourcesContent":["/**\n * Conversation-shape rules for the OpenAI Chat Completions API. Pure — no\n * React, no HTTP — so the domain harness can test them.\n *\n * What the standards require, and where each rule comes from:\n *\n * - Every `tool_calls` entry on an assistant message must be answered by a\n *   `role: \"tool\"` message carrying the same `tool_call_id`, directly after\n *   that assistant message. Chat Completions rejects the request otherwise\n *   (\"An assistant message with 'tool_calls' must be followed by tool messages\n *   responding to each 'tool_call_id'\"). LiteLLM only repairs this for\n *   Anthropic, and only with `modify_params` on\n *   (docs.litellm.ai/docs/completion/message_sanitization), so the client owns\n *   it: a turn is only kept in history once it is well-formed.\n *\n * - Prompt caching is an exact-prefix match over tools → system → messages.\n *   OpenAI: \"Put stable developer instructions and shared reference material\n *   first … place [dynamic content] at the end rather than the beginning\"\n *   (developers.openai.com/api/docs/guides/prompt-caching). Anthropic: \"For a\n *   prompt with a varying suffix (timestamps, per-request context, the\n *   incoming message), place the breakpoint at the end of the static prefix\"\n *   (platform.claude.com/docs/en/docs/build-with-claude/prompt-caching). So\n *   the system prompt carries only the stable part and live state rides in\n *   the current user message, after the history.\n *\n * - Anthropic has no `system` role inside `messages` (\"there is no 'system'\n *   role for input messages in the Messages API\") and \"consecutive user or\n *   assistant turns … will be combined into a single turn\"\n *   (platform.claude.com/docs/en/api/messages); LiteLLM merges consecutive\n *   same-role messages for it. A mid-conversation note is therefore a `user`\n *   message, never a `system` one, and two user messages in a row are fine.\n */\n\nexport interface ConversationToolCall {\n  id: string;\n  [key: string]: unknown;\n}\n\nexport interface ConversationMessage {\n  role: string;\n  content: string | null;\n  tool_calls?: ConversationToolCall[];\n  tool_call_id?: string;\n  [key: string]: unknown;\n}\n\n/** One exchange: the user's message and everything the model did in reply. */\nexport type Turn = ConversationMessage[];\n\nexport const CONTEXT_OPEN = '<current_context>';\nexport const CONTEXT_CLOSE = '</current_context>';\n\n/** Appended to the system prompt whenever a context block is in use. */\nexport const CONTEXT_PREAMBLE =\n  `A user message may begin with a ${CONTEXT_OPEN} block: the live state of the application at the moment that message was sent. It is authoritative and more current than anything said in earlier turns.`;\n\n/** The user's text, prefixed with the live context block when there is one. */\nexport const withContext = (text: string, context: string): string => {\n  const trimmed = context.trim();\n  return trimmed ? `${CONTEXT_OPEN}\\n${trimmed}\\n${CONTEXT_CLOSE}\\n\\n${text}` : text;\n};\n\n/**\n * The messages array for one request: stable system prompt, the kept turns\n * verbatim, then the incoming message carrying the per-request context.\n */\nexport const buildRequestMessages = (\n  systemPrompt: string,\n  history: readonly Turn[],\n  userText: string,\n  context: string\n): ConversationMessage[] => [\n  {\n    role: 'system',\n    content: context.trim() ? `${systemPrompt}\\n\\n${CONTEXT_PREAMBLE}` : systemPrompt\n  },\n  ...history.flat(),\n  { role: 'user', content: withContext(userText, context) }\n];\n\n/**\n * Keeps the newest `max` turns. Whole turns only, so an assistant `tool_calls`\n * message is never separated from the tool messages that answer it.\n */\nexport const trimTurns = (\n  turns: readonly Turn[],\n  max: number\n): { turns: Turn[]; dropped: number } => {\n  const keep = Math.max(0, Math.floor(max));\n  const dropped = Math.max(0, turns.length - keep);\n  return { turns: turns.slice(dropped), dropped };\n};\n\n/** Tool calls in `turn` that no tool message answers. */\nexport const unansweredToolCalls = (turn: readonly ConversationMessage[]): ConversationToolCall[] => {\n  const answered = new Set(\n    turn.filter((m) => m.role === 'tool' && m.tool_call_id).map((m) => m.tool_call_id)\n  );\n  return turn\n    .filter((m) => m.role === 'assistant')\n    .flatMap((m) => m.tool_calls ?? [])\n    .filter((call) => !answered.has(call.id));\n};\n\n/**\n * Makes `turn` valid to resend by inserting a tool message for every\n * unanswered tool call, directly after the assistant message that made it.\n * `result(call)` supplies the content (JSON-stringified). Mutates `turn`;\n * returns the messages it inserted.\n */\nexport const answerUnansweredToolCalls = (\n  turn: ConversationMessage[],\n  result: (call: ConversationToolCall) => unknown\n): ConversationMessage[] => {\n  const pending = new Set(unansweredToolCalls(turn).map((c) => c.id));\n  const inserted: ConversationMessage[] = [];\n  if (pending.size === 0) return inserted;\n\n  for (let i = 0; i < turn.length; i++) {\n    const message = turn[i];\n    if (message.role !== 'assistant' || !message.tool_calls?.length) continue;\n    // Skip past the tool messages already answering this assistant message.\n    let insertAt = i + 1;\n    while (insertAt < turn.length && turn[insertAt].role === 'tool') insertAt++;\n    const answers = message.tool_calls\n      .filter((call) => pending.has(call.id))\n      .map<ConversationMessage>((call) => ({\n        role: 'tool',\n        tool_call_id: call.id,\n        content: JSON.stringify(result(call))\n      }));\n    turn.splice(insertAt, 0, ...answers);\n    inserted.push(...answers);\n    i = insertAt + answers.length - 1;\n  }\n  return inserted;\n};\n"],"mappings":";AAiDA,MAAaS,eAAe;AAC5B,MAAaC,gBAAgB;;AAG7B,MAAaC,mBACX,mCAAmCF,aAAY;;AAGjD,MAAaG,eAAeC,MAAcC,YAA4B;CACpE,MAAMC,UAAUD,QAAQE,MAAM;AAC9B,QAAOD,UAAU,GAAGN,aAAY,IAAKM,QAAO,IAAKL,cAAa,MAAOG,SAASA;;;;;;AAOhF,MAAaI,wBACXC,cACAC,SACAC,UACAN,YAC0B;CAC1B;EACEV,MAAM;EACNC,SAASS,QAAQE,MAAM,GAAG,GAAGE,aAAY,MAAOP,qBAAqBO;EACtE;CACD,GAAGC,QAAQE,MAAM;CACjB;EAAEjB,MAAM;EAAQC,SAASO,YAAYQ,UAAUN,QAAO;EAAG;CAC1D;;;;;AAMD,MAAaQ,aACXC,OACAC,QACuC;CACvC,MAAME,OAAOC,KAAKH,IAAI,GAAGG,KAAKC,MAAMJ,IAAI,CAAC;CACzC,MAAMC,UAAUE,KAAKH,IAAI,GAAGD,MAAMM,SAASH,KAAK;AAChD,QAAO;EAAEH,OAAOA,MAAMO,MAAML,QAAQ;EAAEA;EAAS;;;AAIjD,MAAaM,uBAAuBC,SAAiE;CACnG,MAAMC,WAAW,IAAIC,IACnBF,KAAKG,QAAQC,MAAMA,EAAEhC,SAAS,UAAUgC,EAAE7B,aAAa,CAAC8B,KAAKD,MAAMA,EAAE7B,aACvE,CAAC;AACD,QAAOyB,KACJG,QAAQC,MAAMA,EAAEhC,SAAS,YAAY,CACrCkC,SAASF,MAAMA,EAAE9B,cAAc,EAAE,CAAC,CAClC6B,QAAQI,SAAS,CAACN,SAASO,IAAID,KAAKtC,GAAG,CAAC;;;;;;;;AAS7C,MAAawC,6BACXT,MACAU,WAC0B;CAC1B,MAAMC,UAAU,IAAIT,IAAIH,oBAAoBC,KAAK,CAACK,KAAKO,MAAMA,EAAE3C,GAAG,CAAC;CACnE,MAAM4C,WAAkC,EAAE;AAC1C,KAAIF,QAAQG,SAAS,EAAG,QAAOD;AAE/B,MAAK,IAAIE,IAAI,GAAGA,IAAIf,KAAKH,QAAQkB,KAAK;EACpC,MAAMC,UAAUhB,KAAKe;AACrB,MAAIC,QAAQ5C,SAAS,eAAe,CAAC4C,QAAQ1C,YAAYuB,OAAQ;EAEjE,IAAIoB,WAAWF,IAAI;AACnB,SAAOE,WAAWjB,KAAKH,UAAUG,KAAKiB,UAAU7C,SAAS,OAAQ6C;EACjE,MAAMC,UAAUF,QAAQ1C,WACrB6B,QAAQI,SAASI,QAAQH,IAAID,KAAKtC,GAAG,CAAC,CACtCoC,KAA0BE,UAAU;GACnCnC,MAAM;GACNG,cAAcgC,KAAKtC;GACnBI,SAAS8C,KAAKC,UAAUV,OAAOH,KAAK,CAAA;GACrC,EAAE;AACLP,OAAKqB,OAAOJ,UAAU,GAAG,GAAGC,QAAQ;AACpCL,WAASS,KAAK,GAAGJ,QAAQ;AACzBH,MAAIE,WAAWC,QAAQrB,SAAS;;AAElC,QAAOgB"}