| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662 |
- import { describe, expect, test } from "bun:test"
- import { SessionV1 } from "@kirincode-ai/core/v1/session"
- import { APICallError } from "ai"
- import { MessageV2 } from "../../src/session/message-v2"
- import { ProviderTransform } from "@/provider/transform"
- import type { Provider } from "@/provider/provider"
- import { SessionID, MessageID, PartID } from "../../src/session/schema"
- import { Question } from "../../src/question"
- import { ProviderV2 } from "@kirincode-ai/core/provider"
- import { ModelV2 } from "@kirincode-ai/core/model"
- const sessionID = SessionID.make("session")
- const providerID = ProviderV2.ID.make("test")
- const model: Provider.Model = {
- id: ModelV2.ID.make("test-model"),
- providerID,
- api: {
- id: "test-model",
- url: "https://example.com",
- npm: "@ai-sdk/openai",
- },
- name: "Test Model",
- capabilities: {
- temperature: true,
- reasoning: false,
- attachment: false,
- toolcall: true,
- input: {
- text: true,
- audio: false,
- image: false,
- video: false,
- pdf: false,
- },
- output: {
- text: true,
- audio: false,
- image: false,
- video: false,
- pdf: false,
- },
- interleaved: false,
- },
- cost: {
- input: 0,
- output: 0,
- cache: {
- read: 0,
- write: 0,
- },
- },
- limit: {
- context: 0,
- input: 0,
- output: 0,
- },
- status: "active",
- options: {},
- headers: {},
- release_date: "2026-01-01",
- }
- function userInfo(id: string): SessionV1.User {
- return {
- id,
- sessionID,
- role: "user",
- time: { created: 0 },
- agent: "user",
- model: { providerID, modelID: ModelV2.ID.make("test") },
- tools: {},
- mode: "",
- } as unknown as SessionV1.User
- }
- function assistantInfo(
- id: string,
- parentID: string,
- error?: SessionV1.Assistant["error"],
- meta?: { providerID: string; modelID: string },
- ): SessionV1.Assistant {
- const infoModel = meta ?? { providerID: model.providerID, modelID: model.api.id }
- return {
- id,
- sessionID,
- role: "assistant",
- time: { created: 0 },
- error,
- parentID,
- modelID: infoModel.modelID,
- providerID: infoModel.providerID,
- mode: "",
- agent: "agent",
- path: { cwd: "/", root: "/" },
- cost: 0,
- tokens: {
- input: 0,
- output: 0,
- reasoning: 0,
- cache: { read: 0, write: 0 },
- },
- } as unknown as SessionV1.Assistant
- }
- function basePart(messageID: string, id: string) {
- return {
- id: PartID.make(id.startsWith("prt") ? id : `prt_${id}`),
- sessionID,
- messageID: MessageID.make(messageID.startsWith("msg") ? messageID : `msg_${messageID}`),
- }
- }
- describe("session.message-v2.toModelMessage", () => {
- test("filters out messages with no parts", async () => {
- const input: SessionV1.WithParts[] = [
- {
- info: userInfo("m-empty"),
- parts: [],
- },
- {
- info: userInfo("m-user"),
- parts: [
- {
- ...basePart("m-user", "p1"),
- type: "text",
- text: "hello",
- },
- ] as SessionV1.Part[],
- },
- ]
- expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
- {
- role: "user",
- content: [{ type: "text", text: "hello" }],
- },
- ])
- })
- test("filters out messages with only ignored parts", async () => {
- const messageID = "m-user"
- const input: SessionV1.WithParts[] = [
- {
- info: userInfo(messageID),
- parts: [
- {
- ...basePart(messageID, "p1"),
- type: "text",
- text: "ignored",
- ignored: true,
- },
- ] as SessionV1.Part[],
- },
- ]
- expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([])
- })
- test("filters out user messages with only empty text parts", async () => {
- const messageID = "m-user"
- const input: SessionV1.WithParts[] = [
- {
- info: userInfo(messageID),
- parts: [
- {
- ...basePart(messageID, "p1"),
- type: "text",
- text: "",
- },
- ] as SessionV1.Part[],
- },
- ]
- expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([])
- })
- test("filters empty user text parts while keeping non-empty parts", async () => {
- const messageID = "m-user"
- const input: SessionV1.WithParts[] = [
- {
- info: userInfo(messageID),
- parts: [
- {
- ...basePart(messageID, "p1"),
- type: "text",
- text: "",
- },
- {
- ...basePart(messageID, "p2"),
- type: "text",
- text: "hello",
- },
- ] as SessionV1.Part[],
- },
- ]
- expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
- {
- role: "user",
- content: [{ type: "text", text: "hello" }],
- },
- ])
- })
- test("includes synthetic text parts", async () => {
- const messageID = "m-user"
- const input: SessionV1.WithParts[] = [
- {
- info: userInfo(messageID),
- parts: [
- {
- ...basePart(messageID, "p1"),
- type: "text",
- text: "hello",
- synthetic: true,
- },
- ] as SessionV1.Part[],
- },
- {
- info: assistantInfo("m-assistant", messageID),
- parts: [
- {
- ...basePart("m-assistant", "a1"),
- type: "text",
- text: "assistant",
- synthetic: true,
- },
- ] as SessionV1.Part[],
- },
- ]
- expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
- {
- role: "user",
- content: [{ type: "text", text: "hello" }],
- },
- {
- role: "assistant",
- content: [{ type: "text", text: "assistant" }],
- },
- ])
- })
- test("converts user text/file parts and injects compaction/subtask prompts", async () => {
- const messageID = "m-user"
- const input: SessionV1.WithParts[] = [
- {
- info: userInfo(messageID),
- parts: [
- {
- ...basePart(messageID, "p1"),
- type: "text",
- text: "hello",
- },
- {
- ...basePart(messageID, "p2"),
- type: "text",
- text: "ignored",
- ignored: true,
- },
- {
- ...basePart(messageID, "p3"),
- type: "file",
- mime: "image/png",
- filename: "img.png",
- url: "https://example.com/img.png",
- },
- {
- ...basePart(messageID, "p4"),
- type: "file",
- mime: "text/plain",
- filename: "note.txt",
- url: "https://example.com/note.txt",
- },
- {
- ...basePart(messageID, "p5"),
- type: "file",
- mime: "application/x-directory",
- filename: "dir",
- url: "https://example.com/dir",
- },
- {
- ...basePart(messageID, "p6"),
- type: "compaction",
- auto: true,
- },
- {
- ...basePart(messageID, "p7"),
- type: "subtask",
- prompt: "prompt",
- description: "desc",
- agent: "agent",
- },
- ] as SessionV1.Part[],
- },
- ]
- expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
- {
- role: "user",
- content: [
- { type: "text", text: "hello" },
- {
- type: "file",
- mediaType: "image/png",
- filename: "img.png",
- data: "https://example.com/img.png",
- },
- { type: "text", text: "What did we do so far?" },
- { type: "text", text: "The following tool was executed by the user" },
- ],
- },
- ])
- })
- test("converts assistant tool completion into tool-call + tool-result messages with attachments", async () => {
- const userID = "m-user"
- const assistantID = "m-assistant"
- const input: SessionV1.WithParts[] = [
- {
- info: userInfo(userID),
- parts: [
- {
- ...basePart(userID, "u1"),
- type: "text",
- text: "run tool",
- },
- ] as SessionV1.Part[],
- },
- {
- info: assistantInfo(assistantID, userID),
- parts: [
- {
- ...basePart(assistantID, "a1"),
- type: "text",
- text: "done",
- metadata: { openai: { assistant: "meta" } },
- },
- {
- ...basePart(assistantID, "a2"),
- type: "tool",
- callID: "call-1",
- tool: "bash",
- state: {
- status: "completed",
- input: { cmd: "ls" },
- output: "ok",
- title: "Bash",
- metadata: {},
- time: { start: 0, end: 1 },
- attachments: [
- {
- ...basePart(assistantID, "file-1"),
- type: "file",
- mime: "image/png",
- filename: "attachment.png",
- url: "data:image/png;base64,Zm9v",
- },
- ],
- },
- metadata: { openai: { tool: "meta" } },
- },
- ] as SessionV1.Part[],
- },
- ]
- expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
- {
- role: "user",
- content: [{ type: "text", text: "run tool" }],
- },
- {
- role: "assistant",
- content: [
- { type: "text", text: "done", providerOptions: { openai: { assistant: "meta" } } },
- {
- type: "tool-call",
- toolCallId: "call-1",
- toolName: "bash",
- input: { cmd: "ls" },
- providerExecuted: undefined,
- providerOptions: { openai: { tool: "meta" } },
- },
- ],
- },
- {
- role: "tool",
- content: [
- {
- type: "tool-result",
- toolCallId: "call-1",
- toolName: "bash",
- output: {
- type: "content",
- value: [
- { type: "text", text: "ok" },
- { type: "media", mediaType: "image/png", data: "Zm9v" },
- ],
- },
- providerOptions: { openai: { tool: "meta" } },
- },
- ],
- },
- ])
- })
- test("preserves jpeg tool-result media for anthropic models", async () => {
- const anthropicModel: Provider.Model = {
- ...model,
- id: ModelV2.ID.make("anthropic/claude-opus-4-7"),
- providerID: ProviderV2.ID.make("anthropic"),
- api: {
- id: "claude-opus-4-7-20250805",
- url: "https://api.anthropic.com",
- npm: "@ai-sdk/anthropic",
- },
- capabilities: {
- ...model.capabilities,
- attachment: true,
- input: {
- ...model.capabilities.input,
- image: true,
- pdf: true,
- },
- },
- }
- const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]).toString(
- "base64",
- )
- const userID = "m-user-anthropic"
- const assistantID = "m-assistant-anthropic"
- const input: SessionV1.WithParts[] = [
- {
- info: userInfo(userID),
- parts: [
- {
- ...basePart(userID, "u1-anthropic"),
- type: "text",
- text: "run tool",
- },
- ] as SessionV1.Part[],
- },
- {
- info: assistantInfo(assistantID, userID),
- parts: [
- {
- ...basePart(assistantID, "a1-anthropic"),
- type: "tool",
- callID: "call-anthropic-1",
- tool: "read",
- state: {
- status: "completed",
- input: { filePath: "/tmp/rails-demo.png" },
- output: "Image read successfully",
- title: "Read",
- metadata: {},
- time: { start: 0, end: 1 },
- attachments: [
- {
- ...basePart(assistantID, "file-anthropic-1"),
- type: "file",
- mime: "image/jpeg",
- filename: "rails-demo.png",
- url: `data:image/jpeg;base64,${jpeg}`,
- },
- ],
- },
- },
- ] as SessionV1.Part[],
- },
- ]
- const result = ProviderTransform.message(await MessageV2.toModelMessages(input, anthropicModel), anthropicModel, {})
- expect(result).toHaveLength(3)
- expect(result[2].role).toBe("tool")
- expect(result[2].content[0]).toMatchObject({
- type: "tool-result",
- toolCallId: "call-anthropic-1",
- toolName: "read",
- output: {
- type: "content",
- value: [
- { type: "text", text: "Image read successfully" },
- { type: "media", mediaType: "image/jpeg", data: jpeg },
- ],
- },
- })
- })
- test("moves bedrock pdf tool-result media into a separate user message", async () => {
- const bedrockModel: Provider.Model = {
- ...model,
- id: ModelV2.ID.make("amazon-bedrock/anthropic.claude-sonnet-4-6"),
- providerID: ProviderV2.ID.make("amazon-bedrock"),
- api: {
- id: "anthropic.claude-sonnet-4-6",
- url: "https://bedrock-runtime.us-east-1.amazonaws.com",
- npm: "@ai-sdk/amazon-bedrock",
- },
- capabilities: {
- ...model.capabilities,
- attachment: true,
- input: {
- ...model.capabilities.input,
- image: true,
- pdf: true,
- },
- },
- }
- const pdf = Buffer.from("%PDF-1.4\n").toString("base64")
- const userID = "m-user-bedrock-pdf"
- const assistantID = "m-assistant-bedrock-pdf"
- const input: SessionV1.WithParts[] = [
- {
- info: userInfo(userID),
- parts: [
- {
- ...basePart(userID, "u1-bedrock-pdf"),
- type: "text",
- text: "run tool",
- },
- ] as SessionV1.Part[],
- },
- {
- info: assistantInfo(assistantID, userID),
- parts: [
- {
- ...basePart(assistantID, "a1-bedrock-pdf"),
- type: "tool",
- callID: "call-bedrock-pdf-1",
- tool: "read",
- state: {
- status: "completed",
- input: { filePath: "/tmp/example.pdf" },
- output: "PDF read successfully",
- title: "Read",
- metadata: {},
- time: { start: 0, end: 1 },
- attachments: [
- {
- ...basePart(assistantID, "file-bedrock-pdf-1"),
- type: "file",
- mime: "application/pdf",
- filename: "example.pdf",
- url: `data:application/pdf;base64,${pdf}`,
- },
- ],
- },
- },
- ] as SessionV1.Part[],
- },
- ]
- expect(await MessageV2.toModelMessages(input, bedrockModel)).toStrictEqual([
- {
- role: "user",
- content: [{ type: "text", text: "run tool" }],
- },
- {
- role: "assistant",
- content: [
- {
- type: "tool-call",
- toolCallId: "call-bedrock-pdf-1",
- toolName: "read",
- input: { filePath: "/tmp/example.pdf" },
- providerExecuted: undefined,
- },
- ],
- },
- {
- role: "tool",
- content: [
- {
- type: "tool-result",
- toolCallId: "call-bedrock-pdf-1",
- toolName: "read",
- output: { type: "text", value: "PDF read successfully" },
- },
- ],
- },
- {
- role: "user",
- content: [
- { type: "text", text: "Attached media from tool result:" },
- {
- type: "file",
- mediaType: "application/pdf",
- filename: "example.pdf",
- data: `data:application/pdf;base64,${pdf}`,
- },
- ],
- },
- ])
- })
- test("omits provider metadata when assistant model differs", async () => {
- const userID = "m-user"
- const assistantID = "m-assistant"
- const input: SessionV1.WithParts[] = [
- {
- info: userInfo(userID),
- parts: [
- {
- ...basePart(userID, "u1"),
- type: "text",
- text: "run tool",
- },
- ] as SessionV1.Part[],
- },
- {
- info: assistantInfo(assistantID, userID, undefined, { providerID: "other", modelID: "other" }),
- parts: [
- {
- ...basePart(assistantID, "a1"),
- type: "text",
- text: "done",
- metadata: { openai: { assistant: "meta" } },
- },
- {
- ...basePart(assistantID, "a2"),
- type: "reasoning",
- text: "thinking",
- metadata: { openai: { reasoning: "meta" } },
- time: { start: 0 },
- },
- {
- ...basePart(assistantID, "a3"),
- type: "tool",
- callID: "call-1",
- tool: "bash",
- state: {
- status: "completed",
- input: { cmd: "ls" },
- output: "ok",
- title: "Bash",
- metadata: {},
- time: { start: 0, end: 1 },
- },
- metadata: { openai: { tool: "meta" } },
- },
- ] as SessionV1.Part[],
- },
- ]
- expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
- {
- role: "user",
- content: [{ type: "text", text: "run tool" }],
- },
- {
- role: "assistant",
- content: [
- { type: "text", text: "done" },
- { type: "text", text: "thinking" },
- {
- type: "tool-call",
- toolCallId: "call-1",
- toolName: "bash",
- input: { cmd: "ls" },
- providerExecuted: undefined,
- },
- ],
- },
- {
- role: "tool",
- content: [
- {
- type: "tool-result",
- toolCallId: "call-1",
- toolName: "bash",
- output: { type: "text", value: "ok" },
- },
- ],
- },
- ])
- })
- test("replaces compacted tool output with placeholder", async () => {
- const userID = "m-user"
- const assistantID = "m-assistant"
- const input: SessionV1.WithParts[] = [
- {
- info: userInfo(userID),
- parts: [
- {
- ...basePart(userID, "u1"),
- type: "text",
- text: "run tool",
- },
- ] as SessionV1.Part[],
- },
- {
- info: assistantInfo(assistantID, userID),
- parts: [
- {
- ...basePart(assistantID, "a1"),
- type: "tool",
- callID: "call-1",
- tool: "bash",
- state: {
- status: "completed",
- input: { cmd: "ls" },
- output: "this should be cleared",
- title: "Bash",
- metadata: {},
- time: { start: 0, end: 1, compacted: 1 },
- },
- },
- ] as SessionV1.Part[],
- },
- ]
- expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
- {
- role: "user",
- content: [{ type: "text", text: "run tool" }],
- },
- {
- role: "assistant",
- content: [
- {
- type: "tool-call",
- toolCallId: "call-1",
- toolName: "bash",
- input: { cmd: "ls" },
- providerExecuted: undefined,
- },
- ],
- },
- {
- role: "tool",
- content: [
- {
- type: "tool-result",
- toolCallId: "call-1",
- toolName: "bash",
- output: { type: "text", value: "[Old tool result content cleared]" },
- },
- ],
- },
- ])
- })
- test("truncates tool output when requested", async () => {
- const userID = "m-user"
- const assistantID = "m-assistant"
- const input: SessionV1.WithParts[] = [
- {
- info: userInfo(userID),
- parts: [
- {
- ...basePart(userID, "u1"),
- type: "text",
- text: "run tool",
- },
- ] as SessionV1.Part[],
- },
- {
- info: assistantInfo(assistantID, userID),
- parts: [
- {
- ...basePart(assistantID, "a1"),
- type: "tool",
- callID: "call-1",
- tool: "bash",
- state: {
- status: "completed",
- input: { cmd: "ls" },
- output: "abcdefghij",
- title: "Shell",
- metadata: {},
- time: { start: 0, end: 1 },
- },
- },
- ] as SessionV1.Part[],
- },
- ]
- expect(await MessageV2.toModelMessages(input, model, { toolOutputMaxChars: 4 })).toStrictEqual([
- {
- role: "user",
- content: [{ type: "text", text: "run tool" }],
- },
- {
- role: "assistant",
- content: [
- {
- type: "tool-call",
- toolCallId: "call-1",
- toolName: "bash",
- input: { cmd: "ls" },
- providerExecuted: undefined,
- },
- ],
- },
- {
- role: "tool",
- content: [
- {
- type: "tool-result",
- toolCallId: "call-1",
- toolName: "bash",
- output: {
- type: "text",
- value: "abcd\n[Tool output truncated for compaction: omitted 6 chars]",
- },
- },
- ],
- },
- ])
- })
- test("converts assistant tool error into error-text tool result", async () => {
- const userID = "m-user"
- const assistantID = "m-assistant"
- const input: SessionV1.WithParts[] = [
- {
- info: userInfo(userID),
- parts: [
- {
- ...basePart(userID, "u1"),
- type: "text",
- text: "run tool",
- },
- ] as SessionV1.Part[],
- },
- {
- info: assistantInfo(assistantID, userID),
- parts: [
- {
- ...basePart(assistantID, "a1"),
- type: "tool",
- callID: "call-1",
- tool: "bash",
- state: {
- status: "error",
- input: { cmd: "ls" },
- error: "nope",
- time: { start: 0, end: 1 },
- metadata: {},
- },
- metadata: { openai: { tool: "meta" } },
- },
- ] as SessionV1.Part[],
- },
- ]
- expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
- {
- role: "user",
- content: [{ type: "text", text: "run tool" }],
- },
- {
- role: "assistant",
- content: [
- {
- type: "tool-call",
- toolCallId: "call-1",
- toolName: "bash",
- input: { cmd: "ls" },
- providerExecuted: undefined,
- providerOptions: { openai: { tool: "meta" } },
- },
- ],
- },
- {
- role: "tool",
- content: [
- {
- type: "tool-result",
- toolCallId: "call-1",
- toolName: "bash",
- output: { type: "error-text", value: "nope" },
- providerOptions: { openai: { tool: "meta" } },
- },
- ],
- },
- ])
- })
- test("forwards partial bash output for aborted tool calls", async () => {
- const userID = "m-user"
- const assistantID = "m-assistant"
- const output = [
- "31403",
- "12179",
- "4575",
- "",
- "<shell_metadata>",
- "User aborted the command",
- "</shell_metadata>",
- ].join("\n")
- const input: SessionV1.WithParts[] = [
- {
- info: userInfo(userID),
- parts: [
- {
- ...basePart(userID, "u1"),
- type: "text",
- text: "run tool",
- },
- ] as SessionV1.Part[],
- },
- {
- info: assistantInfo(assistantID, userID),
- parts: [
- {
- ...basePart(assistantID, "a1"),
- type: "tool",
- callID: "call-1",
- tool: "bash",
- state: {
- status: "error",
- input: { command: "for i in {1..20}; do print -- $RANDOM; sleep 1; done" },
- error: "Tool execution aborted",
- metadata: { interrupted: true, output },
- time: { start: 0, end: 1 },
- },
- },
- ] as SessionV1.Part[],
- },
- ]
- expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
- {
- role: "user",
- content: [{ type: "text", text: "run tool" }],
- },
- {
- role: "assistant",
- content: [
- {
- type: "tool-call",
- toolCallId: "call-1",
- toolName: "bash",
- input: { command: "for i in {1..20}; do print -- $RANDOM; sleep 1; done" },
- providerExecuted: undefined,
- },
- ],
- },
- {
- role: "tool",
- content: [
- {
- type: "tool-result",
- toolCallId: "call-1",
- toolName: "bash",
- output: { type: "text", value: output },
- },
- ],
- },
- ])
- })
- test("filters assistant messages with non-abort errors", async () => {
- const assistantID = "m-assistant"
- const input: SessionV1.WithParts[] = [
- {
- info: assistantInfo(
- assistantID,
- "m-parent",
- new SessionV1.APIError({ message: "boom", isRetryable: true }).toObject() as SessionV1.APIError,
- ),
- parts: [
- {
- ...basePart(assistantID, "a1"),
- type: "text",
- text: "should not render",
- },
- ] as SessionV1.Part[],
- },
- ]
- expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([])
- })
- test("includes aborted assistant messages only when they have non-step-start/reasoning content", async () => {
- const assistantID1 = "m-assistant-1"
- const assistantID2 = "m-assistant-2"
- const aborted = new SessionV1.AbortedError({
- message: "aborted",
- }).toObject() as SessionV1.Assistant["error"]
- const input: SessionV1.WithParts[] = [
- {
- info: assistantInfo(assistantID1, "m-parent", aborted),
- parts: [
- {
- ...basePart(assistantID1, "a1"),
- type: "reasoning",
- text: "thinking",
- time: { start: 0 },
- },
- {
- ...basePart(assistantID1, "a2"),
- type: "text",
- text: "partial answer",
- },
- ] as SessionV1.Part[],
- },
- {
- info: assistantInfo(assistantID2, "m-parent", aborted),
- parts: [
- {
- ...basePart(assistantID2, "b1"),
- type: "step-start",
- },
- {
- ...basePart(assistantID2, "b2"),
- type: "reasoning",
- text: "thinking",
- time: { start: 0 },
- },
- ] as SessionV1.Part[],
- },
- ]
- expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
- {
- role: "assistant",
- content: [
- { type: "reasoning", text: "thinking", providerOptions: undefined },
- { type: "text", text: "partial answer" },
- ],
- },
- ])
- })
- test("preserves OpenRouter reasoning details through provider transform", async () => {
- const assistantID = "m-assistant"
- const openrouterModel: Provider.Model = {
- ...model,
- id: ModelV2.ID.make("deepseek/deepseek-v4-pro"),
- providerID: ProviderV2.ID.make("openrouter"),
- api: {
- id: "deepseek/deepseek-v4-pro",
- url: "https://openrouter.ai/api/v1",
- npm: "@openrouter/ai-sdk-provider",
- },
- capabilities: {
- ...model.capabilities,
- reasoning: true,
- interleaved: { field: "reasoning_details" },
- },
- }
- const reasoningDetails = [
- {
- type: "reasoning.text",
- text: "thinking",
- format: "unknown",
- index: 0,
- },
- ]
- const input: SessionV1.WithParts[] = [
- {
- info: assistantInfo(assistantID, "m-parent", undefined, {
- providerID: openrouterModel.providerID,
- modelID: openrouterModel.id,
- }),
- parts: [
- {
- ...basePart(assistantID, "a1"),
- type: "reasoning",
- text: "thinking",
- time: { start: 0 },
- metadata: {
- openrouter: {
- reasoning_details: reasoningDetails,
- },
- },
- },
- {
- ...basePart(assistantID, "a2"),
- type: "text",
- text: "answer",
- },
- ] as SessionV1.Part[],
- },
- ]
- expect(
- ProviderTransform.message(await MessageV2.toModelMessages(input, openrouterModel), openrouterModel, {}),
- ).toStrictEqual([
- {
- role: "assistant",
- content: [
- {
- type: "reasoning",
- text: "thinking",
- providerOptions: {
- openrouter: {
- reasoning_details: reasoningDetails,
- },
- },
- },
- { type: "text", text: "answer" },
- ],
- },
- ])
- })
- test("splits assistant messages on step-start boundaries", async () => {
- const assistantID = "m-assistant"
- const input: SessionV1.WithParts[] = [
- {
- info: assistantInfo(assistantID, "m-parent"),
- parts: [
- {
- ...basePart(assistantID, "p1"),
- type: "text",
- text: "first",
- },
- {
- ...basePart(assistantID, "p2"),
- type: "step-start",
- },
- {
- ...basePart(assistantID, "p3"),
- type: "text",
- text: "second",
- },
- ] as SessionV1.Part[],
- },
- ]
- expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
- {
- role: "assistant",
- content: [{ type: "text", text: "first" }],
- },
- {
- role: "assistant",
- content: [{ type: "text", text: "second" }],
- },
- ])
- })
- test("drops messages that only contain step-start parts", async () => {
- const assistantID = "m-assistant"
- const input: SessionV1.WithParts[] = [
- {
- info: assistantInfo(assistantID, "m-parent"),
- parts: [
- {
- ...basePart(assistantID, "p1"),
- type: "step-start",
- },
- ] as SessionV1.Part[],
- },
- ]
- expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([])
- })
- test("converts pending/running tool calls to error results to prevent dangling tool_use", async () => {
- const userID = "m-user"
- const assistantID = "m-assistant"
- const input: SessionV1.WithParts[] = [
- {
- info: userInfo(userID),
- parts: [
- {
- ...basePart(userID, "u1"),
- type: "text",
- text: "run tool",
- },
- ] as SessionV1.Part[],
- },
- {
- info: assistantInfo(assistantID, userID),
- parts: [
- {
- ...basePart(assistantID, "a1"),
- type: "tool",
- callID: "call-pending",
- tool: "bash",
- state: {
- status: "pending",
- input: { cmd: "ls" },
- raw: "",
- },
- },
- {
- ...basePart(assistantID, "a2"),
- type: "tool",
- callID: "call-running",
- tool: "read",
- state: {
- status: "running",
- input: { path: "/tmp" },
- time: { start: 0 },
- },
- },
- ] as SessionV1.Part[],
- },
- ]
- const result = await MessageV2.toModelMessages(input, model)
- expect(result).toStrictEqual([
- {
- role: "user",
- content: [{ type: "text", text: "run tool" }],
- },
- {
- role: "assistant",
- content: [
- {
- type: "tool-call",
- toolCallId: "call-pending",
- toolName: "bash",
- input: { cmd: "ls" },
- providerExecuted: undefined,
- },
- {
- type: "tool-call",
- toolCallId: "call-running",
- toolName: "read",
- input: { path: "/tmp" },
- providerExecuted: undefined,
- },
- ],
- },
- {
- role: "tool",
- content: [
- {
- type: "tool-result",
- toolCallId: "call-pending",
- toolName: "bash",
- output: { type: "error-text", value: "[Tool execution was interrupted]" },
- },
- {
- type: "tool-result",
- toolCallId: "call-running",
- toolName: "read",
- output: { type: "error-text", value: "[Tool execution was interrupted]" },
- },
- ],
- },
- ])
- })
- test("substitutes space for empty text between signed reasoning blocks", async () => {
- // Reproduces the bug pattern: [reasoning(sig), text(""), reasoning(sig), text(full)]
- const assistantID = "m-assistant"
- const input: SessionV1.WithParts[] = [
- {
- info: assistantInfo(assistantID, "m-parent"),
- parts: [
- { ...basePart(assistantID, "p1"), type: "step-start" },
- {
- ...basePart(assistantID, "p2"),
- type: "reasoning",
- text: "thinking-one",
- metadata: { anthropic: { signature: "sig1" } },
- },
- { ...basePart(assistantID, "p3"), type: "text", text: "" },
- { ...basePart(assistantID, "p4"), type: "step-start" },
- {
- ...basePart(assistantID, "p5"),
- type: "reasoning",
- text: "thinking-two",
- metadata: { anthropic: { signature: "sig2" } },
- },
- { ...basePart(assistantID, "p6"), type: "text", text: "the answer" },
- ] as SessionV1.Part[],
- },
- ]
- const result = await MessageV2.toModelMessages(input, model)
- // step-start splits into two assistant messages; SDK's groupIntoBlocks merges them later
- expect(result).toHaveLength(2)
- expect((result[0].content as any[]).find((p) => p.type === "text").text).toBe(" ")
- expect((result[1].content as any[]).find((p) => p.type === "text").text).toBe("the answer")
- })
- test("leaves empty text alone when reasoning signature is under 'bedrock' namespace", async () => {
- // Bedrock signed reasoning is preserved as reasoning metadata, but unlike the
- // direct Anthropic path we do not preserve empty text separators for Bedrock.
- const assistantID = "m-assistant-bedrock"
- const input: SessionV1.WithParts[] = [
- {
- info: assistantInfo(assistantID, "m-parent"),
- parts: [
- {
- ...basePart(assistantID, "p1"),
- type: "reasoning",
- text: "thinking-bedrock",
- metadata: { bedrock: { signature: "bedrock-sig" } },
- },
- { ...basePart(assistantID, "p2"), type: "text", text: "" },
- { ...basePart(assistantID, "p3"), type: "text", text: "answer" },
- ] as SessionV1.Part[],
- },
- ]
- const result = await MessageV2.toModelMessages(input, model)
- expect(result).toHaveLength(1)
- const texts = (result[0].content as any[]).filter((p) => p.type === "text")
- expect(texts.map((t) => t.text)).toStrictEqual(["", "answer"])
- })
- test("leaves empty text alone when reasoning has no Anthropic signature", async () => {
- // Non-Anthropic providers' reasoning doesn't position-validate, so empty text
- // should be filtered normally rather than substituted.
- const assistantID = "m-assistant-unsigned"
- const input: SessionV1.WithParts[] = [
- {
- info: assistantInfo(assistantID, "m-parent"),
- parts: [
- { ...basePart(assistantID, "p1"), type: "reasoning", text: "thinking" },
- { ...basePart(assistantID, "p2"), type: "text", text: "" },
- { ...basePart(assistantID, "p3"), type: "text", text: "answer" },
- ] as SessionV1.Part[],
- },
- ]
- const result = await MessageV2.toModelMessages(input, model)
- expect(result).toHaveLength(1)
- const texts = (result[0].content as any[]).filter((p) => p.type === "text")
- expect(texts.map((t) => t.text)).toStrictEqual(["", "answer"])
- })
- test("leaves empty text alone in assistant messages without reasoning", async () => {
- const assistantID = "m-assistant-no-reasoning"
- const input: SessionV1.WithParts[] = [
- {
- info: assistantInfo(assistantID, "m-parent"),
- parts: [
- { ...basePart(assistantID, "p1"), type: "text", text: "" },
- { ...basePart(assistantID, "p2"), type: "text", text: "hello" },
- ] as SessionV1.Part[],
- },
- ]
- const result = await MessageV2.toModelMessages(input, model)
- expect(result).toHaveLength(1)
- const texts = (result[0].content as any[]).filter((p) => p.type === "text")
- expect(texts.map((t) => t.text)).toStrictEqual(["", "hello"])
- })
- })
- describe("session.message-v2.fromError", () => {
- test("serializes context_length_exceeded as ContextOverflowError", () => {
- const input = {
- type: "error",
- error: {
- code: "context_length_exceeded",
- },
- }
- const result = MessageV2.fromError(input, { providerID })
- expect(result).toStrictEqual({
- name: "ContextOverflowError",
- data: {
- message: "Input exceeds context window of this model",
- responseBody: JSON.stringify(input),
- },
- })
- })
- test("serializes response error codes", () => {
- const cases = [
- {
- code: "insufficient_quota",
- message: "Quota exceeded. Check your plan and billing details.",
- },
- {
- code: "usage_not_included",
- message: "To use Codex with your ChatGPT plan, upgrade to Plus: https://chatgpt.com/explore/plus.",
- },
- {
- code: "invalid_prompt",
- message: "Invalid prompt from test",
- },
- ]
- cases.forEach((item) => {
- const input = {
- type: "error",
- error: {
- code: item.code,
- message: item.code === "invalid_prompt" ? item.message : undefined,
- },
- }
- const result = MessageV2.fromError(input, { providerID })
- expect(result).toStrictEqual({
- name: "APIError",
- data: {
- message: item.message,
- isRetryable: false,
- responseBody: JSON.stringify(input),
- },
- })
- })
- })
- test("serializes OpenAI response server_error stream chunks as retryable APIError", () => {
- const body = {
- type: "error",
- sequence_number: 2,
- error: {
- type: "server_error",
- code: "server_error",
- message:
- "An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID req_77eccd008d984bf6bf82d1b2c2b68715 in your message.",
- param: null,
- },
- }
- const result = MessageV2.fromError({ message: JSON.stringify(body) }, { providerID })
- expect(result).toStrictEqual({
- name: "APIError",
- data: {
- message: body.error.message,
- isRetryable: true,
- responseBody: JSON.stringify(body),
- },
- })
- })
- test("detects context overflow from APICallError provider messages", () => {
- const cases = [
- "prompt is too long: 213462 tokens > 200000 maximum",
- "Your input exceeds the context window of this model",
- "The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)",
- "tokens in request more than max tokens allowed",
- "Please reduce the length of the messages or completion",
- "400 status code (no body)",
- "413 status code (no body)",
- ]
- cases.forEach((message) => {
- const error = new APICallError({
- message,
- url: "https://example.com",
- requestBodyValues: {},
- statusCode: 400,
- responseHeaders: { "content-type": "application/json" },
- isRetryable: false,
- })
- const result = MessageV2.fromError(error, { providerID })
- expect(SessionV1.ContextOverflowError.isInstance(result)).toBe(true)
- })
- })
- test("detects context overflow from context_length_exceeded code in response body", () => {
- const error = new APICallError({
- message: "Request failed",
- url: "https://example.com",
- requestBodyValues: {},
- statusCode: 422,
- responseHeaders: { "content-type": "application/json" },
- responseBody: JSON.stringify({
- error: {
- message: "Some message",
- type: "invalid_request_error",
- code: "context_length_exceeded",
- },
- }),
- isRetryable: false,
- })
- const result = MessageV2.fromError(error, { providerID })
- expect(SessionV1.ContextOverflowError.isInstance(result)).toBe(true)
- })
- test("does not classify 429 no body as context overflow", () => {
- const result = MessageV2.fromError(
- new APICallError({
- message: "429 status code (no body)",
- url: "https://example.com",
- requestBodyValues: {},
- statusCode: 429,
- responseHeaders: { "content-type": "application/json" },
- isRetryable: false,
- }),
- { providerID },
- )
- expect(SessionV1.ContextOverflowError.isInstance(result)).toBe(false)
- expect(SessionV1.APIError.isInstance(result)).toBe(true)
- })
- test("serializes unknown inputs", () => {
- const result = MessageV2.fromError(123, { providerID })
- expect(result).toStrictEqual({
- name: "UnknownError",
- data: {
- message: "123",
- },
- })
- })
- test("serializes tagged errors with their message", () => {
- const result = MessageV2.fromError(new Question.RejectedError(), { providerID })
- expect(result).toStrictEqual({
- name: "UnknownError",
- data: {
- message: "The user dismissed this question",
- },
- })
- })
- test("classifies ZlibError from fetch as retryable APIError", () => {
- const zlibError = new Error(
- 'ZlibError fetching "https://opencode.cloudflare.dev/anthropic/messages". For more information, pass `verbose: true` in the second argument to fetch()',
- )
- ;(zlibError as any).code = "ZlibError"
- ;(zlibError as any).errno = 0
- ;(zlibError as any).path = ""
- const result = MessageV2.fromError(zlibError, { providerID })
- expect(SessionV1.APIError.isInstance(result)).toBe(true)
- expect((result as SessionV1.APIError).data.isRetryable).toBe(true)
- expect((result as SessionV1.APIError).data.message).toInclude("decompression")
- })
- test("classifies ZlibError as AbortedError when abort context is provided", () => {
- const zlibError = new Error(
- 'ZlibError fetching "https://opencode.cloudflare.dev/anthropic/messages". For more information, pass `verbose: true` in the second argument to fetch()',
- )
- ;(zlibError as any).code = "ZlibError"
- ;(zlibError as any).errno = 0
- const result = MessageV2.fromError(zlibError, { providerID, aborted: true })
- expect(result.name).toBe("MessageAbortedError")
- })
- })
- describe("session.message-v2.latest", () => {
- const TAIL_USER = MessageID.make("msg_001")
- const OVERFLOW_ASSISTANT = MessageID.make("msg_002")
- const COMPACTION_USER = MessageID.make("msg_003")
- const SUMMARY_ASSISTANT = MessageID.make("msg_004")
- const CONTINUE_USER = MessageID.make("msg_005")
- const NEW_COMPACTION_USER = MessageID.make("msg_006")
- const tailUser: SessionV1.WithParts = {
- info: userInfo(TAIL_USER),
- parts: [{ ...basePart(TAIL_USER, "p1"), type: "text", text: "original prompt" }] as SessionV1.Part[],
- }
- const overflowAssistant: SessionV1.WithParts = {
- info: {
- ...assistantInfo(OVERFLOW_ASSISTANT, TAIL_USER),
- finish: "tool-calls",
- tokens: { input: 280_000, output: 200, reasoning: 0, cache: { read: 0, write: 0 }, total: 280_200 },
- } as SessionV1.Assistant,
- parts: [],
- }
- const compactionUser: SessionV1.WithParts = {
- info: userInfo(COMPACTION_USER),
- parts: [
- {
- ...basePart(COMPACTION_USER, "p1"),
- type: "compaction",
- auto: true,
- tail_start_id: TAIL_USER,
- },
- ] as SessionV1.Part[],
- }
- const summaryAssistant: SessionV1.WithParts = {
- info: {
- ...assistantInfo(SUMMARY_ASSISTANT, COMPACTION_USER),
- summary: true,
- finish: "stop",
- tokens: { input: 150_000, output: 1_500, reasoning: 0, cache: { read: 0, write: 0 }, total: 151_500 },
- } as SessionV1.Assistant,
- parts: [],
- }
- const continueUser: SessionV1.WithParts = {
- info: userInfo(CONTINUE_USER),
- parts: [
- {
- ...basePart(CONTINUE_USER, "p1"),
- type: "text",
- text: "Continue if you have next steps...",
- synthetic: true,
- metadata: { compaction_continue: true },
- },
- ] as SessionV1.Part[],
- }
- // Regression for double auto-compaction. The reorder in filterCompacted
- // (#27145) returns [compaction-user, summary, ...tail..., continue-user],
- // so picking lastFinished by array position landed on the pre-compaction
- // overflow assistant and bypassed the `summary !== true` overflow guard
- // in SessionPrompt.runLoop, firing a second compaction.create immediately.
- test("finished is the chronologically-latest finished assistant, not the array-latest", () => {
- const filtered = MessageV2.filterCompacted([
- continueUser,
- summaryAssistant,
- compactionUser,
- overflowAssistant,
- tailUser,
- ])
- const state = MessageV2.latest(filtered)
- expect(state.finished?.id).toBe(SUMMARY_ASSISTANT)
- expect(state.finished?.summary).toBe(true)
- expect(state.user?.id).toBe(CONTINUE_USER)
- expect(state.tasks).toEqual([])
- })
- test("a fresh compaction-user newer than the latest summary surfaces in tasks", () => {
- const newCompactionUser: SessionV1.WithParts = {
- info: userInfo(NEW_COMPACTION_USER),
- parts: [
- {
- ...basePart(NEW_COMPACTION_USER, "p1"),
- type: "compaction",
- auto: true,
- },
- ] as SessionV1.Part[],
- }
- const state = MessageV2.latest([
- tailUser,
- overflowAssistant,
- compactionUser,
- summaryAssistant,
- continueUser,
- newCompactionUser,
- ])
- expect(state.finished?.id).toBe(SUMMARY_ASSISTANT)
- expect(state.user?.id).toBe(NEW_COMPACTION_USER)
- expect(state.tasks).toHaveLength(1)
- expect(state.tasks[0]).toMatchObject({ type: "compaction", auto: true })
- })
- })
|