From 93341c1b4ac6b8e3c099ac91de43c7bf87ea82bc Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 4 Sep 2026 11:52:51 +0800 Subject: [PATCH 1/7] chore: record delivery binding for native-tool-settlement --- .specgit.yaml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 5c6fea502..4d965e431 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,11 +1,10 @@ version: 1 -delivery: sync-v1-0-39 +delivery: native-tool-settlement context: kind: branch - branch: chore/517-sync-v1-0-39 + branch: fix/538-native-tool-settlement issues: - - 517 + - 538 issueKinds: - - issue: 517 - kind: kind::chore -pr: 518 + - issue: 538 + kind: kind::fix From 8b76952eb2e06395b2befaedf844ab3b7ba6ebcd Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 4 Sep 2026 11:52:58 +0800 Subject: [PATCH 2/7] chore: record delivery binding for native-tool-settlement --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 4d965e431..b12c4aa0a 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -8,3 +8,4 @@ issues: issueKinds: - issue: 538 kind: kind::fix +pr: 539 From 1d37a58e347e6f8ae4183a7456c1e1109c35a73c Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 4 Sep 2026 11:59:25 +0800 Subject: [PATCH 3/7] fix(session): settle native tools before compaction --- .../src/session/llm/native-runtime.ts | 20 +- .../opencode/test/session/llm-native.test.ts | 7 +- .../test/session/processor-effect.test.ts | 210 +++++++++++++++++- 3 files changed, 228 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/session/llm/native-runtime.ts b/packages/opencode/src/session/llm/native-runtime.ts index 02a3c1902..cfce400d4 100644 --- a/packages/opencode/src/session/llm/native-runtime.ts +++ b/packages/opencode/src/session/llm/native-runtime.ts @@ -105,6 +105,7 @@ export function stream(input: StreamInput): StreamResult { Effect.gen(function* () { const settlements = yield* FiberSet.make() const results = yield* Queue.unbounded() + const completion: LLMEvent[] = [] const provider = input.llmClient .stream( LLMRequest.update(request, { @@ -112,8 +113,14 @@ export function stream(input: StreamInput): StreamResult { }), ) .pipe( - Stream.flatMap((event) => - event.type !== "tool-call" || event.providerExecuted + Stream.flatMap((event) => { + // The processor may close the stream for compaction at step-finish. + // Deliver every local settlement before exposing that boundary. + if (event.type === "step-finish" || event.type === "finish") { + completion.push(event) + return Stream.empty + } + return event.type !== "tool-call" || event.providerExecuted ? Stream.make(event) : Stream.make(event).pipe( Stream.concat( @@ -126,15 +133,18 @@ export function stream(input: StreamInput): StreamResult { ), ), ), - ), - ), + ) + }), Stream.concat( Stream.fromEffectDrain( FiberSet.awaitEmpty(settlements).pipe(Effect.andThen(Queue.end(results)), Effect.asVoid), ), ), ) - return provider.pipe(Stream.concat(Stream.fromQueue(results))) + return provider.pipe( + Stream.concat(Stream.fromQueue(results)), + Stream.concat(Stream.suspend(() => Stream.fromIterable(completion))), + ) }), ), ) diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index 3be43cf92..aa7219a09 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -557,7 +557,7 @@ describe("session.llm-native.request", () => { }), ) - it.effect("emits native tool calls before overlapping local settlements complete", () => + it.effect("settles parallel native tools before completing the provider step", () => Effect.gen(function* () { const observed: string[] = [] const started: string[] = [] @@ -585,6 +585,7 @@ describe("session.llm-native.request", () => { Stream.fromIterable([ LLMEvent.toolCall({ id: "call-1", name: "lookup", input: {} }), LLMEvent.toolCall({ id: "call-2", name: "lookup", input: {} }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls", usage: { inputTokens: 30_000, outputTokens: 1 } }), LLMEvent.finish({ reason: "tool-calls" }), ]), generate: () => Effect.die("unused"), @@ -609,11 +610,11 @@ describe("session.llm-native.request", () => { yield* Effect.promise(() => bothStarted) expect(started).toEqual(["call-1", "call-2"]) - expect(observed).toEqual(["tool-call", "tool-call", "finish"]) + expect(observed).toEqual(["tool-call", "tool-call"]) release?.() yield* Fiber.join(fiber) - expect(observed).toEqual(["tool-call", "tool-call", "finish", "tool-result", "tool-result"]) + expect(observed).toEqual(["tool-call", "tool-call", "tool-result", "tool-result", "step-finish", "finish"]) }), ) diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 106067c6b..99ecb9465 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -19,7 +19,7 @@ import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" -import { testEffect } from "../lib/effect" +import { awaitWithTimeout, testEffect } from "../lib/effect" import { raw, reply, TestLLMServer } from "../lib/llm-server" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -184,6 +184,18 @@ const env = LayerNode.buildLayer(LayerNode.group([root, LayerNode.make(TestLLMSe const it = testEffect(env) +const native = testEffect( + LayerNode.buildLayer(LayerNode.group([root, LayerNode.make(TestLLMServer.layer, [])]), { + replacements: [ + LayerNode.replace(SessionSummary.node, summary), + LayerNode.replace( + RuntimeFlags.node, + RuntimeFlags.layer({ experimentalEventSystem: true, experimentalNativeLlm: true }), + ), + ], + }), +) + const providerErrorLLM = Layer.succeed( LLM.Service, LLM.Service.of({ @@ -824,6 +836,202 @@ it.live("session.processor effect tests complete AI SDK tool calls when native f ), ) +native.live("native tools settle before high usage requests compaction", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + yield* llm.push(reply().tool("lookup", { query: "weather" }).usage({ input: 30_000, output: 1 })) + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "finish the slow lookup before compacting") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const model = { + ...(yield* provider.getModel(ref.providerID, ref.modelID)), + limit: { context: 32_000, output: 4_000 }, + } + const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model }) + const value = yield* handle.process({ + user: parent, + sessionID: chat.id, + model, + agent: agent(), + system: [], + messages: [{ role: "user", content: "finish the slow lookup before compacting" }], + tools: { + lookup: tool({ + description: "Delayed lookup", + inputSchema: z.object({ query: z.string() }), + execute: async (input, options) => { + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, 500) + options.abortSignal?.addEventListener( + "abort", + () => { + clearTimeout(timer) + reject(new Error("lookup interrupted")) + }, + { once: true }, + ) + }) + return { title: "Lookup", output: `result:${input.query}`, metadata: {} } + }, + }), + }, + }) + const parts = yield* MessageV2.parts(msg.id) + const call = parts.find((part): part is SessionV1.ToolPart => part.type === "tool") + expect(value).toBe("compact") + expect(call?.state.status).toBe("completed") + if (call?.state.status !== "completed") return + expect(call.state.output).toBe("result:weather") + expect(call.state.input).toEqual({ query: "weather" }) + expect(handle.message.tokens.input).toBe(30_000) + }), + { config: (url) => providerCfg(url) }, + ), +) + +native.live("native parallel tools all deliver results before compaction", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + yield* llm.push( + raw({ + chunks: [ + { + id: "chatcmpl-parallel", + object: "chat.completion.chunk", + choices: [ + { + index: 0, + delta: { + tool_calls: ["first", "second"].map((query, index) => ({ + index, + id: `call_${query}`, + type: "function", + function: { name: "lookup", arguments: JSON.stringify({ query }) }, + })), + }, + finish_reason: "tool_calls", + }, + ], + usage: { prompt_tokens: 30_000, completion_tokens: 1, total_tokens: 30_001 }, + }, + ], + }), + ) + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "complete both lookups") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const model = { + ...(yield* provider.getModel(ref.providerID, ref.modelID)), + limit: { context: 32_000, output: 4_000 }, + } + const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model }) + const started: string[] = [] + const bothStarted = defer() + const value = yield* handle + .process({ + user: parent, + sessionID: chat.id, + model, + agent: agent(), + system: [], + messages: [{ role: "user", content: "complete both lookups" }], + tools: { + lookup: tool({ + description: "Parallel lookup", + inputSchema: z.object({ query: z.string() }), + execute: async (input) => { + started.push(input.query) + if (started.length === 2) bothStarted.resolve() + await bothStarted.promise + return { title: "Lookup", output: `result:${input.query}`, metadata: {} } + }, + }), + }, + }) + .pipe((effect) => awaitWithTimeout(effect, "parallel native tools did not complete", "5 seconds")) + expect(value).toBe("compact") + const calls = (yield* MessageV2.parts(msg.id)).filter((part) => part.type === "tool") + expect( + calls.map((part) => ({ + id: part.callID, + state: part.state.status, + output: part.state.status === "completed" ? part.state.output : undefined, + })), + ).toEqual([ + { id: "call_first", state: "completed", output: "result:first" }, + { id: "call_second", state: "completed", output: "result:second" }, + ]) + }), + { config: (url) => providerCfg(url) }, + ), +) + +native.live("user interruption still aborts a native tool waiting to settle", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + yield* llm.push(reply().tool("lookup", { query: "weather" }).usage({ input: 30_000, output: 1 })) + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "cancel the lookup") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const model = { + ...(yield* provider.getModel(ref.providerID, ref.modelID)), + limit: { context: 32_000, output: 4_000 }, + } + const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model }) + const started = defer() + let aborted = false + const run = yield* handle + .process({ + user: parent, + sessionID: chat.id, + model, + agent: agent(), + system: [], + messages: [{ role: "user", content: "cancel the lookup" }], + tools: { + lookup: tool({ + description: "Pending lookup", + inputSchema: z.object({ query: z.string() }), + execute: async (_input, options) => { + await new Promise((_resolve, reject) => { + options.abortSignal?.addEventListener( + "abort", + () => { + aborted = true + reject(new Error("lookup interrupted")) + }, + { once: true }, + ) + started.resolve() + }) + return { title: "Lookup", output: "unexpected completion", metadata: {} } + }, + }), + }, + }) + .pipe(Effect.forkChild) + yield* awaitWithTimeout( + Effect.promise(() => started.promise), + "native tool did not start", + ) + yield* awaitWithTimeout(Fiber.interrupt(run), "native tool ignored user interruption") + expect(aborted).toBe(true) + const exit = yield* Fiber.await(run) + expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true) + const call = (yield* MessageV2.parts(msg.id)).find((part) => part.type === "tool") + expect(call?.state.status).toBe("error") + if (call?.state.status === "error") expect(call.state.metadata?.interrupted).toBe(true) + }), + { config: (url) => providerCfg(url) }, + ), +) + it.live("session.processor effect tests mark pending tools as aborted on cleanup", () => provideTmpdirServer( ({ dir, llm }) => From 8d9972908c308da1836a004cebe27c7c23db1acc Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 4 Sep 2026 12:01:22 +0800 Subject: [PATCH 4/7] docs(release): describe native tool settlement and integrated fixes --- .github/releases/v1.0.40.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/releases/v1.0.40.md diff --git a/.github/releases/v1.0.40.md b/.github/releases/v1.0.40.md new file mode 100644 index 000000000..5cc052090 --- /dev/null +++ b/.github/releases/v1.0.40.md @@ -0,0 +1,37 @@ +## opencode {VERSION} + +{Prerelease/Stable} release from `{branch}` branch. Native LLM requests now settle local tools before automatic compaction can close the stream. This release also includes the reviewed event-storage, summary-diff, delivery, and macOS installer fixes already integrated into dev. + +--- + +### ๐Ÿ› Bug Fixes + +- **Tools survive automatic compaction, #539**: a high-usage `step-finish` could abort a slow local tool before its result reached the session processor. Native LLM now delivers all local tool results before terminal events. Parallel tools settle completely; explicit user cancellation still interrupts execution. +- **Summary diffs retain later small entries, #526**: skip an oversized diff individually instead of dropping every following entry. Remove the unused legacy `session.summary_diffs` column through a tested database migration. +- **Identical durable events no longer consume storage or sequence numbers, #527**: suppress byte-identical fresh appends within the same aggregate/type while preserving explicit-sequence replay. Batch results retain input alignment; legacy rows require no hash backfill. + +--- + +### โš™๏ธ CI / Engineering + +- **Delivery tracking, #520 and #532 through #535**: close linked issues after dev merges, preserve repository-specific SpecGit harness files, restore failed bootstrap state, reject unsupported branch types before remote writes, and verify that delivery PRs target dev. +- **macOS installation verification, #536**: verify release archive checksums before extraction and validate the installed binary's signature after quarantine clearing and re-signing. Added a negative checksum control and a real macOS installation acceptance test. + +--- + +### ๐Ÿงช Test Summary + +``` +LLM / native runtime / processor targeted suites: 66 pass, 1 existing skip, 0 fail +opencode typecheck: passed +``` + +--- + +### ๐Ÿ” Verification + +The slow-tool regression was observed failing before the fix and passing afterward through the real session processor and a local HTTP model endpoint. Additional cases cover parallel local tools and explicit cancellation. Formal publication requires independent review, SpecGit acceptance, and the main-branch Typecheck, Linux unit, and Linux/Windows E2E gates. Reported model usage in the regression is deterministic test input; no live model context limit is inferred from it. + +--- + +**Full changelog:** [`{previous_tag}`...`{current_tag}`](https://github.com/LeXwDeX/OpenCode-GraphAgent/compare/{previous_tag}...{current_tag}) From 4508e6711eae6253ca101f5c5c7d194054d59410 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 4 Sep 2026 12:19:43 +0800 Subject: [PATCH 5/7] chore: record delivery binding for native-tool-settlement --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index b12c4aa0a..76c3984ba 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -5,6 +5,7 @@ context: branch: fix/538-native-tool-settlement issues: - 538 + - 540 issueKinds: - issue: 538 kind: kind::fix From 43894ec18a1c107e7d381898d83ccb4d4a2e0ef1 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 4 Sep 2026 12:21:19 +0800 Subject: [PATCH 6/7] test(core): isolate local npm fixtures from online audit --- packages/core/test/npm.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index f66734962..695ab1a30 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -54,7 +54,9 @@ describe("Npm.add", () => { await Bun.write(path.join(tmp.path, "fixture-provider", "index.js"), "export const fixture = true\n") const spec = `fixture-provider@file:${path.join(tmp.path, "fixture-provider")}` - await fs.mkdir(path.join(tmp.path, "cache", "packages", Npm.sanitize(spec)), { recursive: true }) + const cache = path.join(tmp.path, "cache", "packages", Npm.sanitize(spec)) + await fs.mkdir(cache, { recursive: true }) + await Bun.write(path.join(cache, ".npmrc"), "audit=false\n") const entry = await Effect.gen(function* () { const npm = yield* Npm.Service @@ -78,7 +80,7 @@ describe("Npm.install", () => { "dev-pkg": "file:./dev-pkg", }, }) - await Bun.write(path.join(tmp.path, ".npmrc"), "omit=dev\n") + await Bun.write(path.join(tmp.path, ".npmrc"), "omit=dev\naudit=false\n") await fs.mkdir(path.join(tmp.path, "prod-pkg")) await fs.mkdir(path.join(tmp.path, "dev-pkg")) await writePackage(path.join(tmp.path, "prod-pkg"), { name: "prod-pkg" }) @@ -108,7 +110,10 @@ describe("Npm.install", () => { const bundled = path.join(tmp.path, "bundled-plugin-sdk") process.env.OPENCODE_PLUGIN_SDK_PATH = bundled await fs.mkdir(path.join(bundled, "src"), { recursive: true }) - await writePackage(bundled, { name: "@opencode-ai/plugin", exports: { ".": "./src/index.ts", "./tui": "./src/tui.ts" } }) + await writePackage(bundled, { + name: "@opencode-ai/plugin", + exports: { ".": "./src/index.ts", "./tui": "./src/tui.ts" }, + }) await Bun.write(path.join(bundled, "src", "index.ts"), "export const plugin = true\n") await Bun.write(path.join(bundled, "src", "tui.ts"), "export const tui = true\n") @@ -118,7 +123,9 @@ describe("Npm.install", () => { yield* npm.install(tmp.path, { add: [{ name: "@opencode-ai/plugin" }] }) }).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise) - await expect(fs.stat(path.join(tmp.path, "node_modules", "@opencode-ai", "plugin", "src", "tui.ts"))).resolves.toBeDefined() + await expect( + fs.stat(path.join(tmp.path, "node_modules", "@opencode-ai", "plugin", "src", "tui.ts")), + ).resolves.toBeDefined() await expect(fs.stat(path.join(tmp.path, "package-lock.json"))).rejects.toThrow() } finally { delete process.env.OPENCODE_PLUGIN_SDK_PATH From e03ad876f126e040400b1d703a8474bf24ed3264 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 4 Sep 2026 13:04:30 +0800 Subject: [PATCH 7/7] test(session): share native compaction processor setup --- .../test/session/processor-effect.test.ts | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 99ecb9465..a8c777675 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -247,6 +247,17 @@ const boot = Effect.fn("test.boot")(function* () { return { processors, session, provider } }) +const nativeCompactionProcessor = Effect.fn("test.nativeCompactionProcessor")(function* (msg: SessionV1.Assistant) { + const processors = yield* SessionProcessor.Service + const provider = yield* Provider.Service + const model = { + ...(yield* provider.getModel(ref.providerID, ref.modelID)), + limit: { context: 32_000, output: 4_000 }, + } + const handle = yield* processors.create({ assistantMessage: msg, sessionID: msg.sessionID, model }) + return { model, handle } +}) + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -840,16 +851,12 @@ native.live("native tools settle before high usage requests compaction", () => provideTmpdirServer( ({ dir, llm }) => Effect.gen(function* () { - const { processors, session, provider } = yield* boot() + const { session } = yield* boot() yield* llm.push(reply().tool("lookup", { query: "weather" }).usage({ input: 30_000, output: 1 })) const chat = yield* session.create({}) const parent = yield* user(chat.id, "finish the slow lookup before compacting") const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) - const model = { - ...(yield* provider.getModel(ref.providerID, ref.modelID)), - limit: { context: 32_000, output: 4_000 }, - } - const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model }) + const { model, handle } = yield* nativeCompactionProcessor(msg) const value = yield* handle.process({ user: parent, sessionID: chat.id, @@ -895,7 +902,7 @@ native.live("native parallel tools all deliver results before compaction", () => provideTmpdirServer( ({ dir, llm }) => Effect.gen(function* () { - const { processors, session, provider } = yield* boot() + const { session } = yield* boot() yield* llm.push( raw({ chunks: [ @@ -924,11 +931,7 @@ native.live("native parallel tools all deliver results before compaction", () => const chat = yield* session.create({}) const parent = yield* user(chat.id, "complete both lookups") const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) - const model = { - ...(yield* provider.getModel(ref.providerID, ref.modelID)), - limit: { context: 32_000, output: 4_000 }, - } - const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model }) + const { model, handle } = yield* nativeCompactionProcessor(msg) const started: string[] = [] const bothStarted = defer() const value = yield* handle @@ -974,16 +977,12 @@ native.live("user interruption still aborts a native tool waiting to settle", () provideTmpdirServer( ({ dir, llm }) => Effect.gen(function* () { - const { processors, session, provider } = yield* boot() + const { session } = yield* boot() yield* llm.push(reply().tool("lookup", { query: "weather" }).usage({ input: 30_000, output: 1 })) const chat = yield* session.create({}) const parent = yield* user(chat.id, "cancel the lookup") const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) - const model = { - ...(yield* provider.getModel(ref.providerID, ref.modelID)), - limit: { context: 32_000, output: 4_000 }, - } - const handle = yield* processors.create({ assistantMessage: msg, sessionID: chat.id, model }) + const { model, handle } = yield* nativeCompactionProcessor(msg) const started = defer() let aborted = false const run = yield* handle