diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 68157f5f4d89..09bbe266bbc8 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -18,6 +18,7 @@ import * as ProviderError from "@/provider/error" import { iife } from "@/util/iife" import { errorMessage } from "@/util/error" import { isMedia } from "@/util/media" +import { isRecord } from "@/util/record" import type { SystemError } from "bun" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" @@ -577,12 +578,30 @@ export const cursor = { }, } -const info = (row: typeof MessageTable.$inferSelect) => - ({ +function infoModel(input: unknown): User["model"] | undefined { + if (!isRecord(input) || typeof input.providerID !== "string") return + const modelID = typeof input.modelID === "string" ? input.modelID : typeof input.id === "string" ? input.id : undefined + if (!modelID) return + return { + providerID: ProviderID.make(input.providerID), + modelID: ModelID.make(modelID), + ...(typeof input.variant === "string" ? { variant: input.variant } : {}), + } +} + +const legacyModel = (input: unknown, fallback?: User["model"]) => + infoModel(input) ?? fallback ?? { providerID: ProviderID.make("unknown"), modelID: ModelID.make("unknown") } + +const info = (row: typeof MessageTable.$inferSelect, fallbackModel?: User["model"]) => { + const data = row.data as typeof row.data & { agent?: unknown; mode?: unknown; model?: unknown } + return { ...row.data, id: row.id, sessionID: row.session_id, - }) as Info + ...(typeof data.agent === "string" ? {} : { agent: typeof data.mode === "string" ? data.mode : "build" }), + ...(data.role === "user" ? { model: legacyModel(data.model, fallbackModel) } : {}), + } as Info +} const part = (row: typeof PartTable.$inferSelect) => ({ @@ -598,6 +617,20 @@ const older = (row: Cursor) => function hydrate(rows: (typeof MessageTable.$inferSelect)[]) { const ids = rows.map((row) => row.id) const partByMessage = new Map() + const modelByParent = new Map() + for (const row of rows) { + const data = row.data as typeof row.data & { + modelID?: unknown + parentID?: unknown + providerID?: unknown + variant?: unknown + } + if (data.role !== "assistant" || typeof data.parentID !== "string") continue + modelByParent.set( + data.parentID, + legacyModel({ providerID: data.providerID, modelID: data.modelID, variant: data.variant }), + ) + } if (ids.length > 0) { const partRows = Database.use((db) => db @@ -616,7 +649,7 @@ function hydrate(rows: (typeof MessageTable.$inferSelect)[]) { } return rows.map((row) => ({ - info: info(row), + info: info(row, modelByParent.get(row.id)), parts: partByMessage.get(row.id) ?? [], })) } diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index db06a7a77c73..cdaad7898517 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -20,7 +20,7 @@ import { Session } from "@/session/session" import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema" import { MessageV2 } from "../../src/session/message-v2" import { Database } from "@/storage/db" -import { SessionMessageTable, SessionTable } from "@/session/session.sql" +import { MessageTable, SessionMessageTable, SessionTable } from "@/session/session.sql" import { SessionMessage } from "@opencode-ai/core/session-message" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -161,6 +161,59 @@ const insertCorruptV2Message = (sessionID: SessionIDType, time = 1) => ), ) +const insertLegacyMessageV2Assistant = (sessionID: SessionIDType, parentID: MessageID) => + Effect.sync(() => { + Database.use((db) => + db + .insert(MessageTable) + .values({ + id: MessageID.ascending(), + session_id: sessionID, + time_created: 1, + time_updated: 1, + data: { + role: "assistant", + time: { created: 1, completed: 1 }, + parentID, + modelID: ModelID.make("test"), + providerID: ProviderID.make("test"), + mode: "build", + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + } as NonNullable<(typeof MessageTable.$inferInsert)["data"]>, + }) + .run(), + ) + }) + +const insertLegacyMessageV2User = (sessionID: SessionIDType) => + Effect.sync(() => { + const id = MessageID.ascending() + Database.use((db) => + db + .insert(MessageTable) + .values({ + id, + session_id: sessionID, + time_created: 1, + time_updated: 1, + data: { + role: "user", + time: { created: 1 }, + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + } as NonNullable<(typeof MessageTable.$inferInsert)["data"]>, + }) + .run(), + ) + return id + }) + const setLegacySummaryDiff = (sessionID: SessionIDType) => Effect.sync(() => Database.use((db) => @@ -611,6 +664,35 @@ describe("session HttpApi", () => { { git: true, config: { formatter: false, lsp: false } }, ) + it.instance( + "serves legacy messages missing agent", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* createSession({ title: "legacy messages" }) + const userID = yield* insertLegacyMessageV2User(session.id) + yield* insertLegacyMessageV2Assistant(session.id, userID) + + const headers = { "x-opencode-directory": test.directory } + const response = yield* request(`${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=80`, { + headers, + }) + const messages = yield* json(response) + + expect(response.status).toBe(200) + expect(messages.find((item) => item.info.role === "user")?.info).toMatchObject({ + role: "user", + agent: "build", + model: { providerID: "test", modelID: "test" }, + }) + expect(messages.find((item) => item.info.role === "assistant")?.info).toMatchObject({ + role: "assistant", + agent: "build", + }) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + it.instance( "serves lifecycle mutation routes", () =>