From 193520a2a8d3ab2d9d7a770edf034da20b6c59b7 Mon Sep 17 00:00:00 2001 From: rich-jojo Date: Fri, 27 Mar 2026 00:41:33 +0900 Subject: [PATCH 1/4] fix: ignore synthetic user messages in session history --- packages/opencode/src/acp/agent.ts | 4 +- .../opencode/src/server/routes/session.ts | 6 +- packages/opencode/src/session/compaction.ts | 6 +- packages/opencode/src/session/message-v2.ts | 11 ++++ packages/opencode/src/session/prompt.ts | 20 +++--- packages/opencode/src/session/revert.ts | 2 +- packages/opencode/src/share/share-next.ts | 2 +- packages/opencode/src/tool/plan.ts | 2 +- .../opencode/test/session/message-v2.test.ts | 46 +++++++++++++ .../test/session/revert-compact.test.ts | 66 +++++++++++++++++++ 10 files changed, 142 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 2a6bbbb1e444..8b535e1bf2d1 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -634,8 +634,8 @@ export namespace ACP { return undefined }) - const lastUser = messages?.findLast((m) => m.info.role === "user")?.info - if (lastUser?.role === "user") { + const lastUser = messages?.findLast((m) => MessageV2.isRealUserMessage(m))?.info + if (lastUser) { result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}` this.sessionManager.setModel(sessionId, { providerID: ProviderID.make(lastUser.model.providerID), diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index d499e5a1ecf4..53db4511d6cc 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -526,9 +526,9 @@ export const SessionRoutes = lazy(() => const msgs = await Session.messages({ sessionID }) let currentAgent = await Agent.defaultAgent() for (let i = msgs.length - 1; i >= 0; i--) { - const info = msgs[i].info - if (info.role === "user") { - currentAgent = info.agent || (await Agent.defaultAgent()) + const msg = msgs[i] + if (MessageV2.isRealUserMessage(msg)) { + currentAgent = msg.info.agent || (await Agent.defaultAgent()) break } } diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index f6145b7a47e2..d524e24f30ec 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -73,7 +73,7 @@ export namespace SessionCompaction { loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) { const msg = msgs[msgIndex] - if (msg.info.role === "user") turns++ + if (MessageV2.isRealUserMessage(msg)) turns++ if (turns < 2) continue if (msg.info.role === "assistant" && msg.info.summary) break loop for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) { @@ -120,14 +120,14 @@ export namespace SessionCompaction { const idx = input.messages.findIndex((m) => m.info.id === input.parentID) for (let i = idx - 1; i >= 0; i--) { const msg = input.messages[i] - if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) { + if (MessageV2.isRealUserMessage(msg) && !msg.parts.some((p) => p.type === "compaction")) { replay = msg messages = input.messages.slice(0, i) break } } const hasContent = - replay && messages.some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction")) + replay && messages.some((m) => MessageV2.isRealUserMessage(m) && !m.parts.some((p) => p.type === "compaction")) if (!hasContent) { replay = undefined messages = input.messages diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 86e43156523b..de6f4343f3ff 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -27,6 +27,17 @@ export namespace MessageV2 { return mime.startsWith("image/") || mime === "application/pdf" } + export function isRealUserMessage(msg: MessageV2.WithParts): msg is MessageV2.WithParts & { info: MessageV2.User } + export function isRealUserMessage( + msg: T, + ): msg is T & { info: T["info"] & { role: "user" } } + export function isRealUserMessage(msg: { info: { role: string }; parts: readonly unknown[] }) { + return ( + msg.info.role === "user" && + !msg.parts.every((part) => typeof part === "object" && part !== null && "synthetic" in part && part.synthetic) + ) + } + export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() })) export const StructuredOutputError = NamedError.create( diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b3c34539e77e..d80eb790ff36 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -307,10 +307,9 @@ export namespace SessionPrompt { let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = [] for (let i = msgs.length - 1; i >= 0; i--) { const msg = msgs[i] - if (!lastUser && msg.info.role === "user") lastUser = msg.info as MessageV2.User + if (!lastUser && MessageV2.isRealUserMessage(msg)) lastUser = msg.info as MessageV2.User if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info as MessageV2.Assistant - if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) - lastFinished = msg.info as MessageV2.Assistant + if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info as MessageV2.Assistant if (lastUser && lastFinished) break const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask") if (task && !lastFinished) { @@ -621,7 +620,7 @@ export namespace SessionPrompt { using _ = defer(() => InstructionPrompt.clear(processor.message.id)) // Check if user explicitly invoked an agent via @ in this turn - const lastUserMsg = msgs.findLast((m) => m.info.role === "user") + const lastUserMsg = msgs.findLast((m) => MessageV2.isRealUserMessage(m)) const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false const tools = await resolveTools({ @@ -654,7 +653,7 @@ export namespace SessionPrompt { // Ephemerally wrap queued user messages with a reminder to stay on track if (step > 1 && lastFinished) { for (const msg of msgs) { - if (msg.info.role !== "user" || msg.info.id <= lastFinished.id) continue + if (!MessageV2.isRealUserMessage(msg) || msg.info.id <= lastFinished.id) continue for (const part of msg.parts) { if (part.type !== "text" || part.ignored || part.synthetic) continue if (!part.text.trim()) continue @@ -757,7 +756,7 @@ export namespace SessionPrompt { async function lastModel(sessionID: SessionID) { for await (const item of MessageV2.stream(sessionID)) { - if (item.info.role === "user" && item.info.model) return item.info.model + if (MessageV2.isRealUserMessage(item) && item.info.model) return item.info.model } return Provider.defaultModel() } @@ -1387,7 +1386,7 @@ export namespace SessionPrompt { } async function insertReminders(input: { messages: MessageV2.WithParts[]; agent: Agent.Info; session: Session.Info }) { - const userMessage = input.messages.findLast((msg) => msg.info.role === "user") + const userMessage = input.messages.findLast((msg) => MessageV2.isRealUserMessage(msg)) if (!userMessage) return input.messages // Original logic when experimental plan mode is disabled @@ -1982,14 +1981,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (!Session.isDefaultTitle(input.session.title)) return // Find first non-synthetic user message - const firstRealUserIdx = input.history.findIndex( - (m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic), - ) + const firstRealUserIdx = input.history.findIndex((m) => MessageV2.isRealUserMessage(m)) if (firstRealUserIdx === -1) return const isFirst = - input.history.filter((m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic)) - .length === 1 + input.history.filter((m) => MessageV2.isRealUserMessage(m)).length === 1 if (!isFirst) return // Gather all messages up to and including the first real user message for context diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 6df8b3d53fee..24d9235c695f 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -29,7 +29,7 @@ export namespace SessionRevert { let revert: Session.Info["revert"] const patches: Snapshot.Patch[] = [] for (const msg of all) { - if (msg.info.role === "user") lastUser = msg.info + if (MessageV2.isRealUserMessage(msg)) lastUser = msg.info const remaining = [] for (const part of msg.parts) { if (revert) { diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 2a11094f8074..9c78f20ce71d 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -258,7 +258,7 @@ export namespace ShareNext { Array.from( new Map( messages - .filter((m) => m.info.role === "user") + .filter((m) => MessageV2.isRealUserMessage(m)) .map((m) => (m.info as SDK.UserMessage).model) .map((m) => [`${m.providerID}/${m.modelID}`, m] as const), ).values(), diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index e91bc3faa225..e2615a2ff288 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -11,7 +11,7 @@ import EXIT_DESCRIPTION from "./plan-exit.txt" async function getLastModel(sessionID: SessionID) { for await (const item of MessageV2.stream(sessionID)) { - if (item.info.role === "user" && item.info.model) return item.info.model + if (MessageV2.isRealUserMessage(item) && item.info.model) return item.info.model } return Provider.defaultModel() } diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 7d416597a8f5..52c45caafad9 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -108,6 +108,52 @@ function basePart(messageID: string, id: string) { } describe("session.message-v2.toModelMessage", () => { + test("detects real user messages", () => { + const realUser: MessageV2.WithParts = { + info: userInfo("m-real"), + parts: [ + { + ...basePart("m-real", "p-real"), + type: "text", + text: "hello", + } as MessageV2.Part, + ], + } + + const syntheticUser: MessageV2.WithParts = { + info: userInfo("m-synth"), + parts: [ + { + ...basePart("m-synth", "p-synth"), + type: "text", + text: "synthetic", + synthetic: true, + } as MessageV2.Part, + ], + } + + const emptyUser: MessageV2.WithParts = { + info: userInfo("m-empty"), + parts: [], + } + + const assistant: MessageV2.WithParts = { + info: assistantInfo("m-assistant", "m-real"), + parts: [ + { + ...basePart("m-assistant", "p-assistant"), + type: "text", + text: "assistant", + } as MessageV2.Part, + ], + } + + expect(MessageV2.isRealUserMessage(realUser)).toBe(true) + expect(MessageV2.isRealUserMessage(syntheticUser)).toBe(false) + expect(MessageV2.isRealUserMessage(emptyUser)).toBe(false) + expect(MessageV2.isRealUserMessage(assistant)).toBe(false) + }) + test("filters out messages with no parts", () => { const input: MessageV2.WithParts[] = [ { diff --git a/packages/opencode/test/session/revert-compact.test.ts b/packages/opencode/test/session/revert-compact.test.ts index fb37a3a8dca1..41994c398db6 100644 --- a/packages/opencode/test/session/revert-compact.test.ts +++ b/packages/opencode/test/session/revert-compact.test.ts @@ -283,4 +283,70 @@ describe("revert + compact workflow", () => { }, }) }) + + test("uses latest real user for revert anchor when synthetic message is selected", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const sessionID = session.id + + const userMsg = await Session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "default", + model: { + providerID: ProviderID.make("openai"), + modelID: ModelID.make("gpt-4"), + }, + time: { + created: Date.now(), + }, + }) + + await Session.updatePart({ + id: PartID.ascending(), + messageID: userMsg.id, + sessionID, + type: "text", + text: "hello", + }) + + const synthetic = await Session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "default", + model: { + providerID: ProviderID.make("openai"), + modelID: ModelID.make("gpt-4"), + }, + time: { + created: Date.now(), + }, + }) + + await Session.updatePart({ + id: PartID.ascending(), + messageID: synthetic.id, + sessionID, + type: "text", + text: "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed.", + synthetic: true, + }) + + await SessionRevert.revert({ + sessionID, + messageID: synthetic.id, + }) + + const sessionInfo = await Session.get(sessionID) + expect(sessionInfo.revert?.messageID).toBe(userMsg.id) + + await Session.remove(sessionID) + }, + }) + }) }) From 038b48b74a9769f8279b565052646754951443bb Mon Sep 17 00:00:00 2001 From: rich-jojo Date: Fri, 27 Mar 2026 00:49:00 +0900 Subject: [PATCH 2/4] fix: exclude control messages from real user history --- packages/opencode/src/session/message-v2.ts | 7 ++- .../opencode/test/session/message-v2.test.ts | 26 +++++++++ .../test/session/revert-compact.test.ts | 57 +++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index de6f4343f3ff..be69124e6fdd 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -34,7 +34,12 @@ export namespace MessageV2 { export function isRealUserMessage(msg: { info: { role: string }; parts: readonly unknown[] }) { return ( msg.info.role === "user" && - !msg.parts.every((part) => typeof part === "object" && part !== null && "synthetic" in part && part.synthetic) + msg.parts.some((part) => { + if (typeof part !== "object" || part === null || !("type" in part)) return false + if (part.type === "compaction" || part.type === "subtask") return false + if ("synthetic" in part && part.synthetic) return false + return true + }) ) } diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 52c45caafad9..0ed5d02fef58 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -137,6 +137,30 @@ describe("session.message-v2.toModelMessage", () => { parts: [], } + const compactionUser: MessageV2.WithParts = { + info: userInfo("m-compaction"), + parts: [ + { + ...basePart("m-compaction", "p-compaction"), + type: "compaction", + auto: true, + } as MessageV2.Part, + ], + } + + const subtaskUser: MessageV2.WithParts = { + info: userInfo("m-subtask"), + parts: [ + { + ...basePart("m-subtask", "p-subtask"), + type: "subtask", + agent: "general", + description: "run task", + prompt: "do thing", + } as MessageV2.Part, + ], + } + const assistant: MessageV2.WithParts = { info: assistantInfo("m-assistant", "m-real"), parts: [ @@ -151,6 +175,8 @@ describe("session.message-v2.toModelMessage", () => { expect(MessageV2.isRealUserMessage(realUser)).toBe(true) expect(MessageV2.isRealUserMessage(syntheticUser)).toBe(false) expect(MessageV2.isRealUserMessage(emptyUser)).toBe(false) + expect(MessageV2.isRealUserMessage(compactionUser)).toBe(false) + expect(MessageV2.isRealUserMessage(subtaskUser)).toBe(false) expect(MessageV2.isRealUserMessage(assistant)).toBe(false) }) diff --git a/packages/opencode/test/session/revert-compact.test.ts b/packages/opencode/test/session/revert-compact.test.ts index 41994c398db6..85d151579fb1 100644 --- a/packages/opencode/test/session/revert-compact.test.ts +++ b/packages/opencode/test/session/revert-compact.test.ts @@ -349,4 +349,61 @@ describe("revert + compact workflow", () => { }, }) }) + + test("uses latest real user for revert anchor when compaction message is selected", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const sessionID = session.id + + const userMsg = await Session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "default", + model: { + providerID: ProviderID.make("openai"), + modelID: ModelID.make("gpt-4"), + }, + time: { + created: Date.now(), + }, + }) + + await Session.updatePart({ + id: PartID.ascending(), + messageID: userMsg.id, + sessionID, + type: "text", + text: "hello", + }) + + await SessionCompaction.create({ + sessionID, + agent: "default", + model: { + providerID: ProviderID.make("openai"), + modelID: ModelID.make("gpt-4"), + }, + auto: true, + }) + + const messages = await Session.messages({ sessionID }) + const compaction = messages.findLast((msg) => msg.parts.some((part) => part.type === "compaction")) + expect(compaction).toBeDefined() + + await SessionRevert.revert({ + sessionID, + messageID: compaction!.info.id, + }) + + const sessionInfo = await Session.get(sessionID) + expect(sessionInfo.revert?.messageID).toBe(userMsg.id) + + await Session.remove(sessionID) + }, + }) + }) }) From 73793ef5001e4371dfbda528ea7cf97599e0c122 Mon Sep 17 00:00:00 2001 From: rich-jojo Date: Fri, 27 Mar 2026 00:52:59 +0900 Subject: [PATCH 3/4] fix: restore opencode typecheck compatibility --- packages/opencode/src/effect/cross-spawn-spawner.ts | 2 +- packages/opencode/src/storage/node-sqlite.d.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/storage/node-sqlite.d.ts diff --git a/packages/opencode/src/effect/cross-spawn-spawner.ts b/packages/opencode/src/effect/cross-spawn-spawner.ts index f7b8786d08a0..519deb4cc65e 100644 --- a/packages/opencode/src/effect/cross-spawn-spawner.ts +++ b/packages/opencode/src/effect/cross-spawn-spawner.ts @@ -229,7 +229,7 @@ export const make = Effect.gen(function* () { evaluate: () => proc.stdin!, onError: (err) => toPlatformError("fromWritable(stdin)", toError(err), command), endOnDone: cfg.endOnDone, - encoding: cfg.encoding, + encoding: cfg.encoding === "utf-16le" ? "utf16le" : cfg.encoding, }) } if (Stream.isStream(cfg.stream)) return Effect.as(Effect.forkScoped(Stream.run(cfg.stream, sink)), sink) diff --git a/packages/opencode/src/storage/node-sqlite.d.ts b/packages/opencode/src/storage/node-sqlite.d.ts new file mode 100644 index 000000000000..c76612583f0a --- /dev/null +++ b/packages/opencode/src/storage/node-sqlite.d.ts @@ -0,0 +1,5 @@ +declare module "node:sqlite" { + export class DatabaseSync { + constructor(path: string) + } +} From b7c4ef57a31ecd7e7a34827efb7fff055374275b Mon Sep 17 00:00:00 2001 From: rich-jojo Date: Fri, 27 Mar 2026 01:00:04 +0900 Subject: [PATCH 4/4] fix: ignore control messages in share model sync --- packages/opencode/src/share/share-next.ts | 16 ++--- .../opencode/test/share/share-next.test.ts | 63 +++++++++++++++++++ 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 9c78f20ce71d..92ee8d4272f9 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -83,14 +83,6 @@ export namespace ShareNext { data: evt.properties.info, }, ]) - if (info.role === "user") { - await sync(info.sessionID, [ - { - type: "model", - data: [await Provider.getModel(info.model.providerID, info.model.modelID).then((m) => m)], - }, - ]) - } }) Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { await sync(evt.properties.part.sessionID, [ @@ -99,6 +91,14 @@ export namespace ShareNext { data: evt.properties.part, }, ]) + const msg = await MessageV2.get({ sessionID: evt.properties.part.sessionID, messageID: evt.properties.part.messageID }) + if (!MessageV2.isRealUserMessage(msg)) return + await sync(evt.properties.part.sessionID, [ + { + type: "model", + data: [await Provider.getModel(msg.info.model.providerID, msg.info.model.modelID).then((m) => m)], + }, + ]) }) Bus.subscribe(Session.Event.Diff, async (evt) => { await sync(evt.properties.sessionID, [ diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index fc8d511509fe..ce056bb44962 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -2,6 +2,13 @@ import { test, expect, mock } from "bun:test" import { ShareNext } from "../../src/share/share-next" import { AccessToken, Account, AccountID, OrgID } from "../../src/account" import { Config } from "../../src/config/config" +import { Session } from "../../src/session" +import { SessionCompaction } from "../../src/session/compaction" +import { ProviderID, ModelID } from "../../src/provider/schema" +import { Instance } from "../../src/project/instance" +import { Database } from "../../src/storage/db" +import { SessionShareTable } from "../../src/share/share.sql" +import { tmpdir } from "../fixture/fixture" test("ShareNext.request uses legacy share API without active org account", async () => { const originalActive = Account.active @@ -74,3 +81,59 @@ test("ShareNext.request fails when org account has no token", async () => { Account.token = originalToken } }) + +test("ShareNext.init does not sync model metadata for compaction messages", async () => { + const originalFetch = globalThis.fetch + const calls: any[] = [] + + globalThis.fetch = mock(async (_input, init) => { + calls.push(JSON.parse(String(init?.body ?? "{}"))) + return new Response(null, { status: 200 }) + }) as unknown as typeof fetch + + await using tmp = await tmpdir({ git: true }) + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const sessionID = session.id + + Database.use((db) => + db + .insert(SessionShareTable) + .values({ + session_id: sessionID, + id: "shr_test", + secret: "sec_test", + url: "https://share.example.com/share/shr_test", + }) + .run(), + ) + + await ShareNext.init() + + await SessionCompaction.create({ + sessionID, + agent: "default", + model: { + providerID: ProviderID.make("openai"), + modelID: ModelID.make("gpt-4"), + }, + auto: true, + }) + + await Bun.sleep(1100) + + const payload = calls.find((item) => Array.isArray(item.data)) + expect(payload).toBeDefined() + expect(payload.data.some((item: { type: string }) => item.type === "message")).toBe(true) + expect(payload.data.some((item: { type: string }) => item.type === "part")).toBe(true) + expect(payload.data.some((item: { type: string }) => item.type === "model")).toBe(false) + }, + }) + } finally { + globalThis.fetch = originalFetch + } +})