diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 862e06fc214e..8a45482e346a 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -34,6 +34,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 { Effect, Schema } from "effect" @@ -89,12 +90,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: ProviderV2.ID.make(input.providerID), + modelID: ProviderV2.ModelID.make(modelID), + ...(typeof input.variant === "string" ? { variant: input.variant } : {}), + } +} + +const legacyModel = (input: unknown, fallback?: User["model"]) => + infoModel(input) ?? fallback ?? { providerID: ProviderV2.ID.make("unknown"), modelID: ProviderV2.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) => ({ @@ -110,6 +129,20 @@ const older = (row: Cursor) => function hydrate(db: Database.Interface["db"], 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 }), + ) + } return Effect.gen(function* () { if (ids.length > 0) { const partRows = yield* db @@ -128,7 +161,7 @@ function hydrate(db: Database.Interface["db"], rows: (typeof MessageTable.$infer } 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 7eac7d4f35c3..8410ba84f509 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -22,9 +22,8 @@ import * as HttpSessionError from "../../src/server/routes/instance/httpapi/hand import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" 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 "@opencode-ai/core/database/database" -import { SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" +import { MessageTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { SessionMessage } from "@opencode-ai/core/session/message" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -182,6 +181,59 @@ const insertCorruptV2Message = (sessionID: SessionIDType, time = 1) => .pipe(Effect.orDie) }) +const insertLegacyMessageV2Assistant = (sessionID: SessionIDType, parentID: MessageID) => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* 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: ProviderV2.ModelID.make("test"), + providerID: ProviderV2.ID.make("test"), + mode: "build", + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + } as unknown as NonNullable<(typeof MessageTable.$inferInsert)["data"]>, + }) + .run() + .pipe(Effect.orDie) + }) + +const insertLegacyMessageV2User = (sessionID: SessionIDType) => + Effect.gen(function* () { + const id = MessageID.ascending() + const { db } = yield* Database.Service + yield* db + .insert(MessageTable) + .values({ + id, + session_id: sessionID, + time_created: 1, + time_updated: 1, + data: { + role: "user", + time: { created: 1 }, + model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") }, + } as unknown as NonNullable<(typeof MessageTable.$inferInsert)["data"]>, + }) + .run() + .pipe(Effect.orDie) + return id + }) + const setLegacySummaryDiff = (sessionID: SessionIDType) => Effect.gen(function* () { const { db } = yield* Database.Service @@ -635,6 +687,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", () =>