From 3e4afdf7a64bb28e59d882f8f6109c2f1b19b32f Mon Sep 17 00:00:00 2001 From: Eric-Guo Date: Wed, 13 May 2026 21:23:36 +0800 Subject: [PATCH 1/4] Restore legacy assistant agent fallback --- packages/opencode/src/session/message-v2.ts | 9 ++- .../test/server/httpapi-session.test.ts | 57 ++++++++++++++++++- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 862e06fc214e..f588c326dab1 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -89,12 +89,15 @@ export const cursor = { }, } -const info = (row: typeof MessageTable.$inferSelect) => - ({ +const info = (row: typeof MessageTable.$inferSelect) => { + const data = row.data as typeof row.data & { agent?: string; mode?: string } + return { ...row.data, id: row.id, sessionID: row.session_id, - }) as Info + ...(data.role === "assistant" && data.agent === undefined ? { agent: data.mode ?? "build" } : {}), + } as Info +} const part = (row: typeof PartTable.$inferSelect) => ({ diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 7eac7d4f35c3..472234bb128e 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -24,7 +24,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 "@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 +182,37 @@ const insertCorruptV2Message = (sessionID: SessionIDType, time = 1) => .pipe(Effect.orDie) }) +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 setLegacySummaryDiff = (sessionID: SessionIDType) => Effect.gen(function* () { const { db } = yield* Database.Service @@ -635,6 +666,30 @@ describe("session HttpApi", () => { { git: true, config: { formatter: false, lsp: false } }, ) + it.instance( + "serves legacy assistant messages missing agent", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* createSession({ title: "legacy assistant" }) + const message = yield* createTextMessage(session.id, "hello") + yield* insertLegacyMessageV2Assistant(session.id, message.info.id) + + const response = yield* request(`${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=80`, { + headers: { "x-opencode-directory": test.directory }, + }) + + expect(response.status).toBe(200) + expect( + (yield* json(response)).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", () => From eb669360a1af10aaa11e2b02cb6c1ede40cc5bf9 Mon Sep 17 00:00:00 2001 From: Eric-Guo Date: Wed, 13 May 2026 21:28:44 +0800 Subject: [PATCH 2/4] Handle legacy messages missing agent --- packages/opencode/src/session/message-v2.ts | 4 +- .../test/server/httpapi-session.test.ts | 51 +++++++++++++++---- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index f588c326dab1..0252589b3341 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -90,12 +90,12 @@ export const cursor = { } const info = (row: typeof MessageTable.$inferSelect) => { - const data = row.data as typeof row.data & { agent?: string; mode?: string } + const data = row.data as typeof row.data & { agent?: unknown; mode?: unknown } return { ...row.data, id: row.id, sessionID: row.session_id, - ...(data.role === "assistant" && data.agent === undefined ? { agent: data.mode ?? "build" } : {}), + ...(typeof data.agent === "string" ? {} : { agent: typeof data.mode === "string" ? data.mode : "build" }), } as Info } diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 472234bb128e..81ebc0ad01e9 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -213,6 +213,26 @@ const insertLegacyMessageV2Assistant = (sessionID: SessionIDType, parentID: Mess ) }) +const insertLegacyMessageV2User = (sessionID: SessionIDType) => + Effect.sync(() => { + Database.use((db) => + db + .insert(MessageTable) + .values({ + id: MessageID.ascending(), + 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(), + ) + }) + const setLegacySummaryDiff = (sessionID: SessionIDType) => Effect.gen(function* () { const { db } = yield* Database.Service @@ -667,21 +687,34 @@ describe("session HttpApi", () => { ) it.instance( - "serves legacy assistant messages missing agent", + "serves legacy messages missing agent", () => Effect.gen(function* () { const test = yield* TestInstance - const session = yield* createSession({ title: "legacy assistant" }) - const message = yield* createTextMessage(session.id, "hello") - yield* insertLegacyMessageV2Assistant(session.id, message.info.id) + const userSession = yield* createSession({ title: "legacy user" }) + const assistantSession = yield* createSession({ title: "legacy assistant" }) + const message = yield* createTextMessage(assistantSession.id, "hello") + yield* insertLegacyMessageV2User(userSession.id) + yield* insertLegacyMessageV2Assistant(assistantSession.id, message.info.id) - const response = yield* request(`${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=80`, { - headers: { "x-opencode-directory": test.directory }, - }) + const headers = { "x-opencode-directory": test.directory } + const userResponse = yield* request( + `${pathFor(SessionPaths.messages, { sessionID: userSession.id })}?limit=80`, + { headers }, + ) + const assistantResponse = yield* request( + `${pathFor(SessionPaths.messages, { sessionID: assistantSession.id })}?limit=80`, + { headers }, + ) - expect(response.status).toBe(200) + expect(userResponse.status).toBe(200) + expect((yield* json(userResponse))[0]?.info).toMatchObject({ + role: "user", + agent: "build", + }) + expect(assistantResponse.status).toBe(200) expect( - (yield* json(response)).find((item) => item.info.role === "assistant")?.info, + (yield* json(assistantResponse)).find((item) => item.info.role === "assistant")?.info, ).toMatchObject({ role: "assistant", agent: "build", From eaba8d0ca68320b2d2f0e1505324a9244f8c9ffa Mon Sep 17 00:00:00 2001 From: Eric-Guo Date: Wed, 13 May 2026 21:32:47 +0800 Subject: [PATCH 3/4] Normalize legacy session messages without agent or model --- packages/opencode/src/session/message-v2.ts | 36 +++++++++++++++++-- .../test/server/httpapi-session.test.ts | 34 ++++++++---------- 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 0252589b3341..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,13 +90,28 @@ export const cursor = { }, } -const info = (row: typeof MessageTable.$inferSelect) => { - const data = row.data as typeof row.data & { agent?: unknown; mode?: unknown } +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, ...(typeof data.agent === "string" ? {} : { agent: typeof data.mode === "string" ? data.mode : "build" }), + ...(data.role === "user" ? { model: legacyModel(data.model, fallbackModel) } : {}), } as Info } @@ -113,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 @@ -131,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 81ebc0ad01e9..d2b49caf6bbc 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -215,11 +215,12 @@ const insertLegacyMessageV2Assistant = (sessionID: SessionIDType, parentID: Mess const insertLegacyMessageV2User = (sessionID: SessionIDType) => Effect.sync(() => { + const id = MessageID.ascending() Database.use((db) => db .insert(MessageTable) .values({ - id: MessageID.ascending(), + id, session_id: sessionID, time_created: 1, time_updated: 1, @@ -231,6 +232,7 @@ const insertLegacyMessageV2User = (sessionID: SessionIDType) => }) .run(), ) + return id }) const setLegacySummaryDiff = (sessionID: SessionIDType) => @@ -691,31 +693,23 @@ describe("session HttpApi", () => { () => Effect.gen(function* () { const test = yield* TestInstance - const userSession = yield* createSession({ title: "legacy user" }) - const assistantSession = yield* createSession({ title: "legacy assistant" }) - const message = yield* createTextMessage(assistantSession.id, "hello") - yield* insertLegacyMessageV2User(userSession.id) - yield* insertLegacyMessageV2Assistant(assistantSession.id, message.info.id) + 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 userResponse = yield* request( - `${pathFor(SessionPaths.messages, { sessionID: userSession.id })}?limit=80`, - { headers }, - ) - const assistantResponse = yield* request( - `${pathFor(SessionPaths.messages, { sessionID: assistantSession.id })}?limit=80`, - { headers }, - ) + const response = yield* request(`${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=80`, { + headers, + }) + const messages = yield* json(response) - expect(userResponse.status).toBe(200) - expect((yield* json(userResponse))[0]?.info).toMatchObject({ + 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(assistantResponse.status).toBe(200) - expect( - (yield* json(assistantResponse)).find((item) => item.info.role === "assistant")?.info, - ).toMatchObject({ + expect(messages.find((item) => item.info.role === "assistant")?.info).toMatchObject({ role: "assistant", agent: "build", }) From 90beb9e2f909a51d6b616e6c41f2b4dc953d3189 Mon Sep 17 00:00:00 2001 From: Eric-Guo Date: Sun, 31 May 2026 21:27:43 +0800 Subject: [PATCH 4/4] Fix test --- .../test/server/httpapi-session.test.ts | 93 +++++++++---------- 1 file changed, 46 insertions(+), 47 deletions(-) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index d2b49caf6bbc..8410ba84f509 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -22,7 +22,6 @@ 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 { MessageTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { SessionMessage } from "@opencode-ai/core/session/message" @@ -183,55 +182,55 @@ 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(), - ) + 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.sync(() => { + Effect.gen(function* () { 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(), - ) + 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 }) @@ -701,7 +700,7 @@ describe("session HttpApi", () => { const response = yield* request(`${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=80`, { headers, }) - const messages = yield* json(response) + const messages = yield* json(response) expect(response.status).toBe(200) expect(messages.find((item) => item.info.role === "user")?.info).toMatchObject({