Skip to content
53 changes: 53 additions & 0 deletions .github/releases/v1.0.40.md
Original file line number Diff line number Diff line change
@@ -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})
12 changes: 8 additions & 4 deletions .specgit.yaml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 15 additions & 5 deletions packages/opencode/src/session/llm/native-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,15 +105,22 @@ export function stream(input: StreamInput): StreamResult {
Effect.gen(function* () {
const settlements = yield* FiberSet.make<void>()
const results = yield* Queue.unbounded<LLMEvent, Cause.Done>()
const completion: LLMEvent[] = []
const provider = input.llmClient
.stream(
LLMRequest.update(request, {
tools: [...request.tools, ...toDefinitions(tools)],
}),
)
.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(
Expand All @@ -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))),
)
}),
),
)
Expand Down
7 changes: 4 additions & 3 deletions packages/opencode/test/session/llm-native.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = []
Expand Down Expand Up @@ -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"),
Expand All @@ -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"])
}),
)

Expand Down
209 changes: 208 additions & 1 deletion packages/opencode/test/session/processor-effect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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<void>((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<void>()
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<void>()
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<void>((_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 }) =>
Expand Down
Loading