Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/releases/v1.0.64.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,18 @@ Stable release from `main` branch. Repair independently confirmed defects in the

- The HTTP API exerciser runs its auth probes and global scenarios inside its temporary root and fails if the invoking checkout gains worktrees. Earlier runs could create `opencode/*` worktrees and branches in the developer's checkout.
- Every finding has a regression test converted from its failing reproduction. The integrated change passed an independent review; its findings were repaired with tests.
- The opencode test preload serves the plugin SDK locally. Every `.opencode` directory installs the SDK in the background and custom tools wait for it; under bun that install reached the public npm registry, and the context-folding tests that load custom tools timed out together in CI.
- The remaining code scanning alerts are resolved: the Markdown sanitizer test also rejects `data:` and `vbscript:` links, a test-generated tool no longer splices a stringified value into code, and the MCP HTTP fixture no longer returns error text.
- New regression tests cover the per-session organizer ceiling and interrupted organizer calls in both hosts, oversized reasoning in the core runner, and hook-forced asks on MCP tools; each was checked against a mutation of the code it guards.

---

### 🧪 Test Summary

```text
workspace typecheck tasks: 31/31 passed
opencode tests: 5332 passed; 9 prompt.test.ts cases hit the 30s timeout under host load, 158/158 on an isolated rerun
core tests: 1568 passed, 0 failed
opencode tests: 5345 passed, 0 failed
core tests: 1570 passed, 0 failed
tui tests: 282 passed, 0 failed
app unit tests: 456 passed, 0 failed
HTTP exerciser scenarios: 239 passed (coverage, auth, effect)
Expand Down
60 changes: 56 additions & 4 deletions packages/core/test/session/reasoning-distillation-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { SessionMessage } from "@opencode-ai/core/session/message"
import * as CoreReasoningDistillation from "@opencode-ai/core/session/runner/reasoning-distillation"
import { COVERAGE_CONTRACT, DENOISING_CONTRACT } from "@opencode-ai/core/session/reasoning-distillation"
import {
COVERAGE_CONTRACT,
DENOISING_CONTRACT,
ReasoningDistillationPolicy,
} from "@opencode-ai/core/session/reasoning-distillation"
import { toLLMMessagesWithBindings } from "@opencode-ai/core/session/runner/to-llm-message"
import { Hash } from "@opencode-ai/core/util/hash"
import { DateTime, Deferred, Duration, Effect, Fiber, Stream } from "effect"
Expand Down Expand Up @@ -736,14 +740,16 @@ describe("Core runner reasoning distillation adapter", () => {
}),
)

const turnSource = (index: number): SessionMessage.Assistant => ({
const turnSource = (index: number, text?: string): SessionMessage.Assistant => ({
...history(),
id: SessionMessage.ID.make(`msg_turn_${index}`),
content: [
{
type: "reasoning",
id: `reasoning-turn-${index}`,
text: `第 ${index} 轮:先猜测原因是缓存,再看看日志。确认真实原因是权限不足,最终决定改用安全路径并保留回滚步骤。`,
text:
text ??
`第 ${index} 轮:先猜测原因是缓存,再看看日志。确认真实原因是权限不足,最终决定改用安全路径并保留回滚步骤。`,
},
],
})
Expand All @@ -753,9 +759,10 @@ describe("Core runner reasoning distillation adapter", () => {
sessionID: string,
index: number,
auxiliaryModel: Model = model,
text?: string,
) =>
Effect.gen(function* () {
const source = turnSource(index)
const source = turnSource(index, text)
const conversion = toLLMMessagesWithBindings([source], model)
const request = LLM.request({ model, messages: conversion.messages })
return yield* adapter.distill({
Expand Down Expand Up @@ -843,6 +850,51 @@ describe("Core runner reasoning distillation adapter", () => {
}),
)

it.effect("stops calling once reported usage fills the per-session reservation ceiling", () =>
Effect.gen(function* () {
let calls = 0
const adapter = CoreReasoningDistillation.make({
prepare: mockPrepare,
stream: () => Stream.empty,
generate: () =>
Effect.sync(() => {
calls++
// Reported usage equal to the output allowance never exceeds a reservation, so it is reconciled
// rather than paused; the session ceiling alone must stop admission.
return response("决定改用安全路径。", ReasoningDistillationPolicy.tokens.maxOutputTokens)
}),
})
const results = []
for (let index = 1; index <= 15; index++) results.push(yield* distillTurn(adapter, "ses_ceiling", index))
const exhausted = results.findIndex((result) => result.skipReason === "call-budget-exhausted")
expect(exhausted).toBeGreaterThan(0)
expect(results.slice(0, exhausted).every((result) => result.applied)).toBe(true)
expect(results.slice(exhausted).every((result) => result.skipReason === "call-budget-exhausted")).toBe(true)
expect(results.at(-1)?.modelCalls).toBe(0)
expect(results.at(-1)?.usage?.paidAdmissionPaused).toBe(false)
expect(results.at(-1)?.usage?.reservedTokens).toBeLessThanOrEqual(
ReasoningDistillationPolicy.tokens.maxReservedTokensPerSession,
)
expect(calls).toBe(exhausted)
}),
)

it.effect("skips reasoning too large for one organizer call without calling the model", () =>
Effect.gen(function* () {
let calls = 0
const adapter = CoreReasoningDistillation.make({
prepare: mockPrepare,
stream: () => Stream.empty,
generate: () => Effect.sync(() => (calls++, response("决定改用安全路径。"))),
})
const oversized = "确认权限不足后改用安全路径。".repeat(ReasoningDistillationPolicy.tokens.maxInputTokens / 4)
const result = yield* distillTurn(adapter, "ses_oversized", 1, model, oversized)
expect(result).toMatchObject({ applied: false, skipReason: "work-limit", modelCalls: 0 })
expect(result.usage).toMatchObject({ reservedTokens: 0, paidAdmissionPaused: false })
expect(calls).toBe(0)
}),
)

it.effect("keeps a declared no-reasoning variant and uses the configured auxiliary timeout", () =>
Effect.gen(function* () {
const generated: LLMRequest[] = []
Expand Down
101 changes: 101 additions & 0 deletions packages/opencode/test/hook/hook-ask-ruleset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { Tool } from "@/tool/tool"
import { TestInstance } from "../fixture/fixture"
import { testEffect, pollWithTimeout } from "../lib/effect"
import { ProviderTest } from "../fake/provider"
import { jsonSchema, tool } from "ai"

// A PreToolUse `permissionDecision:"ask"` must always surface a dialog, even when
// the agent's ruleset is a wildcard deny with pattern-scoped allows (plan agent
Expand Down Expand Up @@ -232,3 +233,103 @@ describe("hook permissionDecision ask confirms the effective input once", () =>
}),
)
})

// MCP tools take the same forced-ask path: the dialog shows the rewritten arguments, approval runs exactly those
// arguments without the tool's own second dialog, and rejection never reaches the MCP server.
const mcpCalls: unknown[] = []
const withMcpEcho = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const mcp = yield* MCP.Service
return yield* effect.pipe(
Effect.provideService(MCP.Service, {
...mcp,
tools: () =>
Effect.succeed({
fixture_echo: tool({
inputSchema: jsonSchema<{ text: string }>({
type: "object",
properties: { text: { type: "string" } },
required: ["text"],
}),
execute: async (args: { text: string }) => {
mcpCalls.push(args)
return { content: [{ type: "text" as const, text: `MCP_COMPLETE ${args.text}` }] }
},
}),
}),
}),
Effect.provideService(
Truncate.Service,
Truncate.Service.of({
cleanup: () => Effect.void,
write: (text) => Effect.succeed(text),
output: (text) => Effect.succeed({ content: text, truncated: false }),
limits: () => Effect.succeed({ maxLines: Number.MAX_SAFE_INTEGER, maxBytes: Number.MAX_SAFE_INTEGER }),
}),
),
)
})

const setupMcpRewrite = withMcpEcho(
setupWith(
{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "ask",
permissionDecisionReason: "rewritten arguments",
updatedInput: { text: "rewritten" },
},
},
"build",
[{ permission: "fixture_echo", pattern: "*", action: "ask" }],
),
).pipe(Effect.map((result) => result.tools))

const runMcpEcho = (tools: Record<string, any>, reply: "once" | "reject") =>
Effect.gen(function* () {
const permission = yield* Permission.Service
mcpCalls.length = 0
const fiber = yield* Effect.tryPromise(() =>
tools.fixture_echo.execute(
{ text: "original" },
{ toolCallId: "call-mcp-echo", messages: [], abortSignal: new AbortController().signal },
),
).pipe(Effect.forkScoped)
const pending = yield* pollWithTimeout(
permission.list().pipe(Effect.map((list) => (list.length > 0 ? list : undefined))),
"MCP hook ask did not surface a permission dialog",
)
expect(pending).toHaveLength(1)
expect(pending[0].always).toEqual([])
expect(pending[0].metadata).toMatchObject({
hookAsk: true,
reason: "rewritten arguments",
input: { text: "rewritten" },
})
yield* permission.reply({ requestID: pending[0].id, reply })
const exit = yield* Fiber.await(fiber)
return { exit, remaining: yield* permission.list() }
})

describe("hook permissionDecision ask on MCP tools", () => {
it.instance("confirms the rewritten MCP arguments once and runs them", () =>
Effect.gen(function* () {
const { exit, remaining } = yield* runMcpEcho(yield* setupMcpRewrite, "once")
expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit)) expect(JSON.stringify(exit.value)).toContain("MCP_COMPLETE rewritten")
expect(mcpCalls).toEqual([{ text: "rewritten" }])
expect(remaining).toHaveLength(0)
}),
)

it.instance("a rejected MCP hook ask never calls the server", () =>
Effect.gen(function* () {
const { exit, remaining } = yield* runMcpEcho(yield* setupMcpRewrite, "reject")
expect(Exit.isSuccess(exit)).toBe(true)
if (Exit.isSuccess(exit))
expect(JSON.stringify(exit.value)).toContain("[Tool denied by hook] rewritten arguments")
expect(mcpCalls).toEqual([])
expect(remaining).toHaveLength(0)
}),
)
})
2 changes: 1 addition & 1 deletion packages/opencode/test/mcp/fixtures/server-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const server = Bun.serve({
return await handler.fetch(req)
} catch (e) {
console.error("[fixture]", e)
return new Response(JSON.stringify({ error: String(e) }), { status: 500 })
return new Response(JSON.stringify({ error: "internal fixture error" }), { status: 500 })
}
},
})
Expand Down
13 changes: 13 additions & 0 deletions packages/opencode/test/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ process.env["OPENCODE_TEST_MANAGED_CONFIG_DIR"] = testManagedConfigDir
// registry install that can hold the shared config dependency lock.
await markPluginDependenciesReady(path.join(dir, "config", "opencode"))

// Every `.opencode` directory installs the plugin SDK in the background, and custom
// tools wait for it. Under bun the bundled SDK (next to the executable) is absent, so
// each install reached the public npm registry and custom-tool tests depended on its
// latency. Serve a local stub instead. Tests that need the real SDK place it in their
// own node_modules, which the install uses first.
const pluginSdkStub = path.join(dir, "plugin-sdk")
await fs.mkdir(pluginSdkStub, { recursive: true })
await Bun.write(
path.join(pluginSdkStub, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", version: "0.0.0" }),
)
process.env["OPENCODE_PLUGIN_SDK_PATH"] ??= pluginSdkStub

// Write the cache version file to prevent global/index.ts from clearing the cache
const cacheDir = path.join(dir, "cache", "opencode")
await fs.mkdir(cacheDir, { recursive: true })
Expand Down
91 changes: 89 additions & 2 deletions packages/opencode/test/session/llm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import path from "path"
import { InvalidResponseDataError, tool, type ModelMessage } from "ai"
import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect"
import { InstanceRef } from "../../src/effect/instance-ref"
import { ReasoningDistillationPolicy } from "@opencode-ai/core/session/reasoning-distillation"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import z from "zod"
import { LLM, strictJSON } from "../../src/session/llm"
Expand Down Expand Up @@ -1006,14 +1007,14 @@ const auxiliaryResponse = (content: unknown) =>
usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 },
})

const auxiliaryTextResponse = (text: string, finishReason = "stop") =>
const auxiliaryTextResponse = (text: string, finishReason = "stop", totalTokens = 20) =>
Response.json({
id: "chatcmpl-organize",
object: "chat.completion",
created: 0,
model: "deepseek-test-r1",
choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: finishReason }],
usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 },
usage: { prompt_tokens: totalTokens - 10, completion_tokens: 10, total_tokens: totalTokens },
})

const organizeInput = (
Expand Down Expand Up @@ -2564,6 +2565,92 @@ describe("session.llm.stream", () => {
{ config: () => distillationConfig("opencode-ai-sdk", { canonical: true }) },
)

it.instance(
"stops organizing once reported usage fills the per-session reservation ceiling",
() =>
Effect.gen(function* () {
const model = yield* Provider.use.getModel(
ProviderV2.ID.make("custom-provider"),
ModelV2.ID.make("deepseek-test-r1"),
)
const sessionID = SessionID.make("session-test-organize-ceiling")
const adopted: unknown[][] = []
// Usage equal to the output allowance never exceeds a reservation, so it is reconciled rather than
// paused; only the 262k session ceiling can stop later turns.
const turns = Array.from({ length: 12 }, (_, index) => index + 1)
for (const _ of turns)
void waitRequest(
"/chat/completions",
auxiliaryTextResponse("已确认方案A。", "stop", ReasoningDistillationPolicy.tokens.maxOutputTokens),
)
yield* distillSequenceWith(
llmLayerWithExecutor(RequestExecutor.defaultLayer),
turns.map((turn) => turnInput(model, sessionID, turn, adopted)),
)
expect(adopted.length).toBeGreaterThan(1)
expect(adopted.length).toBeLessThan(turns.length)
// No model call is made after the ceiling is reached.
expect(state.queue).toHaveLength(turns.length - adopted.length)
state.queue.length = 0
}),
{ config: () => distillationConfig("opencode-ai-sdk", { canonical: true }) },
)

it.instance(
"aborts an interrupted organizer call and settles it before the next turn",
() =>
Effect.gen(function* () {
const model = yield* Provider.use.getModel(
ProviderV2.ID.make("custom-provider"),
ModelV2.ID.make("deepseek-test-r1"),
)
const sessionID = SessionID.make("session-test-organize-interrupted")
const adopted: unknown[][] = []
const started = deferred<Capture>()
// The first organizer response never completes. Leading JSON whitespace keeps the connection active
// (no server idle timeout), so only the client abort can settle the call.
state.queue.push({
path: "/chat/completions",
response: (req) => {
let keepAlive: ReturnType<typeof setInterval> | undefined
return new Response(
new ReadableStream({
start(controller) {
keepAlive = setInterval(() => controller.enqueue(new TextEncoder().encode(" ")), 1_000)
req.signal.addEventListener("abort", () => clearInterval(keepAlive), { once: true })
},
cancel() {
clearInterval(keepAlive)
},
}),
{ headers: { "content-type": "application/json" } },
)
},
resolve: started.resolve,
})
void waitRequest("/chat/completions", auxiliaryTextResponse("已确认方案A。"))
const ctx = yield* InstanceRef
if (!ctx) throw new Error("InstanceRef not provided")
yield* Effect.promise(() =>
Effect.runPromise(
Effect.gen(function* () {
const svc = yield* LLM.Service
const fiber = yield* svc.distill(turnInput(model, sessionID, 1, adopted)).pipe(Effect.forkChild)
yield* Effect.promise(() => started.promise)
yield* Fiber.interrupt(fiber)
yield* svc.distill(turnInput(model, sessionID, 2, adopted))
}).pipe(
Effect.provide(llmLayerWithExecutor(RequestExecutor.defaultLayer)),
Effect.provideService(InstanceRef, ctx),
),
),
)
expect(adopted).toHaveLength(1)
expect(state.queue).toHaveLength(0)
}),
{ config: () => distillationConfig("opencode-ai-sdk", { canonical: true }) },
)

it.instance(
"uses the configured English organizer instruction",
() =>
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3518,7 +3518,7 @@ const contextFoldingOverrideFailsClosed = (sourceKind: "custom" | "mcp") =>
"export default {",
" description: 'custom read override',",
" args: {},",
` execute: async () => ${JSON.stringify(`custom-context-folding-override-${"c".repeat(24_000)}`)},`,
" execute: async () => 'custom-context-folding-override-' + 'c'.repeat(24000),",
"}",
"",
].join("\n"),
Expand Down
Loading
Loading