From 90e627d68b9a38b475898e85ac97785018687908 Mon Sep 17 00:00:00 2001 From: Dandi007 Date: Fri, 15 May 2026 02:08:40 +0800 Subject: [PATCH 01/11] fix(session): dedupe active compaction markers Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/opencode/src/session/compaction.ts | 69 ++++++++--- .../opencode/test/session/compaction.test.ts | 115 ++++++++++++++++++ 2 files changed, 167 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index bc3327c07d42..c5dd23146146 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -22,6 +22,8 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2 } from "@opencode-ai/core/event" import { EventV2Bridge } from "@/event-v2-bridge" import { SessionEvent } from "@opencode-ai/core/session-event" +import { Database } from "@/storage/db" +import { SyncEvent } from "@/sync" const log = Log.create({ service: "session.compaction" }) @@ -122,6 +124,23 @@ function completedCompactions(messages: MessageV2.WithParts[]) { }) } +function activeCompactionMarker(messages: MessageV2.WithParts[]) { + const completed = new Set() + for (const msg of messages) { + if (msg.info.role !== "assistant") continue + if (msg.info.agent !== "compaction") continue + if (!msg.info.summary || !msg.info.finish || msg.info.error) continue + completed.add(msg.info.parentID) + } + + return messages.find( + (msg) => + msg.info.role === "user" && + msg.parts.some((part) => part.type === "compaction") && + !completed.has(msg.info.id), + ) +} + function buildPrompt(input: { previousSummary?: string; context: string[] }) { const anchor = input.previousSummary ? [ @@ -590,23 +609,39 @@ export const layer = Layer.effect( auto: boolean overflow?: boolean }) { - const msg = yield* session.updateMessage({ - id: MessageID.ascending(), - role: "user", - model: input.model, - sessionID: input.sessionID, - agent: input.agent, - time: { created: Date.now() }, - }) - yield* session.updatePart({ - id: PartID.ascending(), - messageID: msg.id, - sessionID: msg.sessionID, - type: "compaction", - auto: input.auto, - overflow: input.overflow, - }) - if (flags.experimentalEventSystem) { + const created = yield* Effect.sync(() => + Database.transaction( + () => { + const messages = Array.from(MessageV2.stream(input.sessionID)).reverse() + if (activeCompactionMarker(messages)) return false + + const msg: MessageV2.User = { + id: MessageID.ascending(), + role: "user", + model: input.model, + sessionID: input.sessionID, + agent: input.agent, + time: { created: Date.now() }, + } + SyncEvent.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg }) + SyncEvent.run(MessageV2.Event.PartUpdated, { + sessionID: msg.sessionID, + time: Date.now(), + part: { + id: PartID.ascending(), + messageID: msg.id, + sessionID: msg.sessionID, + type: "compaction", + auto: input.auto, + overflow: input.overflow, + }, + }) + return true + }, + { behavior: "immediate" }, + ), + ) + if (created && flags.experimentalEventSystem) { yield* events.publish(SessionEvent.Compaction.Started, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 2bc9b196216d..0695b5985c24 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -188,6 +188,12 @@ function createCompactionMarker(sessionID: SessionID) { ) } +function compactionMarkers(messages: MessageV2.WithParts[]) { + return messages.filter( + (msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction"), + ) +} + function fake( input: Parameters[0], result: "continue" | "compact", @@ -622,6 +628,115 @@ describe("session.compaction.create", () => { }), ), ) + + it.live( + "merges duplicate active compaction creates", + provideTmpdirInstance(() => + Effect.gen(function* () { + const compact = yield* SessionCompaction.Service + const ssn = yield* SessionNs.Service + + const sequential = yield* ssn.create({}) + yield* compact.create({ + sessionID: sequential.id, + agent: "build", + model: ref, + auto: true, + }) + yield* compact.create({ + sessionID: sequential.id, + agent: "build", + model: ref, + auto: true, + }) + + expect(compactionMarkers(yield* ssn.messages({ sessionID: sequential.id }))).toHaveLength(1) + + const concurrent = yield* ssn.create({}) + yield* Effect.all( + [ + compact.create({ + sessionID: concurrent.id, + agent: "build", + model: ref, + auto: true, + }), + compact.create({ + sessionID: concurrent.id, + agent: "build", + model: ref, + auto: true, + }), + ], + { concurrency: "unbounded" }, + ) + + expect(compactionMarkers(yield* ssn.messages({ sessionID: concurrent.id }))).toHaveLength(1) + }), + ), + ) + + it.live( + "allows a new marker after completed compaction", + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const compact = yield* SessionCompaction.Service + const ssn = yield* SessionNs.Service + + const info = yield* ssn.create({}) + yield* compact.create({ + sessionID: info.id, + agent: "build", + model: ref, + auto: true, + }) + const first = compactionMarkers(yield* ssn.messages({ sessionID: info.id })).at(0) + expect(first).toBeTruthy() + + const summary: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + sessionID: info.id, + mode: "compaction", + agent: "compaction", + path: { cwd: dir, root: dir }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: ref.modelID, + providerID: ref.providerID, + parentID: first!.info.id, + summary: true, + time: { created: Date.now() }, + finish: "end_turn", + } + yield* ssn.updateMessage(summary) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: summary.id, + sessionID: info.id, + type: "text", + text: "completed summary", + }) + + yield* compact.create({ + sessionID: info.id, + agent: "build", + model: ref, + auto: true, + }) + + const markers = compactionMarkers(yield* ssn.messages({ sessionID: info.id })) + expect(markers).toHaveLength(2) + expect(markers[0].info.id).toBe(first!.info.id) + expect(markers[1].info.id).not.toBe(first!.info.id) + }), + ), + ) }) describe("session.compaction.prune", () => { From 8b423b2085c17b91d54a72965422c99393f4612a Mon Sep 17 00:00:00 2001 From: Dandi007 Date: Fri, 15 May 2026 02:23:46 +0800 Subject: [PATCH 02/11] fix(session): track compaction task owners Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/opencode/src/session/prompt.ts | 12 +++--- packages/opencode/test/session/prompt.test.ts | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ba9a4d6f1a0f..4fe52d91ae17 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1690,18 +1690,18 @@ NOTE: At any point in time through this workflow you should feel free to ask the const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID) const task = tasks.pop() - if (task?.type === "subtask") { - yield* handleSubtask({ task, model, lastUser, sessionID, session, msgs }) + if (task?.part.type === "subtask") { + yield* handleSubtask({ task: task.part, model, lastUser, sessionID, session, msgs }) continue } - if (task?.type === "compaction") { + if (task?.part.type === "compaction") { const result = yield* compaction.process({ messages: msgs, - parentID: lastUser.id, + parentID: task.part.messageID, sessionID, - auto: task.auto, - overflow: task.overflow, + auto: task.part.auto, + overflow: task.part.overflow, }) if (result === "stop") break continue diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 891efc18721d..ac7cec39a664 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -419,6 +419,18 @@ const addSubtask = (sessionID: SessionID, messageID: MessageID, model = ref) => }) }) +const addCompaction = (sessionID: SessionID, messageID: MessageID) => + Effect.gen(function* () { + const session = yield* Session.Service + yield* session.updatePart({ + id: PartID.ascending(), + messageID, + sessionID, + type: "compaction", + auto: false, + }) + }) + const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) { const config = yield* Config.Service const prompt = yield* SessionPrompt.Service @@ -519,6 +531,37 @@ it.instance( { git: true }, ) +it.instance( + "processes an old compaction marker under its owner message", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Compaction owner", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + const markerOwner = yield* user(chat.id, "needs compaction") + yield* addCompaction(chat.id, markerOwner.id) + const later = yield* user(chat.id, "later user message") + yield* llm.text("summary") + + yield* prompt.loop({ sessionID: chat.id }) + + const summary = (yield* sessions.messages({ sessionID: chat.id })).find( + (msg) => msg.info.role === "assistant" && msg.info.agent === "compaction", + ) + expect(summary?.info.role).toBe("assistant") + if (summary?.info.role === "assistant") { + expect(summary.info.parentID).toBe(markerOwner.id) + expect(summary.info.parentID).not.toBe(later.id) + } + expect(yield* llm.hits).toHaveLength(1) + }), + { git: true }, +) + it.instance( "static loop returns assistant text through local provider", () => From 44ed114792f32954136c5c57726470d62daea200 Mon Sep 17 00:00:00 2001 From: Dandi007 Date: Fri, 15 May 2026 02:24:00 +0800 Subject: [PATCH 03/11] fix(session): bind compaction processing to marker owner Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/opencode/src/session/compaction.ts | 10 ++++++ .../opencode/test/session/compaction.test.ts | 33 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index c5dd23146146..36bbaa08c923 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -373,6 +373,16 @@ export const layer = Layer.effect( if (!parent || parent.info.role !== "user") { throw new Error(`Compaction parent must be a user message: ${input.parentID}`) } + const existing = (yield* session.messages({ sessionID: input.sessionID })).find( + (msg) => + msg.info.role === "assistant" && + msg.info.agent === "compaction" && + msg.info.summary && + msg.info.parentID === input.parentID && + !msg.info.error, + ) + if (existing?.info.role === "assistant") return existing.info.finish ? "continue" : "stop" + const userMessage = parent.info const compactionPart = parent.parts.find((part): part is MessageV2.CompactionPart => part.type === "compaction") diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 0695b5985c24..63068ec346fa 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -988,6 +988,39 @@ describe("session.compaction.process", () => { }), ) + itCompaction.instance( + "does not create duplicate assistants for the same marker", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "hello") + yield* SessionCompaction.use.create({ sessionID: session.id, agent: "build", model: ref, auto: false }) + const msgs = yield* ssn.messages({ sessionID: session.id }) + const marker = compactionMarkers(msgs).at(0) + expect(marker).toBeTruthy() + + yield* SessionCompaction.use.process({ + parentID: marker!.info.id, + messages: msgs, + sessionID: session.id, + auto: false, + }) + yield* SessionCompaction.use.process({ + parentID: marker!.info.id, + messages: msgs, + sessionID: session.id, + auto: false, + }) + + const summaries = (yield* ssn.messages({ sessionID: session.id })).filter( + (msg) => msg.info.role === "assistant" && msg.info.agent === "compaction", + ) + expect(summaries).toHaveLength(1) + expect(summaries[0]?.info.role).toBe("assistant") + if (summaries[0]?.info.role === "assistant") expect(summaries[0].info.parentID).toBe(marker!.info.id) + }).pipe(withCompaction()), + ) + itCompaction.instance( "marks summary message as errored on compact result", Effect.gen(function* () { From 986205947c825fb2b32e237b850026277b70be83 Mon Sep 17 00:00:00 2001 From: Dandi007 Date: Fri, 15 May 2026 02:37:04 +0800 Subject: [PATCH 04/11] test(session): cover compact trigger idempotency Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/opencode/test/session/prompt.test.ts | 136 +++++++++++++++++- 1 file changed, 132 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index ac7cec39a664..3b5ed0bb7eb0 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -163,7 +163,7 @@ const blockingProcessor = Layer.succeed( }), ) -function makeHttp(input?: { processor?: "blocking" }) { +function makeHttp(input?: { processor?: "blocking"; plugin?: Layer.Layer }) { const deps = Layer.mergeAll( Session.defaultLayer, Snapshot.defaultLayer, @@ -172,7 +172,7 @@ function makeHttp(input?: { processor?: "blocking" }) { AgentSvc.defaultLayer, Command.defaultLayer, Permission.defaultLayer, - Plugin.defaultLayer, + input?.plugin ?? Plugin.defaultLayer, Config.defaultLayer, ProviderSvc.defaultLayer, lsp, @@ -234,6 +234,7 @@ function makeHttp(input?: { processor?: "blocking" }) { } const it = testEffect(makeHttp()) +const itNoAutoContinue = testEffect(makeHttp({ plugin: autocontinue(false) })) const race = testEffect(makeHttp({ processor: "blocking" })) const unix = process.platform !== "win32" ? it.instance : it.instance.skip @@ -375,7 +376,10 @@ const user = Effect.fn("test.user")(function* (sessionID: SessionID, text: strin return msg }) -const seed = Effect.fn("test.seed")(function* (sessionID: SessionID, opts?: { finish?: string }) { +const seed = Effect.fn("test.seed")(function* ( + sessionID: SessionID, + opts?: { finish?: string; tokens?: MessageV2.Assistant["tokens"] }, +) { const session = yield* Session.Service const msg = yield* user(sessionID, "hello") const assistant: MessageV2.Assistant = { @@ -387,7 +391,7 @@ const seed = Effect.fn("test.seed")(function* (sessionID: SessionID, opts?: { fi agent: "build", cost: 0, path: { cwd: "/tmp", root: "/tmp" }, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + tokens: opts?.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, modelID: ref.modelID, providerID: ref.providerID, time: { created: Date.now() }, @@ -431,6 +435,30 @@ const addCompaction = (sessionID: SessionID, messageID: MessageID) => }) }) +function compactionMarkers(messages: MessageV2.WithParts[]) { + return messages.filter( + (msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction"), + ) +} + +function compactionAssistants(messages: MessageV2.WithParts[]) { + return messages.filter((msg) => msg.info.role === "assistant" && msg.info.agent === "compaction") +} + +function autocontinue(enabled: boolean) { + return Layer.mock(Plugin.Service)({ + trigger: (name: Name, _input: Input, output: Output) => { + if (name !== "experimental.compaction.autocontinue") return Effect.succeed(output) + return Effect.sync(() => { + ;(output as { enabled: boolean }).enabled = enabled + return output + }) + }, + list: () => Effect.succeed([]), + init: () => Effect.void, + }) +} + const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) { const config = yield* Config.Service const prompt = yield* SessionPrompt.Service @@ -562,6 +590,106 @@ it.instance( { git: true }, ) +it.instance( + "explicit summarize reuses an active compaction marker", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const compact = yield* SessionCompaction.Service + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Explicit summarize", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* user(chat.id, "needs summary") + yield* compact.create({ sessionID: chat.id, agent: "build", model: ref, auto: true }) + const marker = compactionMarkers(yield* sessions.messages({ sessionID: chat.id })).at(0) + expect(marker).toBeTruthy() + + yield* compact.create({ sessionID: chat.id, agent: "build", model: ref, auto: false }) + expect(compactionMarkers(yield* sessions.messages({ sessionID: chat.id })).map((msg) => msg.info.id)).toEqual([ + marker!.info.id, + ]) + + yield* llm.text("summary") + yield* prompt.loop({ sessionID: chat.id }) + + const messages = yield* sessions.messages({ sessionID: chat.id }) + const summaries = compactionAssistants(messages) + expect(compactionMarkers(messages)).toHaveLength(1) + expect(summaries).toHaveLength(1) + expect(summaries[0].info.role).toBe("assistant") + if (summaries[0].info.role === "assistant") expect(summaries[0].info.parentID).toBe(marker!.info.id) + }), + { git: true }, +) + +itNoAutoContinue.instance( + "auto overflow creates one marker that prompt.loop processes once", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Overflow compact", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* seed(chat.id, { + finish: "end_turn", + tokens: { input: 100_000, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + const current = yield* user(chat.id, "continue after an oversized answer") + yield* llm.text("summary") + + yield* prompt.loop({ sessionID: chat.id }) + + const messages = yield* sessions.messages({ sessionID: chat.id }) + const markers = compactionMarkers(messages) + const summaries = compactionAssistants(messages) + expect(markers).toHaveLength(1) + expect(summaries).toHaveLength(1) + expect(summaries[0].info.role).toBe("assistant") + if (summaries[0].info.role === "assistant") { + expect(summaries[0].info.parentID).toBe(markers[0].info.id) + expect(summaries[0].info.parentID).not.toBe(current.id) + } + }), + { git: true }, +) + +itNoAutoContinue.instance( + "processor compact trigger creates one marker that prompt.loop processes once", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Processor compact", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + const current = yield* user(chat.id, "trigger processor compact") + yield* llm.error(400, { type: "error", error: { code: "context_length_exceeded" } }) + yield* llm.text("summary") + + yield* prompt.loop({ sessionID: chat.id }) + + const messages = yield* sessions.messages({ sessionID: chat.id }) + const markers = compactionMarkers(messages) + const summaries = compactionAssistants(messages) + expect(markers).toHaveLength(1) + expect(summaries).toHaveLength(1) + expect(summaries[0].info.role).toBe("assistant") + if (summaries[0].info.role === "assistant") { + expect(summaries[0].info.parentID).toBe(markers[0].info.id) + expect(summaries[0].info.parentID).not.toBe(current.id) + } + }), + { git: true }, +) + it.instance( "static loop returns assistant text through local provider", () => From b0891fcbfb2c9b9cf487ad880ab8cf2bb263c1c3 Mon Sep 17 00:00:00 2001 From: Dandi007 Date: Fri, 15 May 2026 03:00:29 +0800 Subject: [PATCH 05/11] test(session): add runtime compaction e2e Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../session/compaction-runtime-e2e.test.ts | 472 ++++++++++++++++++ 1 file changed, 472 insertions(+) create mode 100644 packages/opencode/test/session/compaction-runtime-e2e.test.ts diff --git a/packages/opencode/test/session/compaction-runtime-e2e.test.ts b/packages/opencode/test/session/compaction-runtime-e2e.test.ts new file mode 100644 index 000000000000..eb30894efc37 --- /dev/null +++ b/packages/opencode/test/session/compaction-runtime-e2e.test.ts @@ -0,0 +1,472 @@ +import { afterAll, describe, expect, test } from "bun:test" +import { Database as SQLite } from "bun:sqlite" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +const TMP_ROOT = path.join(os.tmpdir(), "opencode-compact-e2e") +const RUN_ROOT = path.join(TMP_ROOT, `run-${process.pid}`) +const PROJECT_DIR = path.join(RUN_ROOT, "project") +const HOME_DIR = path.join(RUN_ROOT, "home") +const DB_DIR = path.join(RUN_ROOT, "db") +const DB_PATH = path.join(DB_DIR, "opencode.db") +const PACKAGE_DIR = path.resolve(import.meta.dir, "../..") +const BUN_BIN = path.join(os.homedir(), ".bun/bin/bun") + +type OpenCodeProcess = { + process: Bun.Subprocess<"ignore", "pipe", "pipe"> + output: string[] +} + +let openCode: OpenCodeProcess | undefined +let llm: MockLLMServer | undefined +let passed = false + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function sse(lines: unknown[]) { + return lines.map((line) => (line === "[DONE]" ? "data: [DONE]\n\n" : `data: ${JSON.stringify(line)}\n\n`)).join("") +} + +function chatChunk(input: { delta?: Record; finish?: string; usage?: { input: number; output: number } }) { + return { + id: "chatcmpl-runtime-e2e", + object: "chat.completion.chunk", + choices: [ + { + delta: input.delta ?? {}, + ...(input.finish ? { finish_reason: input.finish } : {}), + }, + ], + ...(input.usage + ? { + usage: { + prompt_tokens: input.usage.input, + completion_tokens: input.usage.output, + total_tokens: input.usage.input + input.usage.output, + }, + } + : {}), + } +} + +function chatResponse(text: string) { + return sse([ + chatChunk({ delta: { role: "assistant" } }), + chatChunk({ delta: { content: text } }), + chatChunk({ finish: "stop", usage: { input: 17, output: 5 } }), + "[DONE]", + ]) +} + +function responsesResponse(text: string) { + return sse([ + { + type: "response.created", + sequence_number: 1, + response: { id: "resp_runtime_e2e", created_at: Math.floor(Date.now() / 1000), model: "test-model" }, + }, + { + type: "response.output_item.added", + sequence_number: 2, + output_index: 0, + item: { type: "message", id: "msg_runtime_e2e" }, + }, + { + type: "response.output_text.delta", + sequence_number: 3, + item_id: "msg_runtime_e2e", + delta: text, + logprobs: null, + }, + { + type: "response.output_item.done", + sequence_number: 4, + output_index: 0, + item: { type: "message", id: "msg_runtime_e2e" }, + }, + { + type: "response.completed", + sequence_number: 5, + response: { + incomplete_details: null, + service_tier: null, + usage: { + input_tokens: 17, + input_tokens_details: { cached_tokens: null }, + output_tokens: 5, + output_tokens_details: { reasoning_tokens: null }, + }, + }, + }, + "[DONE]", + ]) +} + +class MockLLMServer { + readonly server: Bun.Server + readonly requests: Array<{ url: string; body: unknown; compaction: boolean }> = [] + + constructor() { + this.server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: async (request) => { + const url = new URL(request.url) + if (request.method !== "POST" || (url.pathname !== "/v1/chat/completions" && url.pathname !== "/v1/responses")) { + return new Response("not found", { status: 404 }) + } + + const body = await request.json().catch(() => ({})) + const serialized = JSON.stringify(body) + const title = serialized.includes("Generate a title for this conversation") + const compaction = !title && /compact|compaction|summary|summar/i.test(serialized) + this.requests.push({ url: url.pathname, body, compaction }) + + if (compaction) await sleep(250) + + const text = compaction ? "runtime compaction summary" : title ? "Runtime E2E" : "runtime assistant response" + const payload = url.pathname === "/v1/responses" ? responsesResponse(text) : chatResponse(text) + return new Response(payload, { headers: { "content-type": "text/event-stream" } }) + }, + }) + } + + get url() { + return `http://127.0.0.1:${this.server.port}/v1` + } + + stop() { + this.server.stop(true) + } +} + +function providerConfig(baseURL: string) { + return { + formatter: false, + lsp: false, + plugin: [], + provider: { + test: { + id: "test", + name: "Runtime Test Provider", + npm: "@ai-sdk/openai-compatible", + env: [], + models: { + "test-model": { + id: "test-model", + name: "Runtime Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2026-05-15", + limit: { context: 100_000, output: 10_000 }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + options: {}, + }, + }, + options: { + apiKey: "test-key", + baseURL, + }, + }, + }, + } +} + +async function initProject() { + await fs.rm(RUN_ROOT, { recursive: true, force: true }) + await fs.mkdir(PROJECT_DIR, { recursive: true }) + await fs.mkdir(HOME_DIR, { recursive: true }) + await fs.mkdir(DB_DIR, { recursive: true }) + await fs.writeFile(path.join(PROJECT_DIR, "README.md"), "# runtime compaction e2e\n") + const proc = Bun.spawn(["git", "init"], { + cwd: PROJECT_DIR, + stdout: "ignore", + stderr: "ignore", + env: { ...process.env, GIT_MASTER: "1" }, + }) + const code = await proc.exited + if (code !== 0) throw new Error("failed to initialize temporary git project") +} + +async function readStream(stream: ReadableStream, onText: (text: string) => void) { + const reader = stream.getReader() + const decoder = new TextDecoder() + try { + while (true) { + const next = await reader.read() + if (next.done) break + onText(decoder.decode(next.value, { stream: true })) + } + } finally { + reader.releaseLock() + } +} + +async function startOpenCode(baseURL: string) { + const output: string[] = [] + const env = { + ...process.env, + HOME: HOME_DIR, + XDG_DATA_HOME: path.join(HOME_DIR, ".local/share"), + XDG_CONFIG_HOME: path.join(HOME_DIR, ".config"), + OPENCODE_DB: DB_PATH, + OPENCODE_SERVER_PASSWORD: "test", + OPENCODE_PURE: "1", + OPENCODE_DISABLE_AUTOUPDATE: "1", + OPENCODE_DISABLE_MODELS_FETCH: "1", + OPENCODE_DISABLE_LSP_DOWNLOAD: "1", + OPENCODE_CONFIG_CONTENT: JSON.stringify(providerConfig(baseURL)), + PATH: `${path.dirname(BUN_BIN)}:${process.env.PATH ?? ""}`, + } + const proc = Bun.spawn([BUN_BIN, "run", "--conditions=browser", "./src/index.ts", "serve", "--port", "0", "--hostname", "127.0.0.1"], { + cwd: PACKAGE_DIR, + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }) + openCode = { process: proc, output } + + let resolved = false + let resolvePort!: (port: number) => void + let rejectPort!: (error: Error) => void + const ready = new Promise((resolve, reject) => { + resolvePort = resolve + rejectPort = reject + }) + + const onText = (text: string) => { + output.push(text) + const match = /opencode server listening on http:\/\/127\.0\.0\.1:(\d+)/.exec(output.join("")) + if (!resolved && match) { + resolved = true + resolvePort(Number(match[1])) + } + } + void readStream(proc.stdout, onText) + void readStream(proc.stderr, onText) + void proc.exited.then((code) => { + if (!resolved) { + resolved = true + rejectPort(new Error(`opencode serve exited before ready: ${code}\n${output.join("")}`)) + } + }) + + const port = await Promise.race([ + ready, + sleep(20_000).then(() => { + throw new Error(`timed out waiting for opencode serve\n${output.join("")}`) + }), + ]) + return { port, output } +} + +function authHeaders() { + return { + Authorization: `Basic ${Buffer.from("opencode:test").toString("base64")}`, + "x-opencode-directory": PROJECT_DIR, + "Content-Type": "application/json", + } +} + +async function requestJson(base: string, route: string, init: RequestInit = {}): Promise { + const response = await fetch(`${base}${route}`, { + ...init, + headers: { ...authHeaders(), ...(init.headers ?? {}) }, + }) + const text = await response.text() + if (!response.ok) throw new Error(`${init.method ?? "GET"} ${route} -> ${response.status}: ${text}`) + if (!text) return undefined as T + return JSON.parse(text) as T +} + +async function waitForIdle(base: string, sessionID: string) { + for (let i = 0; i < 100; i++) { + const status = await requestJson>(base, "/session/status") + if (!status[sessionID] || status[sessionID].type === "idle") return + await sleep(50) + } + throw new Error(`session did not become idle: ${sessionID}`) +} + +function count(db: SQLite, sql: string, sessionID: string) { + return (db.query(sql).get(sessionID) as { count: number }).count +} + +function verifyDatabase(sessionID: string) { + const db = new SQLite(DB_PATH, { readonly: true }) + try { + const markerCount = count( + db, + `SELECT COUNT(*) AS count + FROM message m + JOIN part p ON p.message_id = m.id + WHERE json_extract(p.data, '$.type') = 'compaction' + AND json_extract(m.data, '$.role') = 'user' + AND m.session_id = ?`, + sessionID, + ) + const assistantCount = count( + db, + `SELECT COUNT(*) AS count + FROM message + WHERE json_extract(data, '$.agent') = 'compaction' + AND json_extract(data, '$.role') = 'assistant' + AND session_id = ?`, + sessionID, + ) + const marker = db + .query( + `SELECT m.id AS marker_id + FROM message m + JOIN part p ON p.message_id = m.id + WHERE json_extract(p.data, '$.type') = 'compaction' + AND json_extract(m.data, '$.role') = 'user' + AND m.session_id = ?`, + ) + .get(sessionID) as { marker_id: string } | null + const assistant = db + .query( + `SELECT json_extract(data, '$.parentID') AS parent_id + FROM message + WHERE json_extract(data, '$.agent') = 'compaction' + AND json_extract(data, '$.role') = 'assistant' + AND session_id = ?`, + ) + .get(sessionID) as { parent_id: string } | null + const nonMarkerParentCount = count( + db, + `SELECT COUNT(*) AS count + FROM message a + JOIN message parent ON parent.id = json_extract(a.data, '$.parentID') + WHERE a.session_id = ? + AND json_extract(a.data, '$.agent') = 'compaction' + AND json_extract(a.data, '$.role') = 'assistant' + AND NOT EXISTS ( + SELECT 1 FROM part pp + WHERE pp.message_id = parent.id + AND json_extract(pp.data, '$.type') = 'compaction' + )`, + sessionID, + ) + const orphanMarkerCount = count( + db, + `SELECT COUNT(*) AS count + FROM message marker + JOIN part p ON p.message_id = marker.id + WHERE marker.session_id = ? + AND json_extract(marker.data, '$.role') = 'user' + AND json_extract(p.data, '$.type') = 'compaction' + AND NOT EXISTS ( + SELECT 1 FROM message a + WHERE a.session_id = marker.session_id + AND json_extract(a.data, '$.agent') = 'compaction' + AND json_extract(a.data, '$.role') = 'assistant' + AND json_extract(a.data, '$.parentID') = marker.id + )`, + sessionID, + ) + + expect(markerCount).toBe(1) + expect(assistantCount).toBe(1) + expect(marker?.marker_id).toBeTruthy() + expect(assistant?.parent_id).toBe(marker?.marker_id) + expect(nonMarkerParentCount).toBe(0) + expect(orphanMarkerCount).toBe(0) + + return { + markerCount, + assistantCount, + markerID: marker?.marker_id, + assistantParentID: assistant?.parent_id, + nonMarkerParentCount, + orphanMarkerCount, + } + } finally { + db.close() + } +} + +async function stopOpenCode() { + const proc = openCode?.process + if (!proc) return + proc.kill("SIGTERM") + await Promise.race([ + proc.exited, + sleep(1_500).then(() => { + proc.kill("SIGKILL") + }), + ]) +} + +afterAll(async () => { + await stopOpenCode() + llm?.stop() + if (passed) await fs.rm(RUN_ROOT, { recursive: true, force: true }) + else console.error(`Preserving runtime E2E temp directory: ${RUN_ROOT}`) +}) + +describe("runtime compaction dedupe e2e", () => { + test("dedupes concurrent compact overlap through real serve process", async () => { + await initProject() + llm = new MockLLMServer() + const { port } = await startOpenCode(llm.url) + const base = `http://127.0.0.1:${port}` + + await requestJson(base, "/global/health") + + const session = await requestJson<{ id: string }>(base, "/session", { + method: "POST", + body: JSON.stringify({}), + }) + const sessionID = session.id + + await requestJson(base, `/session/${sessionID}/message`, { + method: "POST", + body: JSON.stringify({ + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "Seed the runtime compaction E2E session." }], + }), + }) + await waitForIdle(base, sessionID) + + const summarize = () => + requestJson(base, `/session/${sessionID}/summarize`, { + method: "POST", + body: JSON.stringify({ providerID: "test", modelID: "test-model", auto: false }), + }) + + const results = await Promise.allSettled([summarize(), summarize()]) + for (const result of results) { + if (result.status === "rejected") throw result.reason + } + await waitForIdle(base, sessionID) + + const dbResult = verifyDatabase(sessionID) + const compactCalls = llm.requests.filter((request) => request.compaction).length + expect(compactCalls).toBeGreaterThanOrEqual(1) + + console.log( + JSON.stringify( + { + sessionID, + dbPath: DB_PATH, + projectDir: PROJECT_DIR, + mockLLMRequests: llm.requests.length, + compactCalls, + ...dbResult, + }, + null, + 2, + ), + ) + + passed = true + }, 45_000) +}) From a96b544354f190a58ec899babb7b6aa49f34b282 Mon Sep 17 00:00:00 2001 From: Dandi007 Date: Fri, 15 May 2026 03:16:29 +0800 Subject: [PATCH 06/11] fix(session): allow new compaction after errored one Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/opencode/src/session/compaction.ts | 2 +- .../opencode/test/session/compaction.test.ts | 58 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 36bbaa08c923..e4579014d70e 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -129,7 +129,7 @@ function activeCompactionMarker(messages: MessageV2.WithParts[]) { for (const msg of messages) { if (msg.info.role !== "assistant") continue if (msg.info.agent !== "compaction") continue - if (!msg.info.summary || !msg.info.finish || msg.info.error) continue + if (!msg.info.summary || !msg.info.finish) continue completed.add(msg.info.parentID) } diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 63068ec346fa..1109306af642 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -737,6 +737,64 @@ describe("session.compaction.create", () => { }), ), ) + + it.live( + "allows new marker after errored compaction", + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const compact = yield* SessionCompaction.Service + const ssn = yield* SessionNs.Service + + const info = yield* ssn.create({}) + yield* compact.create({ + sessionID: info.id, + agent: "build", + model: ref, + auto: true, + }) + const first = compactionMarkers(yield* ssn.messages({ sessionID: info.id })).at(0) + expect(first).toBeTruthy() + + const errored: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + sessionID: info.id, + mode: "compaction", + agent: "compaction", + path: { cwd: dir, root: dir }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: ref.modelID, + providerID: ref.providerID, + parentID: first!.info.id, + summary: true, + time: { created: Date.now() }, + finish: "error", + error: new MessageV2.ContextOverflowError({ + message: "Session too large to compact", + }).toObject(), + } + yield* ssn.updateMessage(errored) + + yield* compact.create({ + sessionID: info.id, + agent: "build", + model: ref, + auto: true, + }) + + const markers = compactionMarkers(yield* ssn.messages({ sessionID: info.id })) + expect(markers).toHaveLength(2) + expect(markers[0].info.id).toBe(first!.info.id) + expect(markers[1].info.id).not.toBe(first!.info.id) + }), + ), + ) }) describe("session.compaction.prune", () => { From 29db465445bca544b32f2a3fbdfc741d75e0eb0d Mon Sep 17 00:00:00 2001 From: Dandi007 Date: Sat, 16 May 2026 16:40:24 +0800 Subject: [PATCH 07/11] fix(session): adapt compact dedupe to dev --- packages/opencode/src/session/compaction.ts | 41 ++++++++++--------- packages/opencode/src/session/prompt.ts | 12 +++--- .../session/compaction-runtime-e2e.test.ts | 2 +- 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index e4579014d70e..4165fc81bda4 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -23,7 +23,7 @@ import { EventV2 } from "@opencode-ai/core/event" import { EventV2Bridge } from "@/event-v2-bridge" import { SessionEvent } from "@opencode-ai/core/session-event" import { Database } from "@/storage/db" -import { SyncEvent } from "@/sync" +import { MessageTable, PartTable } from "./session.sql" const log = Log.create({ service: "session.compaction" }) @@ -373,7 +373,7 @@ export const layer = Layer.effect( if (!parent || parent.info.role !== "user") { throw new Error(`Compaction parent must be a user message: ${input.parentID}`) } - const existing = (yield* session.messages({ sessionID: input.sessionID })).find( + const existing = Array.from(MessageV2.stream(input.sessionID)).find( (msg) => msg.info.role === "assistant" && msg.info.agent === "compaction" && @@ -621,37 +621,40 @@ export const layer = Layer.effect( }) { const created = yield* Effect.sync(() => Database.transaction( - () => { + (tx) => { const messages = Array.from(MessageV2.stream(input.sessionID)).reverse() - if (activeCompactionMarker(messages)) return false + if (activeCompactionMarker(messages)) return undefined + const now = Date.now() const msg: MessageV2.User = { id: MessageID.ascending(), role: "user", model: input.model, sessionID: input.sessionID, agent: input.agent, - time: { created: Date.now() }, + time: { created: now }, } - SyncEvent.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg }) - SyncEvent.run(MessageV2.Event.PartUpdated, { + const part: MessageV2.CompactionPart = { + id: PartID.ascending(), + messageID: msg.id, sessionID: msg.sessionID, - time: Date.now(), - part: { - id: PartID.ascending(), - messageID: msg.id, - sessionID: msg.sessionID, - type: "compaction", - auto: input.auto, - overflow: input.overflow, - }, - }) - return true + type: "compaction", + auto: input.auto, + overflow: input.overflow, + } + const { id, sessionID, ...info } = msg + const { id: partID, messageID, sessionID: partSessionID, ...partData } = part + tx.insert(MessageTable).values({ id, session_id: sessionID, time_created: now, data: info }).run() + tx.insert(PartTable) + .values({ id: partID, message_id: messageID, session_id: partSessionID, time_created: now, data: partData }) + .run() + return { msg, part, time: now } }, { behavior: "immediate" }, ), ) - if (created && flags.experimentalEventSystem) { + if (!created) return + if (flags.experimentalEventSystem) { yield* events.publish(SessionEvent.Compaction.Started, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 4fe52d91ae17..a869fa0f11c2 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1690,18 +1690,18 @@ NOTE: At any point in time through this workflow you should feel free to ask the const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID) const task = tasks.pop() - if (task?.part.type === "subtask") { - yield* handleSubtask({ task: task.part, model, lastUser, sessionID, session, msgs }) + if (task?.type === "subtask") { + yield* handleSubtask({ task, model, lastUser, sessionID, session, msgs }) continue } - if (task?.part.type === "compaction") { + if (task?.type === "compaction") { const result = yield* compaction.process({ messages: msgs, - parentID: task.part.messageID, + parentID: task.messageID, sessionID, - auto: task.part.auto, - overflow: task.part.overflow, + auto: task.auto, + overflow: task.overflow, }) if (result === "stop") break continue diff --git a/packages/opencode/test/session/compaction-runtime-e2e.test.ts b/packages/opencode/test/session/compaction-runtime-e2e.test.ts index eb30894efc37..a469eed7816d 100644 --- a/packages/opencode/test/session/compaction-runtime-e2e.test.ts +++ b/packages/opencode/test/session/compaction-runtime-e2e.test.ts @@ -106,7 +106,7 @@ function responsesResponse(text: string) { } class MockLLMServer { - readonly server: Bun.Server + readonly server: Bun.Server readonly requests: Array<{ url: string; body: unknown; compaction: boolean }> = [] constructor() { From ed6b00ef80e81a05b0feb981fc17202f25fd031f Mon Sep 17 00:00:00 2001 From: Dandi007 Date: Sat, 16 May 2026 22:16:05 +0800 Subject: [PATCH 08/11] fix(session): recover compaction marker sync Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/opencode/src/session/compaction.ts | 33 +++++- .../opencode/test/session/compaction.test.ts | 105 +++++++++++++++++- 2 files changed, 131 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 4165fc81bda4..355a5326ae5b 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -381,12 +381,31 @@ export const layer = Layer.effect( msg.info.parentID === input.parentID && !msg.info.error, ) - if (existing?.info.role === "assistant") return existing.info.finish ? "continue" : "stop" + let inputMessages = input.messages + if (existing?.info.role === "assistant") { + if (existing.info.finish) return "continue" + const interrupted: MessageV2.Assistant = { + ...existing.info, + finish: "error", + error: MessageV2.fromError(new DOMException("Compaction interrupted before completion", "AbortError"), { + providerID: existing.info.providerID, + aborted: true, + }), + time: { + ...existing.info.time, + completed: existing.info.time.completed ?? Date.now(), + }, + } + yield* session.updateMessage(interrupted) + inputMessages = input.messages.map((msg) => + msg.info.id === interrupted.id ? { ...msg, info: interrupted } : msg, + ) + } const userMessage = parent.info const compactionPart = parent.parts.find((part): part is MessageV2.CompactionPart => part.type === "compaction") - let messages = input.messages + let messages = inputMessages let replay: | { info: MessageV2.User @@ -394,12 +413,12 @@ export const layer = Layer.effect( } | undefined if (input.overflow) { - const idx = input.messages.findIndex((m) => m.info.id === input.parentID) + const idx = inputMessages.findIndex((m) => m.info.id === input.parentID) for (let i = idx - 1; i >= 0; i--) { - const msg = input.messages[i] + const msg = inputMessages[i] if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) { replay = { info: msg.info, parts: msg.parts } - messages = input.messages.slice(0, i) + messages = inputMessages.slice(0, i) break } } @@ -407,7 +426,7 @@ export const layer = Layer.effect( replay && messages.some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction")) if (!hasContent) { replay = undefined - messages = input.messages + messages = inputMessages } } @@ -654,6 +673,8 @@ export const layer = Layer.effect( ), ) if (!created) return + yield* session.updateMessage(created.msg) + yield* session.updatePart(created.part) if (flags.experimentalEventSystem) { yield* events.publish(SessionEvent.Compaction.Started, { sessionID: input.sessionID, diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 1109306af642..061c312ec3c2 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -194,6 +194,10 @@ function compactionMarkers(messages: MessageV2.WithParts[]) { ) } +function compactionAssistants(messages: MessageV2.WithParts[]) { + return messages.filter((msg) => msg.info.role === "assistant" && msg.info.agent === "compaction") +} + function fake( input: Parameters[0], result: "continue" | "compact", @@ -629,6 +633,45 @@ describe("session.compaction.create", () => { ), ) + it.live( + "publishes message and part events for the created marker", + provideTmpdirInstance(() => + Effect.gen(function* () { + const compact = yield* SessionCompaction.Service + const ssn = yield* SessionNs.Service + const bus = yield* Bus.Service + const info = yield* ssn.create({}) + const messageSeen = yield* Deferred.make() + const partSeen = yield* Deferred.make() + + const unsubMessage = yield* bus.subscribeCallback(MessageV2.Event.Updated, (event) => { + if (event.properties.info.sessionID !== info.id) return + if (event.properties.info.role !== "user") return + Deferred.doneUnsafe(messageSeen, Effect.void) + }) + yield* Effect.addFinalizer(() => Effect.sync(unsubMessage)) + + const unsubPart = yield* bus.subscribeCallback(MessageV2.Event.PartUpdated, (event) => { + if (event.properties.part.sessionID !== info.id) return + if (event.properties.part.type !== "compaction") return + Deferred.doneUnsafe(partSeen, Effect.void) + }) + yield* Effect.addFinalizer(() => Effect.sync(unsubPart)) + + yield* compact.create({ + sessionID: info.id, + agent: "build", + model: ref, + auto: true, + overflow: true, + }) + + yield* Deferred.await(messageSeen).pipe(Effect.timeout("500 millis")) + yield* Deferred.await(partSeen).pipe(Effect.timeout("500 millis")) + }), + ), + ) + it.live( "merges duplicate active compaction creates", provideTmpdirInstance(() => @@ -1047,7 +1090,7 @@ describe("session.compaction.process", () => { ) itCompaction.instance( - "does not create duplicate assistants for the same marker", + "does not create duplicate assistants for a completed marker", Effect.gen(function* () { const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) @@ -1063,6 +1106,12 @@ describe("session.compaction.process", () => { sessionID: session.id, auto: false, }) + const firstSummary = (yield* ssn.messages({ sessionID: session.id })).find( + (msg) => msg.info.role === "assistant" && msg.info.agent === "compaction", + ) + expect(firstSummary?.info.role).toBe("assistant") + if (firstSummary?.info.role !== "assistant") return + yield* ssn.updateMessage({ ...firstSummary.info, finish: "end_turn" }) yield* SessionCompaction.use.process({ parentID: marker!.info.id, messages: msgs, @@ -1079,6 +1128,60 @@ describe("session.compaction.process", () => { }).pipe(withCompaction()), ) + itCompaction.instance( + "retries unfinished compaction assistant for the same marker", + Effect.gen(function* () { + const test = yield* TestInstance + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "hello") + yield* SessionCompaction.use.create({ sessionID: session.id, agent: "build", model: ref, auto: false }) + const msgs = yield* ssn.messages({ sessionID: session.id }) + const marker = compactionMarkers(msgs).at(0) + expect(marker).toBeTruthy() + + const stale: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + sessionID: session.id, + mode: "compaction", + agent: "compaction", + path: { cwd: test.directory, root: test.directory }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: ref.modelID, + providerID: ref.providerID, + parentID: marker!.info.id, + summary: true, + time: { created: Date.now() }, + } + yield* ssn.updateMessage(stale) + + const result = yield* SessionCompaction.use.process({ + parentID: marker!.info.id, + messages: msgs, + sessionID: session.id, + auto: false, + }) + + const summaries = compactionAssistants(yield* ssn.messages({ sessionID: session.id })) + expect(result).toBe("continue") + expect(summaries).toHaveLength(2) + expect(summaries[0]?.info.role).toBe("assistant") + if (summaries[0]?.info.role === "assistant") { + expect(summaries[0].info.error?.name).toBe("MessageAbortedError") + expect(summaries[0].info.finish).toBe("error") + } + expect(summaries[1]?.info.role).toBe("assistant") + if (summaries[1]?.info.role === "assistant") expect(summaries[1].info.parentID).toBe(marker!.info.id) + }).pipe(withCompaction()), + ) + itCompaction.instance( "marks summary message as errored on compact result", Effect.gen(function* () { From bba7165b4cb8d6dcf0c1daa488eda9e03e4e9791 Mon Sep 17 00:00:00 2001 From: Dandi007 Date: Sat, 16 May 2026 23:21:24 +0800 Subject: [PATCH 09/11] fix(session): recover stale compaction markers Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/opencode/src/session/compaction.ts | 245 ++++++++++-------- .../opencode/test/session/compaction.test.ts | 72 +++++ 2 files changed, 211 insertions(+), 106 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 355a5326ae5b..5fc68276bb40 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -126,8 +126,10 @@ function completedCompactions(messages: MessageV2.WithParts[]) { function activeCompactionMarker(messages: MessageV2.WithParts[]) { const completed = new Set() + let latestFinished: MessageV2.Assistant | undefined for (const msg of messages) { if (msg.info.role !== "assistant") continue + if (msg.info.finish && (!latestFinished || msg.info.id > latestFinished.id)) latestFinished = msg.info if (msg.info.agent !== "compaction") continue if (!msg.info.summary || !msg.info.finish) continue completed.add(msg.info.parentID) @@ -137,6 +139,7 @@ function activeCompactionMarker(messages: MessageV2.WithParts[]) { (msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction") && + (!latestFinished || msg.info.id > latestFinished.id) && !completed.has(msg.info.id), ) } @@ -382,24 +385,27 @@ export const layer = Layer.effect( !msg.info.error, ) let inputMessages = input.messages + let existingFinished: MessageV2.WithParts | undefined if (existing?.info.role === "assistant") { - if (existing.info.finish) return "continue" - const interrupted: MessageV2.Assistant = { - ...existing.info, - finish: "error", - error: MessageV2.fromError(new DOMException("Compaction interrupted before completion", "AbortError"), { - providerID: existing.info.providerID, - aborted: true, - }), - time: { - ...existing.info.time, - completed: existing.info.time.completed ?? Date.now(), - }, + if (existing.info.finish) existingFinished = existing + else { + const interrupted: MessageV2.Assistant = { + ...existing.info, + finish: "error", + error: MessageV2.fromError(new DOMException("Compaction interrupted before completion", "AbortError"), { + providerID: existing.info.providerID, + aborted: true, + }), + time: { + ...existing.info.time, + completed: existing.info.time.completed ?? Date.now(), + }, + } + yield* session.updateMessage(interrupted) + inputMessages = input.messages.map((msg) => + msg.info.id === interrupted.id ? { ...msg, info: interrupted } : msg, + ) } - yield* session.updateMessage(interrupted) - inputMessages = input.messages.map((msg) => - msg.info.id === interrupted.id ? { ...msg, info: interrupted } : msg, - ) } const userMessage = parent.info @@ -444,6 +450,122 @@ export const layer = Layer.effect( cfg, model, }) + + const markerBookkeeping = Effect.gen(function* () { + if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) { + yield* session.updatePart({ + ...compactionPart, + tail_start_id: selected.tail_start_id, + }) + } + + const hasPostSummaryUser = + existingFinished && + inputMessages.some( + (msg) => + msg.info.role === "user" && + msg.info.id > existingFinished.info.id && + !msg.parts.some((part) => part.type === "compaction"), + ) + if (input.auto && !hasPostSummaryUser) { + if (replay) { + const original = replay.info + const replayMsg = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: input.sessionID, + time: { created: Date.now() }, + agent: original.agent, + model: original.model, + format: original.format, + tools: original.tools, + system: original.system, + }) + for (const part of replay.parts) { + if (part.type === "compaction") continue + const replayPart = + part.type === "file" && MessageV2.isMedia(part.mime) + ? { type: "text" as const, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` } + : part + yield* session.updatePart({ + ...replayPart, + id: PartID.ascending(), + messageID: replayMsg.id, + sessionID: input.sessionID, + }) + } + } + + if (!replay) { + const info = yield* provider.getProvider(userMessage.model.providerID) + if ( + (yield* plugin.trigger( + "experimental.compaction.autocontinue", + { + sessionID: input.sessionID, + agent: userMessage.agent, + model: yield* provider + .getModel(userMessage.model.providerID, userMessage.model.modelID) + .pipe(Effect.orDie), + provider: { + source: info.source, + info, + options: info.options, + }, + message: userMessage, + overflow: input.overflow === true, + }, + { enabled: true }, + )).enabled + ) { + const continueMsg = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: input.sessionID, + time: { created: Date.now() }, + agent: userMessage.agent, + model: userMessage.model, + }) + const text = + (input.overflow + ? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n" + : "") + + "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed." + yield* session.updatePart({ + id: PartID.ascending(), + messageID: continueMsg.id, + sessionID: input.sessionID, + type: "text", + // Internal marker for auto-compaction followups so provider plugins + // can distinguish them from manual post-compaction user prompts. + // This is not a stable plugin contract and may change or disappear. + metadata: { compaction_continue: true }, + synthetic: true, + text, + time: { + start: Date.now(), + end: Date.now(), + }, + }) + } + } + } + }) + + if (existingFinished) { + yield* markerBookkeeping + if (flags.experimentalEventSystem) { + yield* events.publish(SessionEvent.Compaction.Ended, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + text: summaryText(existingFinished) ?? "", + include: selected.tail_start_id, + }) + } + yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) + return "continue" + } + // Allow plugins to inject context or replace compaction prompt. const compacting = yield* plugin.trigger( "experimental.session.compacting", @@ -517,96 +639,7 @@ export const layer = Layer.effect( return "stop" } - if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) { - yield* session.updatePart({ - ...compactionPart, - tail_start_id: selected.tail_start_id, - }) - } - - if (result === "continue" && input.auto) { - if (replay) { - const original = replay.info - const replayMsg = yield* session.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID: input.sessionID, - time: { created: Date.now() }, - agent: original.agent, - model: original.model, - format: original.format, - tools: original.tools, - system: original.system, - }) - for (const part of replay.parts) { - if (part.type === "compaction") continue - const replayPart = - part.type === "file" && MessageV2.isMedia(part.mime) - ? { type: "text" as const, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` } - : part - yield* session.updatePart({ - ...replayPart, - id: PartID.ascending(), - messageID: replayMsg.id, - sessionID: input.sessionID, - }) - } - } - - if (!replay) { - const info = yield* provider.getProvider(userMessage.model.providerID) - if ( - (yield* plugin.trigger( - "experimental.compaction.autocontinue", - { - sessionID: input.sessionID, - agent: userMessage.agent, - model: yield* provider - .getModel(userMessage.model.providerID, userMessage.model.modelID) - .pipe(Effect.orDie), - provider: { - source: info.source, - info, - options: info.options, - }, - message: userMessage, - overflow: input.overflow === true, - }, - { enabled: true }, - )).enabled - ) { - const continueMsg = yield* session.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID: input.sessionID, - time: { created: Date.now() }, - agent: userMessage.agent, - model: userMessage.model, - }) - const text = - (input.overflow - ? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n" - : "") + - "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed." - yield* session.updatePart({ - id: PartID.ascending(), - messageID: continueMsg.id, - sessionID: input.sessionID, - type: "text", - // Internal marker for auto-compaction followups so provider plugins - // can distinguish them from manual post-compaction user prompts. - // This is not a stable plugin contract and may change or disappear. - metadata: { compaction_continue: true }, - synthetic: true, - text, - time: { - start: Date.now(), - end: Date.now(), - }, - }) - } - } - } + yield* markerBookkeeping if (processor.message.error) return "stop" if (result === "continue") { diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 061c312ec3c2..84ac36026e0f 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -781,6 +781,41 @@ describe("session.compaction.create", () => { ), ) + it.live( + "ignores stale markers older than the latest finished assistant", + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const compact = yield* SessionCompaction.Service + const ssn = yield* SessionNs.Service + + const info = yield* ssn.create({}) + yield* compact.create({ + sessionID: info.id, + agent: "build", + model: ref, + auto: true, + }) + const stale = compactionMarkers(yield* ssn.messages({ sessionID: info.id })).at(0) + expect(stale).toBeTruthy() + + const laterUser = yield* createUserMessage(info.id, "later turn") + yield* createAssistantMessage(info.id, laterUser.id, dir) + + yield* compact.create({ + sessionID: info.id, + agent: "build", + model: ref, + auto: true, + }) + + const markers = compactionMarkers(yield* ssn.messages({ sessionID: info.id })) + expect(markers).toHaveLength(2) + expect(markers[0].info.id).toBe(stale!.info.id) + expect(markers[1].info.id).not.toBe(stale!.info.id) + }), + ), + ) + it.live( "allows new marker after errored compaction", provideTmpdirInstance((dir) => @@ -1241,6 +1276,43 @@ describe("session.compaction.process", () => { }), ) + itCompaction.instance( + "finishes marker bookkeeping for an existing completed summary", + Effect.gen(function* () { + const test = yield* TestInstance + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "first") + const keep = yield* createUserMessage(session.id, "second") + yield* createSummaryCompaction(session.id) + let msgs = yield* ssn.messages({ sessionID: session.id }) + const marker = compactionMarkers(msgs).at(0) + expect(marker).toBeTruthy() + yield* createSummaryAssistantMessage(session.id, marker!.info.id, test.directory, "finished summary") + msgs = yield* ssn.messages({ sessionID: session.id }) + + const result = yield* SessionCompaction.use.process({ + parentID: marker!.info.id, + messages: msgs, + sessionID: session.id, + auto: true, + }) + + const all = yield* ssn.messages({ sessionID: session.id }) + const updatedMarker = compactionMarkers(all).find((item) => item.info.id === marker!.info.id) + const part = updatedMarker?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction") + const continues = all.filter( + (msg) => + msg.info.role === "user" && + msg.parts.some((item) => item.type === "text" && item.synthetic && item.metadata?.compaction_continue === true), + ) + + expect(result).toBe("continue") + expect(part?.tail_start_id).toBe(keep.id) + expect(continues).toHaveLength(1) + }).pipe(withCompaction({ config: cfg({ tail_turns: 1, preserve_recent_tokens: 10_000 }) })), + ) + itCompaction.instance( "persists tail_start_id for retained recent turns", Effect.gen(function* () { From 826945163a1dbc0863a740386645f0a00369bad4 Mon Sep 17 00:00:00 2001 From: Dandi007 Date: Sat, 16 May 2026 23:21:37 +0800 Subject: [PATCH 10/11] test(session): use current bun executable Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/opencode/test/session/compaction-runtime-e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/session/compaction-runtime-e2e.test.ts b/packages/opencode/test/session/compaction-runtime-e2e.test.ts index a469eed7816d..35175411e6fc 100644 --- a/packages/opencode/test/session/compaction-runtime-e2e.test.ts +++ b/packages/opencode/test/session/compaction-runtime-e2e.test.ts @@ -11,7 +11,7 @@ const HOME_DIR = path.join(RUN_ROOT, "home") const DB_DIR = path.join(RUN_ROOT, "db") const DB_PATH = path.join(DB_DIR, "opencode.db") const PACKAGE_DIR = path.resolve(import.meta.dir, "../..") -const BUN_BIN = path.join(os.homedir(), ".bun/bin/bun") +const BUN_BIN = process.execPath type OpenCodeProcess = { process: Bun.Subprocess<"ignore", "pipe", "pipe"> From eb0427396ef4192eb940c130cf8a58b0bf13bc93 Mon Sep 17 00:00:00 2001 From: Dandi007 Date: Sun, 17 May 2026 13:35:13 +0800 Subject: [PATCH 11/11] chore: stamp qinglin dev versions --- bun.lock | 34 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/script/src/index.ts | 1 + packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- script/publish.ts | 1 + sdks/vscode/package.json | 2 +- 21 files changed, 37 insertions(+), 35 deletions(-) diff --git a/bun.lock b/bun.lock index fe833a1785fa..12c20ac50b50 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -84,7 +84,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -119,7 +119,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -146,7 +146,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -168,7 +168,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -192,7 +192,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "bin": { "opencode": "./bin/opencode", }, @@ -253,7 +253,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -307,7 +307,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -337,7 +337,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -353,7 +353,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -366,7 +366,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -384,7 +384,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "bin": { "opencode": "./bin/opencode", }, @@ -520,7 +520,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -558,7 +558,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "dependencies": { "cross-spawn": "catalog:", }, @@ -573,7 +573,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -608,7 +608,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -657,7 +657,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 85d9f6c42601..c7561d054c0b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 18465e26e6fc..90a995efca81 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 5aa0ad949376..add2d670021a 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 9922e22d6b24..c9c4100bddbb 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 2def12ea1d28..70300f5ce2be 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index d4b2886155e6..bab83350a04f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 3099ee55aecb..d0ac5fa9a9d0 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 563582985910..38698e2509eb 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index de6852eca5f5..61fa96317994 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index badbdab364e2..95a3e0f12bd5 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/llm/package.json b/packages/llm/package.json index abcc02d0143b..39fdd6c3c7ca 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f60d39774094..fa70c5943cd2 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index e8bb59f5085b..7000514bc2a7 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/src/index.ts b/packages/script/src/index.ts index d148ce0d2b70..e59ac7750662 100644 --- a/packages/script/src/index.ts +++ b/packages/script/src/index.ts @@ -32,6 +32,7 @@ const CHANNEL = await (async () => { const IS_PREVIEW = CHANNEL !== "latest" const VERSION = await (async () => { + // Personal fork builds pass OPENCODE_VERSION as -qinglin-dev.; keep it authoritative. if (env.OPENCODE_VERSION) return env.OPENCODE_VERSION if (IS_PREVIEW) return `0.0.0-${CHANNEL}-${new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "")}` const version = await fetch("https://registry.npmjs.org/opencode-ai/latest") diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 5f2a0af2ed8a..cc5006e4b3c4 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 00ba712c249c..02e516acd85a 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 3e30ff86ec21..cb4efcbb308f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index ce3ca7519abd..7f65ac566627 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/script/publish.ts b/script/publish.ts index 7e91eef762dc..43c864c04f86 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -17,6 +17,7 @@ const pkgjsons = await Array.fromAsync( ).then((arr) => arr.filter((x) => !x.includes("node_modules") && !x.includes("dist"))) async function prepareReleaseFiles() { + // Script.version is the single release stamp; personal fork builds use the qinglin-dev prerelease label here too. for (const file of pkgjsons) { let pkg = await Bun.file(file).text() pkg = pkg.replaceAll(/"version": "[^"]+"/g, `"version": "${Script.version}"`) diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 99c182eda9ca..57b21e615703 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.15.1", + "version": "1.15.1-qinglin-dev.826945163a1d", "publisher": "sst-dev", "repository": { "type": "git",