From ecc9dbeecbaae241a00643394ff46314e8b41ce1 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Fri, 15 May 2026 10:00:06 +0100 Subject: [PATCH 1/2] fix(import): support legacy session exports --- packages/opencode/src/cli/cmd/import.ts | 76 ++++++++++++++++++++++- packages/opencode/test/cli/import.test.ts | 61 ++++++++++++++++++ 2 files changed, 134 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 2fcf286f4670..aa0c3b2be6e1 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -77,6 +77,38 @@ export function transformShareData(shareData: ShareData[]): { type ExportData = { info: SDKSession; messages: Array<{ info: Message; parts: Part[] }> } +export function normalizeMessageInfoForImport(info: Message, messages: ExportData["messages"], index: number) { + const record = toRecord(info) + const context = findMessageContext(messages, index) + + if (record.role === "user") { + return { + ...info, + agent: typeof record.agent === "string" ? record.agent : context.agent, + model: isUserModel(record.model) ? record.model : context.model, + } + } + + if (record.role === "assistant" && (typeof record.agent !== "string" || typeof record.parentID !== "string")) { + return { + ...info, + agent: + typeof record.agent === "string" ? record.agent : typeof record.mode === "string" ? record.mode : context.agent, + ...(typeof record.parentID === "string" || !context.parentID ? {} : { parentID: context.parentID }), + } + } + + return info +} + +export function normalizePartForImport(part: Part) { + const record = toRecord(part) + if (record.type === "step-finish" && typeof record.reason !== "string") { + return { ...part, reason: "stop" } + } + return part +} + export const ImportCommand = effectCmd({ command: "import ", describe: "import session data from JSON file or URL", @@ -179,8 +211,10 @@ const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectI .run(), ) - for (const msg of exportData.messages) { - const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info + for (const [index, msg] of exportData.messages.entries()) { + const msgInfo = decodeMessageInfo( + normalizeMessageInfoForImport(msg.info, exportData.messages, index), + ) as MessageV2.Info const { id, sessionID: _, ...msgData } = msgInfo Database.use((db) => db @@ -196,7 +230,7 @@ const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectI ) for (const part of msg.parts) { - const partInfo = decodePart(part) as MessageV2.Part + const partInfo = decodePart(normalizePartForImport(part)) as MessageV2.Part const { id: partId, sessionID: _s, messageID, ...partData } = partInfo Database.use((db) => db @@ -216,3 +250,39 @@ const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectI process.stdout.write(`Imported session: ${exportData.info.id}`) process.stdout.write(EOL) }) + +function findMessageContext(messages: ExportData["messages"], index: number) { + const message = messages + .slice(index + 1) + .concat(messages.slice(0, index).reverse()) + .map((item) => toRecord(item.info)) + .find((item) => item.role === "assistant") + + return { + agent: + typeof message?.agent === "string" ? message.agent : typeof message?.mode === "string" ? message.mode : "build", + model: isUserModel(message?.model) + ? message.model + : typeof message?.providerID === "string" && typeof message?.modelID === "string" + ? { + providerID: message.providerID, + modelID: message.modelID, + ...(typeof message.variant === "string" ? { variant: message.variant } : {}), + } + : undefined, + parentID: messages + .slice(0, index) + .reverse() + .map((item) => toRecord(item.info)) + .find((item) => item.role === "user" && typeof item.id === "string")?.id, + } +} + +function toRecord(value: unknown) { + return value && typeof value === "object" ? (value as Record) : {} +} + +function isUserModel(value: unknown): value is { providerID: string; modelID: string; variant?: string } { + const record = toRecord(value) + return typeof record.providerID === "string" && typeof record.modelID === "string" +} diff --git a/packages/opencode/test/cli/import.test.ts b/packages/opencode/test/cli/import.test.ts index d7c0241e6b0b..19396b138081 100644 --- a/packages/opencode/test/cli/import.test.ts +++ b/packages/opencode/test/cli/import.test.ts @@ -1,5 +1,10 @@ import { test, expect } from "bun:test" +import type { Message, Part } from "@opencode-ai/sdk/v2" +import { Schema } from "effect" +import { MessageV2 } from "@/session/message-v2" import { + normalizeMessageInfoForImport, + normalizePartForImport, parseShareUrl, shouldAttachShareAuthHeaders, transformShareData, @@ -52,3 +57,59 @@ test("returns null for invalid share data", () => { expect(transformShareData([{ type: "message", data: {} as any }])).toBeNull() expect(transformShareData([{ type: "session", data: { id: "s" } as any }])).toBeNull() // no messages }) + +test("normalizes legacy messages missing agent before decoding", () => { + const messages: Array<{ info: Message; parts: Part[] }> = [ + { + info: { + role: "user", + time: { created: 1 }, + id: "msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2M", + sessionID: "ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2K", + } as unknown as Message, + parts: [], + }, + { + info: { + role: "assistant", + time: { created: 2, completed: 3 }, + modelID: "grok-code", + providerID: "opencode", + mode: "build", + path: { cwd: "/repo", root: "/repo" }, + cost: 0, + tokens: { input: 1, output: 2, reasoning: 0, cache: { read: 0, write: 0 } }, + id: "msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2N", + sessionID: "ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2K", + } as unknown as Message, + parts: [], + }, + ] + const decode = Schema.decodeUnknownSync(MessageV2.Info) + + expect(decode(normalizeMessageInfoForImport(messages[0].info, messages, 0))).toMatchObject({ + agent: "build", + model: { providerID: "opencode", modelID: "grok-code" }, + }) + expect(decode(normalizeMessageInfoForImport(messages[1].info, messages, 1))).toMatchObject({ + agent: "build", + parentID: "msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2M", + }) +}) + +test("normalizes legacy step finish parts missing reason before decoding", () => { + const decode = Schema.decodeUnknownSync(MessageV2.Part) + + expect( + decode( + normalizePartForImport({ + type: "step-finish", + cost: 0, + tokens: { input: 1, output: 2, reasoning: 0, cache: { read: 0, write: 0 } }, + id: "prt_01J5Y5H0AH4Q4NXJ6P4C3P5V2N", + sessionID: "ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2K", + messageID: "msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2M", + } as unknown as Part), + ), + ).toMatchObject({ reason: "stop" }) +}) From 3c955cf72e9ef710683c2880461833016302f842 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Fri, 15 May 2026 10:11:52 +0100 Subject: [PATCH 2/2] fix(session): normalize legacy message reads --- packages/opencode/src/cli/cmd/import.ts | 72 +-------- packages/opencode/src/session/message-v2.ts | 125 +++++++++++++-- packages/opencode/test/cli/import.test.ts | 8 +- .../test/session/messages-pagination.test.ts | 146 +++++++++++++++++- 4 files changed, 258 insertions(+), 93 deletions(-) diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index aa0c3b2be6e1..bd1505f22c06 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -77,38 +77,6 @@ export function transformShareData(shareData: ShareData[]): { type ExportData = { info: SDKSession; messages: Array<{ info: Message; parts: Part[] }> } -export function normalizeMessageInfoForImport(info: Message, messages: ExportData["messages"], index: number) { - const record = toRecord(info) - const context = findMessageContext(messages, index) - - if (record.role === "user") { - return { - ...info, - agent: typeof record.agent === "string" ? record.agent : context.agent, - model: isUserModel(record.model) ? record.model : context.model, - } - } - - if (record.role === "assistant" && (typeof record.agent !== "string" || typeof record.parentID !== "string")) { - return { - ...info, - agent: - typeof record.agent === "string" ? record.agent : typeof record.mode === "string" ? record.mode : context.agent, - ...(typeof record.parentID === "string" || !context.parentID ? {} : { parentID: context.parentID }), - } - } - - return info -} - -export function normalizePartForImport(part: Part) { - const record = toRecord(part) - if (record.type === "step-finish" && typeof record.reason !== "string") { - return { ...part, reason: "stop" } - } - return part -} - export const ImportCommand = effectCmd({ command: "import ", describe: "import session data from JSON file or URL", @@ -213,7 +181,7 @@ const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectI for (const [index, msg] of exportData.messages.entries()) { const msgInfo = decodeMessageInfo( - normalizeMessageInfoForImport(msg.info, exportData.messages, index), + MessageV2.normalizeInfoForRead(msg.info, exportData.messages, index), ) as MessageV2.Info const { id, sessionID: _, ...msgData } = msgInfo Database.use((db) => @@ -230,7 +198,7 @@ const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectI ) for (const part of msg.parts) { - const partInfo = decodePart(normalizePartForImport(part)) as MessageV2.Part + const partInfo = decodePart(MessageV2.normalizePartForRead(part)) as MessageV2.Part const { id: partId, sessionID: _s, messageID, ...partData } = partInfo Database.use((db) => db @@ -250,39 +218,3 @@ const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectI process.stdout.write(`Imported session: ${exportData.info.id}`) process.stdout.write(EOL) }) - -function findMessageContext(messages: ExportData["messages"], index: number) { - const message = messages - .slice(index + 1) - .concat(messages.slice(0, index).reverse()) - .map((item) => toRecord(item.info)) - .find((item) => item.role === "assistant") - - return { - agent: - typeof message?.agent === "string" ? message.agent : typeof message?.mode === "string" ? message.mode : "build", - model: isUserModel(message?.model) - ? message.model - : typeof message?.providerID === "string" && typeof message?.modelID === "string" - ? { - providerID: message.providerID, - modelID: message.modelID, - ...(typeof message.variant === "string" ? { variant: message.variant } : {}), - } - : undefined, - parentID: messages - .slice(0, index) - .reverse() - .map((item) => toRecord(item.info)) - .find((item) => item.role === "user" && typeof item.id === "string")?.id, - } -} - -function toRecord(value: unknown) { - return value && typeof value === "object" ? (value as Record) : {} -} - -function isUserModel(value: unknown): value is { providerID: string; modelID: string; variant?: string } { - const record = toRecord(value) - return typeof record.providerID === "string" && typeof record.modelID === "string" -} diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index d3d6a1dfcc10..a51f4e279ca9 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -585,17 +585,49 @@ const info = (row: typeof MessageTable.$inferSelect) => }) as Info const part = (row: typeof PartTable.$inferSelect) => - ({ + normalizePartForRead({ ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id, }) as Part +export function normalizeInfoForRead(input: unknown, messages: Array<{ info: unknown }> = [], index = 0) { + const record = toRecord(input) + const context = findMessageContext(messages, index) + + if (record.role === "user") { + return { + ...record, + agent: typeof record.agent === "string" ? record.agent : context.agent, + model: isUserModel(record.model) ? record.model : context.model, + } + } + + if (record.role === "assistant" && (typeof record.agent !== "string" || typeof record.parentID !== "string")) { + return { + ...record, + agent: + typeof record.agent === "string" ? record.agent : typeof record.mode === "string" ? record.mode : context.agent, + ...(typeof record.parentID === "string" || !context.parentID ? {} : { parentID: context.parentID }), + } + } + + return input +} + +export function normalizePartForRead(input: unknown) { + const record = toRecord(input) + if (record.type === "step-finish" && typeof record.reason !== "string") { + return { ...record, reason: "stop" } + } + return input +} + const older = (row: Cursor) => or(lt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id))) -function hydrate(rows: (typeof MessageTable.$inferSelect)[]) { +function hydrate(rows: (typeof MessageTable.$inferSelect)[], contextRows?: (typeof MessageTable.$inferSelect)[]) { const ids = rows.map((row) => row.id) const partByMessage = new Map() if (ids.length > 0) { @@ -615,12 +647,53 @@ function hydrate(rows: (typeof MessageTable.$inferSelect)[]) { } } + const context = (contextRows ?? rows).map((row) => ({ row, info: info(row) })) return rows.map((row) => ({ - info: info(row), + info: normalizeInfoForRead( + info(row), + context, + context.findIndex((item) => item.row.id === row.id), + ) as Info, parts: partByMessage.get(row.id) ?? [], })) } +function findMessageContext(messages: Array<{ info: unknown }>, index: number) { + const message = messages + .slice(index + 1) + .concat(messages.slice(0, index).reverse()) + .map((item) => toRecord(item.info)) + .find((item) => item.role === "assistant") + + return { + agent: + typeof message?.agent === "string" ? message.agent : typeof message?.mode === "string" ? message.mode : "build", + model: isUserModel(message?.model) + ? message.model + : typeof message?.providerID === "string" && typeof message?.modelID === "string" + ? { + providerID: message.providerID, + modelID: message.modelID, + ...(typeof message.variant === "string" ? { variant: message.variant } : {}), + } + : undefined, + parentID: messages + .slice(0, index) + .reverse() + .map((item) => toRecord(item.info)) + .find((item) => item.role === "user" && typeof item.id === "string")?.id, + } +} + +function toRecord(value: unknown) { + return value && typeof value === "object" ? (value as Record) : {} +} + +function isUserModel(value: unknown): value is { providerID: string; modelID: string; variant?: string } { + const record = toRecord(value) + return typeof record.providerID === "string" && typeof record.modelID === "string" +} + function providerMeta(metadata: Record | undefined) { if (!metadata) return undefined const { providerExecuted: _, ...rest } = metadata @@ -950,7 +1023,7 @@ export const page = Effect.fn("MessageV2.page")(function* (input: { const more = rows.length > input.limit const slice = more ? rows.slice(0, input.limit) : rows - const items = hydrate(slice) + const items = hydrate(slice, needsMessageContext(slice) ? messageContextRows(input.sessionID) : undefined) items.reverse() const tail = slice.at(-1) return { @@ -984,32 +1057,50 @@ export function parts(message_id: MessageID) { const rows = Database.use((db) => db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(), ) - return rows.map( - (row) => - ({ - ...row.data, - id: row.id, - sessionID: row.session_id, - messageID: row.message_id, - }) as Part, - ) + return rows.map(part) } export const get = Effect.fn("MessageV2.get")(function* (input: { sessionID: SessionID; messageID: MessageID }) { - const row = Database.use((db) => + const rows = Database.use((db) => db .select() .from(MessageTable) - .where(and(eq(MessageTable.id, input.messageID), eq(MessageTable.session_id, input.sessionID))) - .get(), + .where(eq(MessageTable.session_id, input.sessionID)) + .orderBy(MessageTable.time_created, MessageTable.id) + .all(), ) + const row = rows.find((item) => item.id === input.messageID) if (!row) return yield* new NotFoundError({ message: `Message not found: ${input.messageID}` }) return { - info: info(row), + info: normalizeInfoForRead( + info(row), + rows.map((item) => ({ info: info(item) })), + rows.findIndex((item) => item.id === row.id), + ) as Info, parts: parts(input.messageID), } }) +function needsMessageContext(rows: (typeof MessageTable.$inferSelect)[]) { + return rows.some((row) => { + const data = toRecord(row.data) + if (data.role === "user") return typeof data.agent !== "string" || !isUserModel(data.model) + if (data.role === "assistant") return typeof data.agent !== "string" || typeof data.parentID !== "string" + return false + }) +} + +function messageContextRows(sessionID: SessionID) { + return Database.use((db) => + db + .select() + .from(MessageTable) + .where(eq(MessageTable.session_id, sessionID)) + .orderBy(MessageTable.time_created, MessageTable.id) + .all(), + ) +} + export function filterCompacted(msgs: Iterable) { const result = [] as WithParts[] const completed = new Set() diff --git a/packages/opencode/test/cli/import.test.ts b/packages/opencode/test/cli/import.test.ts index 19396b138081..6523b3ca5a3b 100644 --- a/packages/opencode/test/cli/import.test.ts +++ b/packages/opencode/test/cli/import.test.ts @@ -3,8 +3,6 @@ import type { Message, Part } from "@opencode-ai/sdk/v2" import { Schema } from "effect" import { MessageV2 } from "@/session/message-v2" import { - normalizeMessageInfoForImport, - normalizePartForImport, parseShareUrl, shouldAttachShareAuthHeaders, transformShareData, @@ -87,11 +85,11 @@ test("normalizes legacy messages missing agent before decoding", () => { ] const decode = Schema.decodeUnknownSync(MessageV2.Info) - expect(decode(normalizeMessageInfoForImport(messages[0].info, messages, 0))).toMatchObject({ + expect(decode(MessageV2.normalizeInfoForRead(messages[0].info, messages, 0))).toMatchObject({ agent: "build", model: { providerID: "opencode", modelID: "grok-code" }, }) - expect(decode(normalizeMessageInfoForImport(messages[1].info, messages, 1))).toMatchObject({ + expect(decode(MessageV2.normalizeInfoForRead(messages[1].info, messages, 1))).toMatchObject({ agent: "build", parentID: "msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2M", }) @@ -102,7 +100,7 @@ test("normalizes legacy step finish parts missing reason before decoding", () => expect( decode( - normalizePartForImport({ + MessageV2.normalizePartForRead({ type: "step-finish", cost: 0, tokens: { input: 1, output: 2, reasoning: 0, cache: { read: 0, write: 0 } }, diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index e558d07b500f..5720df2110d1 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { Effect, Option } from "effect" +import { Effect, Option, Schema } from "effect" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" @@ -7,6 +7,8 @@ import { ModelID, ProviderID } from "../../src/provider/schema" import { NotFoundError } from "@/storage/storage" import * as Log from "@opencode-ai/core/util/log" import { testEffect } from "../lib/effect" +import * as Database from "../../src/storage/db" +import { MessageTable, PartTable } from "../../src/session/session.sql" void Log.init({ print: false }) @@ -109,6 +111,65 @@ const addAssistant = Effect.fn("Test.addAssistant")(function* ( return id }) +const addLegacyTurn = Effect.fn("Test.addLegacyTurn")(function* (sessionID: SessionID) { + const userID = MessageID.ascending() + const assistantID = MessageID.ascending() + const partID = PartID.ascending() + + Database.use((db) => + db + .insert(MessageTable) + .values([ + { + id: userID, + session_id: sessionID, + time_created: 1, + time_updated: 1, + data: { + role: "user", + time: { created: 1 }, + } as any, + }, + { + id: assistantID, + session_id: sessionID, + time_created: 2, + time_updated: 2, + data: { + role: "assistant", + time: { created: 2, completed: 3 }, + modelID: "grok-code", + providerID: "opencode", + mode: "build", + path: { cwd: "/repo", root: "/repo" }, + cost: 0, + tokens: { input: 1, output: 2, reasoning: 0, cache: { read: 0, write: 0 } }, + } as any, + }, + ]) + .run(), + ) + Database.use((db) => + db + .insert(PartTable) + .values({ + id: partID, + session_id: sessionID, + message_id: assistantID, + time_created: 2, + time_updated: 2, + data: { + type: "step-finish", + cost: 0, + tokens: { input: 1, output: 2, reasoning: 0, cache: { read: 0, write: 0 } }, + } as any, + }) + .run(), + ) + + return { userID, assistantID, partID } +}) + const addCompactionPart = Effect.fn("Test.addCompactionPart")(function* ( sessionID: SessionID, messageID: MessageID, @@ -302,6 +363,38 @@ describe("MessageV2.page", () => { }), ), ) + + it.instance("normalizes legacy rows before returning page items", () => + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* addLegacyTurn(sessionID) + const result = yield* MessageV2.page({ sessionID, limit: 10 }) + const decode = Schema.decodeUnknownSync(MessageV2.WithParts) + + expect(result.items.map((item) => item.info.id)).toEqual([ids.userID, ids.assistantID]) + result.items.forEach((item) => decode(item)) + expect(result.items[0].info).toMatchObject({ + agent: "build", + model: { providerID: "opencode", modelID: "grok-code" }, + }) + expect(result.items[1].info).toMatchObject({ agent: "build", parentID: ids.userID }) + expect(result.items[1].parts[0]).toMatchObject({ type: "step-finish", reason: "stop" }) + }), + ), + ) + + it.instance("uses full session context when normalizing a paged legacy item", () => + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* addLegacyTurn(sessionID) + const result = yield* MessageV2.page({ sessionID, limit: 1 }) + Schema.decodeUnknownSync(MessageV2.WithParts)(result.items[0]) + + expect(result.items).toHaveLength(1) + expect(result.items[0].info).toMatchObject({ id: ids.assistantID, agent: "build", parentID: ids.userID }) + }), + ), + ) }) describe("MessageV2.stream", () => { @@ -453,6 +546,18 @@ describe("MessageV2.parts", () => { }), ), ) + + it.instance("normalizes legacy step-finish parts", () => + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* addLegacyTurn(sessionID) + const parts = MessageV2.parts(ids.assistantID) + + Schema.decodeUnknownSync(MessageV2.Part)(parts[0]) + expect(parts[0]).toMatchObject({ id: ids.partID, type: "step-finish", reason: "stop" }) + }), + ), + ) }) describe("MessageV2.get", () => { @@ -552,6 +657,26 @@ describe("MessageV2.get", () => { }), ), ) + + it.instance("normalizes legacy rows before returning a single message", () => + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* addLegacyTurn(sessionID) + const user = yield* MessageV2.get({ sessionID, messageID: ids.userID }) + const assistant = yield* MessageV2.get({ sessionID, messageID: ids.assistantID }) + const decode = Schema.decodeUnknownSync(MessageV2.WithParts) + + decode(user) + decode(assistant) + expect(user.info).toMatchObject({ + agent: "build", + model: { providerID: "opencode", modelID: "grok-code" }, + }) + expect(assistant.info).toMatchObject({ agent: "build", parentID: ids.userID }) + expect(assistant.parts[0]).toMatchObject({ type: "step-finish", reason: "stop" }) + }), + ), + ) }) describe("Session.messages", () => { @@ -574,6 +699,25 @@ describe("Session.messages", () => { expect(error.message).toBe(`Session not found: ${fake}`) }), ) + + it.instance("normalizes legacy rows before returning all session messages", () => + withSession(({ session, sessionID }) => + Effect.gen(function* () { + const ids = yield* addLegacyTurn(sessionID) + const result = yield* session.messages({ sessionID }) + const decode = Schema.decodeUnknownSync(MessageV2.WithParts) + + result.forEach((item) => decode(item)) + expect(result.map((item) => item.info.id)).toEqual([ids.userID, ids.assistantID]) + expect(result[0].info).toMatchObject({ + agent: "build", + model: { providerID: "opencode", modelID: "grok-code" }, + }) + expect(result[1].info).toMatchObject({ agent: "build", parentID: ids.userID }) + expect(result[1].parts[0]).toMatchObject({ type: "step-finish", reason: "stop" }) + }), + ), + ) }) describe("Session.findMessage", () => {