diff --git a/.github/releases/v1.0.40.md b/.github/releases/v1.0.40.md new file mode 100644 index 000000000..b863bc2f3 --- /dev/null +++ b/.github/releases/v1.0.40.md @@ -0,0 +1,53 @@ +## 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. +- **Config startup preserves npm lock files, #542**: keep an existing lock unchanged when the plugin SDK resolves entirely from local or bundled packages. Mixed registry requests and genuine package changes still regenerate the lock. + +--- + +### ๐Ÿ—๏ธ Architecture / Refactor + +- **Deleted-session storage reclamation, #537**: remove durable event residue for deleted aggregates, wire cleanup into session deletion, and add tested SQLite reclamation support. This release does not run the deferred #531 maintenance operation on the user's existing database. + +--- + +### โš™๏ธ 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. +- **Local npm fixture isolation, #540**: keep real package-installation regressions independent of online vulnerability-audit latency while retaining their assertions and deadlines. + +--- + +### ๐Ÿงช Test Summary + +``` +Integration CI baseline (dev 8060765fcc): +core: 1225 pass, 6 skip, 0 fail +opencode: 4426 pass, 23 skip, 1 todo, 0 fail +HttpAPI coverage / auth / effect: 230 pass each, no failures or missing routes +Generated client and SDK freshness: passed +Typecheck, DAG core gate, Linux and Windows E2E: passed + +Merged native/session/TUI regressions: 36 pass, 0 fail +Merged npm regressions: 8 pass, 0 fail +Merged opencode package 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. Independent Standards and Spec reviews found no code blockers. The integration statistics above come from [dev CI](https://github.com/LeXwDeX/OpenCode-GraphAgent/actions/runs/33868550950); they identify the tested baseline and do not substitute for the final release PR's Typecheck, Linux unit, Linux/Windows E2E and SpecGit acceptance 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}) diff --git a/.specgit.yaml b/.specgit.yaml index 90d1c2e85..76c3984ba 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,12 @@ version: 1 -delivery: preserve-npm-lock +delivery: native-tool-settlement context: kind: branch - branch: feat/541-preserve-npm-lock + branch: fix/538-native-tool-settlement issues: - - 541 -pr: 542 + - 538 + - 540 +issueKinds: + - issue: 538 + kind: kind::fix +pr: 539 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..a8c777675 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({ @@ -235,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 // --------------------------------------------------------------------------- @@ -824,6 +847,190 @@ 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 { 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, handle } = yield* nativeCompactionProcessor(msg) + 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 { session } = 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, handle } = yield* nativeCompactionProcessor(msg) + 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 { 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, handle } = yield* nativeCompactionProcessor(msg) + 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 }) =>