diff --git a/.github/releases/v1.0.64.md b/.github/releases/v1.0.64.md
index 25581895c..04e71504f 100644
--- a/.github/releases/v1.0.64.md
+++ b/.github/releases/v1.0.64.md
@@ -31,6 +31,9 @@ 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.
---
@@ -38,8 +41,8 @@ Stable release from `main` branch. Repair independently confirmed defects in the
```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)
diff --git a/packages/core/test/session/reasoning-distillation-runner.test.ts b/packages/core/test/session/reasoning-distillation-runner.test.ts
index b837ac960..810b6dbc7 100644
--- a/packages/core/test/session/reasoning-distillation-runner.test.ts
+++ b/packages/core/test/session/reasoning-distillation-runner.test.ts
@@ -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"
@@ -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} 轮:先猜测原因是缓存,再看看日志。确认真实原因是权限不足,最终决定改用安全路径并保留回滚步骤。`,
},
],
})
@@ -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({
@@ -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[] = []
diff --git a/packages/opencode/test/hook/hook-ask-ruleset.test.ts b/packages/opencode/test/hook/hook-ask-ruleset.test.ts
index 5ef0e6d6b..5bce31a56 100644
--- a/packages/opencode/test/hook/hook-ask-ruleset.test.ts
+++ b/packages/opencode/test/hook/hook-ask-ruleset.test.ts
@@ -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
@@ -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 = (effect: Effect.Effect) =>
+ 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, 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)
+ }),
+ )
+})
diff --git a/packages/opencode/test/mcp/fixtures/server-http.ts b/packages/opencode/test/mcp/fixtures/server-http.ts
index 09aeb752d..69b2577e5 100644
--- a/packages/opencode/test/mcp/fixtures/server-http.ts
+++ b/packages/opencode/test/mcp/fixtures/server-http.ts
@@ -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 })
}
},
})
diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts
index 64ae780cc..93c00c3d7 100644
--- a/packages/opencode/test/preload.ts
+++ b/packages/opencode/test/preload.ts
@@ -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 })
diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts
index f1e7b3d44..f6dca7568 100644
--- a/packages/opencode/test/session/llm.test.ts
+++ b/packages/opencode/test/session/llm.test.ts
@@ -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"
@@ -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 = (
@@ -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()
+ // 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 | 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",
() =>
diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts
index 34c145c00..420368bc2 100644
--- a/packages/opencode/test/session/prompt.test.ts
+++ b/packages/opencode/test/session/prompt.test.ts
@@ -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"),
diff --git a/packages/web/test/sanitize-markdown.test.ts b/packages/web/test/sanitize-markdown.test.ts
index 92d737ca1..e42545578 100644
--- a/packages/web/test/sanitize-markdown.test.ts
+++ b/packages/web/test/sanitize-markdown.test.ts
@@ -25,6 +25,8 @@ async function fixture(svg = false) {
...(svg ? [''] : []),
'',
"[execute](javascript:alert(1))",
+ 'data link',
+ 'vbscript link',
'safe link',
].map(async (value) => marked.parse(value)),
)
@@ -56,7 +58,7 @@ browserTest(
return {
executed: Boolean(Reflect.get(window, "__shareXss")),
executableElements: document.querySelectorAll("script,iframe,svg,[onerror],[onload]").length,
- dangerousLinks: links.some((link) => link.protocol === "javascript:"),
+ dangerousLinks: links.some((link) => ["javascript:", "data:", "vbscript:"].includes(link.protocol)),
safeLink: links.some((link) => link.href === "https://example.com/" && link.rel.includes("noopener")),
code: document.querySelector("pre code")?.textContent,
highlighted: Boolean(document.querySelector("pre code span[style]")),