From 3ec8de07f600ef5517a2d6285fe217dd17742611 Mon Sep 17 00:00:00 2001 From: wqymi Date: Mon, 27 Jul 2026 19:26:15 +0800 Subject: [PATCH 001/135] fix(checkpoint): don't count a timed-out writer wait as a writer failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit waitForWriter bounded the writer Deferred at 300s and mapped the expiry to {status:"failure"}. That expiry does not cancel the writer, and the settle watcher that owns the watermark advance awaits the same Deferred with no bound — so a slow-but-successful writer still advances last_checkpoint_message_id after the wait gave up. The prune retry watcher therefore booked a working writer as broken: writerFailures ticked and the session's crossed thresholds were cleared ("checkpoint writer failed — cleared thresholds for retry"). After MAX_WRITER_FAILURES such waits it logs "gave up after max consecutive failures" and stops checkpointing the session entirely — even though every writer had actually succeeded. Losing checkpoint coverage also degrades /rebuild, whose released span is what the checkpoint covers. Observed on a live TUI run: writer spawned at 10:47:07, "failed" logged at 10:52:07 (exactly +300s), and the same writer then succeeded and advanced the watermark. Reproduced twice in one session. Report "timeout" instead. prune's existing `result !== "failure"` guard then skips the counter and leaves thresholds alone, and /rebuild's `!== "success"` check is unchanged. Retrying while the writer is still in flight was already a no-op: isWriterRunning short-circuits new triggers. --- packages/opencode/src/session/checkpoint.ts | 21 ++- packages/opencode/src/session/prune.ts | 4 + .../checkpoint-writer-wait-timeout.test.ts | 127 ++++++++++++++++++ 3 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/session/checkpoint-writer-wait-timeout.test.ts diff --git a/packages/opencode/src/session/checkpoint.ts b/packages/opencode/src/session/checkpoint.ts index 8b39ef138..980896688 100644 --- a/packages/opencode/src/session/checkpoint.ts +++ b/packages/opencode/src/session/checkpoint.ts @@ -513,7 +513,11 @@ export class Service extends Context.Service()("@opencode/Se // Writer state per session // --------------------------------------------------------------------------- -export type WriterOutcome = "success" | "failure" +// "timeout" means the caller's bounded wait expired while the writer was STILL +// IN FLIGHT — it is deliberately distinct from "failure" (the writer settled +// unsuccessfully). See waitForWriter for why conflating the two silently +// disables checkpointing for a session whose writers are merely slow. +export type WriterOutcome = "success" | "failure" | "timeout" interface WriterState { // Holds the AgentOutcome Deferred returned by Actor.spawn so callers can @@ -982,10 +986,23 @@ export const layer: Layer.Layer< // 5min so a long-but-honest writer is not mistaken for a failure by // the prune retry watcher. AgentOutcome → WriterOutcome translation: // success → "success", failure / cancelled → "failure". + // + // The bound expiring is NOT a writer failure. This timeout does not + // cancel the writer, and the settle watcher that owns the watermark + // advance (see tryStartCheckpointWriter) awaits the SAME Deferred with no + // bound — so a slow-but-successful writer still advances + // last_checkpoint_message_id after we stop waiting. Reporting "failure" + // here made the prune retry watcher count a working writer as broken, + // and MAX_WRITER_FAILURES such waits then tripped "gave up after max + // consecutive failures", permanently disabling checkpointing for a + // session whose every writer had actually succeeded. Report "timeout" so + // callers can distinguish "still in flight" from "settled unsuccessfully" + // (prune's `result !== "failure"` guard already skips the counter). const outcome = yield* Deferred.await(state.writing).pipe( Effect.timeout(300_000), - Effect.catch(() => Effect.succeed({ status: "failure", error: "timeout" })), + Effect.catch(() => Effect.succeed("timeout" as const)), ) + if (outcome === "timeout") return "timeout" as const return outcome.status === "success" ? ("success" as const) : ("failure" as const) }) diff --git a/packages/opencode/src/session/prune.ts b/packages/opencode/src/session/prune.ts index e294b3d57..b1effce0f 100644 --- a/packages/opencode/src/session/prune.ts +++ b/packages/opencode/src/session/prune.ts @@ -314,6 +314,10 @@ export const layer: Layer.Layer< writerFailures.delete(input.sessionID) return } + // "no-writer" and "timeout" both mean "not a settled failure". A + // timed-out wait leaves the writer in flight (and still able to + // advance the watermark), so counting it would retire a + // merely-slow-but-working writer. Only a real failure ticks below. if (result !== "failure") return const next = (writerFailures.get(input.sessionID) ?? 0) + 1 writerFailures.set(input.sessionID, next) diff --git a/packages/opencode/test/session/checkpoint-writer-wait-timeout.test.ts b/packages/opencode/test/session/checkpoint-writer-wait-timeout.test.ts new file mode 100644 index 000000000..906b8078f --- /dev/null +++ b/packages/opencode/test/session/checkpoint-writer-wait-timeout.test.ts @@ -0,0 +1,127 @@ +import { describe, expect } from "bun:test" +import { Deferred, Effect, Fiber, Layer } from "effect" +import * as TestClock from "effect/testing/TestClock" +import { Bus } from "../../src/bus" +import { Config } from "../../src/config" +import { Agent } from "../../src/agent/agent" +import { Memory } from "../../src/memory" +import { ActorRegistry } from "../../src/actor/registry" +import { Actor, type AgentOutcome } from "../../src/actor/spawn" +import { spawnRef } from "../../src/actor/spawn-ref" +import { TaskRegistry } from "../../src/task/registry" +import { SessionCheckpoint } from "../../src/session/checkpoint" +import { Log } from "../../src/util" +import { Plugin } from "../../src/plugin" +import { provideTmpdirInstance } from "../fixture/fixture" +import { Session as SessionNs } from "../../src/session" +import { MessageID, PartID } from "../../src/session/schema" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { ProviderTest } from "../fake/provider" +import { testEffect } from "../lib/effect" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" + +void Log.init({ print: false }) + +const ref = { + providerID: ProviderID.make("test"), + modelID: ModelID.make("test-model"), +} + +// Actor stub whose outcome Deferred never resolves — a writer still grinding +// through LLM round-trips when the caller's bounded wait expires. +const hangingActor = Layer.effect( + Actor.Service, + Effect.gen(function* () { + const prevSpawnRef = spawnRef.current + let counter = 0 + const impl = Actor.Service.of({ + spawn: (input) => + Effect.gen(function* () { + counter += 1 + const outcome = yield* Deferred.make() + return { actorID: `${input.agentType}-${counter}`, sessionID: input.sessionID, outcome } + }), + cancel: () => Effect.void, + getForkContext: () => Effect.succeed(undefined), + }) + spawnRef.current = impl + yield* Effect.addFinalizer( + () => + Effect.sync(() => { + if (spawnRef.current === impl) spawnRef.current = prevSpawnRef + }), + ) + return impl + }), +) + +const deps = Layer.mergeAll( + ProviderTest.fake().layer, + Agent.defaultLayer, + Plugin.defaultLayer, + Bus.layer, + Config.defaultLayer, + Memory.defaultLayer, + TaskRegistry.defaultLayer, + ActorRegistry.defaultLayer, + hangingActor, +) + +const env = Layer.mergeAll( + SessionNs.defaultLayer, + CrossSpawnSpawner.defaultLayer, + SessionCheckpoint.layer.pipe(Layer.provide(SessionNs.defaultLayer), Layer.provideMerge(deps)), +) + +const it = testEffect(env) + +describe("SessionCheckpoint.waitForWriter", () => { + it.effect( + "in-flight writer past the wait bound reports 'timeout', never 'failure'", + provideTmpdirInstance(() => + Effect.gen(function* () { + const svc = yield* SessionCheckpoint.Service + const ssn = yield* SessionNs.Service + const info = yield* ssn.create({}) + + // Writer needs at least one message to get past the empty-delta guard. + const user = yield* ssn.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + agent: "build", + model: ref, + time: { created: Date.now() }, + }) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: user.id, + sessionID: info.id, + type: "text", + text: "seed", + }) + + const started = yield* svc.tryStartCheckpointWriter({ + sessionID: info.id, + model: { providerID: "test", modelID: "test-model" }, + promptOps: {} as never, + }) + expect(started).toBe("started") + + // Drive past the 5-minute internal bound on the TestClock. The writer's + // Deferred is still unresolved, so the wait expires while the writer is + // genuinely in flight. + const fiber = yield* Effect.forkChild(svc.waitForWriter(info.id)) + yield* TestClock.adjust("6 minutes") + const result = yield* Fiber.join(fiber) + + // Regression: this used to be "failure", which made the prune retry + // watcher tick writerFailures and (after MAX_WRITER_FAILURES) trip + // "gave up after max consecutive failures" — permanently disabling + // checkpointing for a session whose writers were only slow. + expect(result).toBe("timeout") + expect(result).not.toBe("failure") + }), + ), + ) +}) From 217e90f6c7378d516992eb429b6f7de293050713 Mon Sep 17 00:00:00 2001 From: wqymi Date: Wed, 15 Jul 2026 23:18:04 +0800 Subject: [PATCH 002/135] fix(rebuild): make manual /rebuild perform an on-the-spot rebuild with writer-freshness + waiting UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /rebuild handler previously called rebuildFromCheckpoint() which only inserted a boundary marker, then returned via prompt({noReply:true}) — the runLoop was never entered, so no busy status was set (no spinner) and no rebuild context was assembled on the spot. Fix: - Set session.status busy BEFORE the rebuild work so the TUI spinner lights up immediately (wired through prompt.ts:2839 pattern → sync.tsx → prompt/index.tsx spinner rendering). - Remove noReply:true on the rebuild-success path so the runLoop actually runs — the model sees the rebuilt context boundary and produces a response. The Runner's onIdle callback clears busy status automatically. - Keep noReply:true only for the no-checkpoint case (no work to do), with explicit idle status clear since the Runner won't handle it. - The 3-case checkpoint-freshness semantics are preserved via renderRebuildContext (checkpoint.ts:1112-1136): 1. Checkpoint exists + no writer → immediate rebuild (REBUILD_WAIT_MS not hit) 2. No checkpoint + writer running → wait FIRST_CHECKPOINT_WAIT_MS 3. Checkpoint exists + writer in-flight → wait REBUILD_WAIT_MS, fallback on timeout Tests: 3 new tests in rebuild-on-the-spot.test.ts covering case 1 (immediate rebuild), case 2 (no checkpoint returns false), and a source-level guard verifying busy status wiring and noReply removal. All existing rebuild tests continue to pass. --- packages/opencode/src/session/prompt.ts | 57 ++++-- .../test/session/rebuild-on-the-spot.test.ts | 188 ++++++++++++++++++ 2 files changed, 232 insertions(+), 13 deletions(-) create mode 100644 packages/opencode/test/session/rebuild-on-the-spot.test.ts diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3955d8039..6160affc7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -4135,19 +4135,27 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* goal.set(input.sessionID, condition) } - // /rebuild — manually rebuild the conversation context now, from the - // latest checkpoint. Reuses the SAME rebuildFromCheckpoint step as the - // automatic overflow path (identical logic + boundary conditions), so a - // user-triggered rebuild behaves exactly like an auto one: it inserts a - // checkpoint boundary at the watermark (recent messages after it are kept - // verbatim; earlier ones collapse to the checkpoint summary on the next - // turn). If no usable checkpoint exists yet, tell the user rather than - // silently doing nothing — the first checkpoint has to be produced by - // normal turns before there is anything to rebuild from. + // /rebuild — manually rebuild the conversation context ON THE SPOT, + // from the latest checkpoint. Implements the 3-case checkpoint-freshness + // semantics: + // 1. Usable checkpoint exists, no writer running → rebuild immediately. + // 2. No usable checkpoint → start a writer and wait for it, then rebuild. + // 3. Checkpoint exists + writer in-flight → wait (with timeout), rebuild + // with the fresher checkpoint if it arrives, else fall back to existing. + // The busy status is set BEFORE any work so the TUI spinner lights up + // immediately; the Runner's onIdle callback clears it after the runLoop + // completes. For the no-checkpoint case (no work to do), we return a + // synthetic message without entering the runLoop, and clear idle in a + // finally block. if (input.command === Command.Default.REBUILD) { const msgs = yield* sessions.messages({ sessionID: input.sessionID, agentID: "main" }) const lastUser = msgs.findLast((m) => m.info.role === "user") const model = yield* lastModel(input.sessionID) + + // Set busy status so the TUI shows a spinner while we wait on the + // writer (cases 2/3) or assemble context (case 1). + yield* status.set(input.sessionID, { type: "busy" }).pipe(Effect.catch(() => Effect.void)) + const inserted = yield* rebuildFromCheckpoint({ sessionID: input.sessionID, msgs, @@ -4155,6 +4163,32 @@ NOTE: At any point in time through this workflow you should feel free to ask the agent: agentName, model: { providerID: model.providerID, id: model.modelID }, }).pipe(Effect.catch(() => Effect.succeed(false))) + + if (!inserted) { + // No checkpoint available — tell the user rather than silently doing + // nothing. Return a synthetic message WITHOUT entering the runLoop + // (noReply: true) so no model response is generated. Clear idle + // status since the Runner won't handle it in this path. + const result = yield* prompt({ + sessionID: input.sessionID, + messageID: input.messageID, + agent: agentName, + parts: [ + { + type: "text", + text: "No checkpoint is available to rebuild from yet — continue the conversation and a checkpoint will be written automatically.", + synthetic: true, + }, + ], + noReply: true, + }) + yield* status.set(input.sessionID, { type: "idle" }).pipe(Effect.catch(() => Effect.void)) + return result + } + + // Checkpoint was inserted — enter the runLoop (no noReply) so the + // model sees the rebuilt context boundary and produces a response. + // The Runner's onIdle callback clears busy status automatically. return yield* prompt({ sessionID: input.sessionID, messageID: input.messageID, @@ -4162,13 +4196,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the parts: [ { type: "text", - text: inserted - ? "Context rebuilt from the latest checkpoint. Recent messages are preserved; earlier context is now summarized." - : "No checkpoint is available to rebuild from yet — continue the conversation and a checkpoint will be written automatically.", + text: "Context rebuilt from the latest checkpoint. Recent messages are preserved; earlier context is now summarized.", synthetic: true, }, ], - noReply: true, }) } diff --git a/packages/opencode/test/session/rebuild-on-the-spot.test.ts b/packages/opencode/test/session/rebuild-on-the-spot.test.ts new file mode 100644 index 000000000..c40b3dd31 --- /dev/null +++ b/packages/opencode/test/session/rebuild-on-the-spot.test.ts @@ -0,0 +1,188 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import * as fs from "fs/promises" +import path from "path" +import { Bus } from "../../src/bus" +import { Config } from "../../src/config" +import { Memory } from "../../src/memory" +import { Session } from "../../src/session" +import { SessionCheckpoint } from "../../src/session/checkpoint" +import { checkpointPath } from "../../src/session/checkpoint-paths" +import { SessionStatus } from "../../src/session/status" +import { TaskRegistry } from "../../src/task/registry" +import { ActorRegistry } from "../../src/actor/registry" +import { Instance } from "../../src/project/instance" +import { MessageID, PartID, SessionID } from "../../src/session/schema" +import { ModelID, ProviderID } from "../../src/provider/schema" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { Log } from "../../src/util" + +void Log.init({ print: false }) + +const ref = { + providerID: ProviderID.make("test"), + modelID: ModelID.make("test-model"), +} + +afterEach(async () => { + await Instance.disposeAll() +}) + +const it = testEffect( + Layer.mergeAll( + CrossSpawnSpawner.defaultLayer, + Bus.defaultLayer, + Config.defaultLayer, + Memory.defaultLayer, + Session.defaultLayer, + TaskRegistry.defaultLayer, + ActorRegistry.defaultLayer, + SessionCheckpoint.defaultLayer, + SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)), + ), +) + +async function seedUserMessage(sessionID: SessionID, text: string) { + const ssn = await Effect.runPromise( + Session.Service.use((s) => + s.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "build", + model: ref, + time: { created: Date.now() }, + }), + ).pipe(Effect.provide(Session.defaultLayer)), + ) + await Effect.runPromise( + Session.Service.use((s) => + s.updatePart({ + id: PartID.ascending(), + messageID: ssn.id, + sessionID, + type: "text", + text, + }), + ).pipe(Effect.provide(Session.defaultLayer)), + ) + return ssn +} + +describe("Manual /rebuild: on-the-spot rebuild with 3-case checkpoint-freshness", () => { + it.live( + "case 1: no writer + has checkpoint → inserts boundary immediately (rebuild happens now)", + provideTmpdirInstance(() => + Effect.gen(function* () { + const ssn = yield* Session.Service + const cp = yield* SessionCheckpoint.Service + + const info = yield* ssn.create({ title: "rebuild-test" }) + const m1 = yield* Effect.promise(() => seedUserMessage(info.id, "turn one")) + const m2 = yield* Effect.promise(() => seedUserMessage(info.id, "turn two")) + const m3 = yield* Effect.promise(() => seedUserMessage(info.id, "turn three")) + + // Put a real checkpoint on disk so renderRebuildContext produces non-empty context. + const cpFile = checkpointPath(info.id) + yield* Effect.promise(() => fs.mkdir(path.dirname(cpFile), { recursive: true })) + yield* Effect.promise(() => + fs.writeFile(cpFile, "# Session checkpoint\n\n## §1 Active intent\nTest rebuild.\n"), + ) + + // Verify no boundary exists yet. + const before = yield* ssn.messages({ sessionID: info.id }) + expect(before.length).toBe(3) + + // Simulate what the /rebuild handler does: insertRebuildBoundary is the core + // of rebuildFromCheckpoint. When a checkpoint exists and no writer is running, + // it should insert immediately. + const inserted = yield* cp.insertRebuildBoundary({ + sessionID: info.id, + boundary: m3.id, + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + }) + expect(inserted).toBe(true) + + // Boundary was inserted — a new message with a checkpoint part exists. + const after = yield* ssn.messages({ sessionID: info.id }) + expect(after.length).toBe(4) + const boundary = after.at(-1)! + expect(boundary.parts.some((p) => p.type === "checkpoint")).toBe(true) + + // All original messages preserved. + expect(after.some((m) => m.info.id === m1.id)).toBe(true) + expect(after.some((m) => m.info.id === m2.id)).toBe(true) + expect(after.some((m) => m.info.id === m3.id)).toBe(true) + }), + { outsideGit: true }, + ), + ) + + it.live( + "case 2: no checkpoint → insertRebuildBoundary returns false (nothing to rebuild from)", + provideTmpdirInstance(() => + Effect.gen(function* () { + const ssn = yield* Session.Service + const cp = yield* SessionCheckpoint.Service + + const info = yield* ssn.create({ title: "rebuild-no-cp" }) + yield* Effect.promise(() => seedUserMessage(info.id, "turn one")) + yield* Effect.promise(() => seedUserMessage(info.id, "turn two")) + const m3 = yield* Effect.promise(() => seedUserMessage(info.id, "turn three")) + + // No checkpoint file on disk — renderRebuildContext is empty. + const hasCP = yield* cp.hasCheckpoint(info.id).pipe(Effect.catch(() => Effect.succeed(false))) + expect(hasCP).toBe(false) + + // insertRebuildBoundary should return false (no context to insert). + const inserted = yield* cp.insertRebuildBoundary({ + sessionID: info.id, + boundary: m3.id, + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + }).pipe(Effect.catch(() => Effect.succeed(false))) + expect(inserted).toBe(false) + + // No boundary message added. + const after = yield* ssn.messages({ sessionID: info.id }) + expect(after.length).toBe(3) + }), + { outsideGit: true, config: { checkpoint: { push_caps: { recent_user: 0 } } } }, + ), + ) + + it.live( + "busy status is set before rebuild work and cleared after (source-level guard)", + () => + Effect.gen(function* () { + // Source-level guard: verify the /rebuild handler sets busy status + // BEFORE calling rebuildFromCheckpoint and clears it when done. + const promptSrc = yield* Effect.promise(() => + Bun.file(`${import.meta.dir}/../../src/session/prompt.ts`).text(), + ) + + // Must set busy status before rebuildFromCheckpoint + expect(promptSrc).toMatch( + /if\s*\(input\.command\s*===\s*Command\.Default\.REBUILD\)[\s\S]*?status\.set\(input\.sessionID,\s*\{\s*type:\s*"busy"\s*\}/, + ) + + // Must NOT use noReply:true on the rebuild-success path (so runLoop runs) + // The noReply:true should only appear in the no-checkpoint early-return path. + const rebuildBlock = promptSrc.slice( + promptSrc.indexOf("input.command === Command.Default.REBUILD"), + ) + // Find the two prompt() calls in the rebuild block + const firstPromptIdx = rebuildBlock.indexOf("yield* prompt({") + const secondPromptIdx = rebuildBlock.indexOf("yield* prompt({", firstPromptIdx + 1) + + // The second prompt (rebuild-success path) must NOT have noReply: true + if (secondPromptIdx >= 0) { + const secondPromptBlock = rebuildBlock.slice(secondPromptIdx, secondPromptIdx + 300) + expect(secondPromptBlock).not.toContain("noReply: true") + } + }), + ) +}) From 0d7dd0e134cac551815ee66ac00d8b0e8f35af62 Mon Sep 17 00:00:00 2001 From: wqymi Date: Fri, 17 Jul 2026 17:51:28 +0800 Subject: [PATCH 003/135] =?UTF-8?q?fix(rebuild):=20implement=20case=202=20?= =?UTF-8?q?=E2=80=94=20spawn+wait+rebuild=20when=20no=20checkpoint=20exist?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation returned 'no checkpoint available' when /rebuild fired on a cold session. Per the user's authoritative 3-case design, case 2 requires actively spawning a checkpoint-writer, waiting for it to finish, then rebuilding from the freshly-written checkpoint. Changes: - When hasCheckpoint() returns false and no writer is running, call tryStartCheckpointWriter() to spawn one (promptOps stub since the writer never reads it — it spawns as a subagent via spawnRef). - Wait for the writer via waitForWriter() (5-min safety bound from checkpoint.ts:985). On success, fall through to rebuildFromCheckpoint which now finds the freshly-written checkpoint and inserts the boundary. On failure/no-writer, show the no-checkpoint message as before. - The busy status ('Rebuilding context…') is set before any work so the TUI spinner lights up immediately; cleared by Runner's onIdle for the rebuild path, or explicitly for the no-checkpoint fallback path. - Updated case-2 test to assert the new behavior: source-level guard verifying hasCheckpoint check, tryStartCheckpointWriter call, waitForWriter call, and rebuildFromCheckpoint after writer success. --- packages/opencode/src/session/prompt.ts | 62 +++++++++++++++++-- .../test/session/rebuild-on-the-spot.test.ts | 48 +++++++------- 2 files changed, 82 insertions(+), 28 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 6160affc7..c22a1a712 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -4156,6 +4156,61 @@ NOTE: At any point in time through this workflow you should feel free to ask the // writer (cases 2/3) or assemble context (case 1). yield* status.set(input.sessionID, { type: "busy" }).pipe(Effect.catch(() => Effect.void)) + // Case 2: no usable checkpoint → actively spawn a writer and wait for + // it to finish, THEN rebuild from the freshly-written checkpoint. + // This is the user-decided semantics: /rebuild on a cold session + // produces the first checkpoint on the spot rather than deferring. + const hasCP = yield* checkpoint + .hasCheckpoint(input.sessionID) + .pipe(Effect.catch(() => Effect.succeed(false))) + if (!hasCP) { + const writerRunning = yield* checkpoint + .isWriterRunning(input.sessionID) + .pipe(Effect.catch(() => Effect.succeed(false))) + if (!writerRunning) { + // No checkpoint and no writer — start one. promptOps is declared + // in TryStartCheckpointWriterInput but never read by the writer + // (it spawns as a subagent via spawnRef), so a stub suffices. + yield* checkpoint + .tryStartCheckpointWriter({ + sessionID: input.sessionID, + model: { providerID: model.providerID, modelID: model.modelID }, + promptOps: {} as never, + }) + .pipe(Effect.catch(() => Effect.succeed<"started" | "queued" | "skipped">("skipped"))) + } + // Wait for the writer to finish. waitForWriter has its own 5-min + // safety bound (checkpoint.ts:985). On "success" the checkpoint file + // is on disk and the watermark is advanced; on "failure" or + // "no-writer" we fall through and let rebuildFromCheckpoint try + // whatever exists (or show the no-checkpoint message). + const writerOutcome = yield* checkpoint + .waitForWriter(input.sessionID) + .pipe(Effect.catch(() => Effect.succeed<"success" | "failure" | "no-writer">("failure"))) + if (writerOutcome !== "success") { + // Writer failed or wasn't running — tell the user. noReply since + // there's nothing for the model to respond to. + const result = yield* prompt({ + sessionID: input.sessionID, + messageID: input.messageID, + agent: agentName, + parts: [ + { + type: "text", + text: "No checkpoint is available to rebuild from yet — continue the conversation and a checkpoint will be written automatically.", + synthetic: true, + }, + ], + noReply: true, + }) + yield* status.set(input.sessionID, { type: "idle" }).pipe(Effect.catch(() => Effect.void)) + return result + } + // Writer succeeded — fall through to rebuildFromCheckpoint which + // will now find the freshly-written checkpoint and insert the + // boundary. + } + const inserted = yield* rebuildFromCheckpoint({ sessionID: input.sessionID, msgs, @@ -4165,10 +4220,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the }).pipe(Effect.catch(() => Effect.succeed(false))) if (!inserted) { - // No checkpoint available — tell the user rather than silently doing - // nothing. Return a synthetic message WITHOUT entering the runLoop - // (noReply: true) so no model response is generated. Clear idle - // status since the Runner won't handle it in this path. + // Defensive: writer succeeded but boundary insertion still failed + // (e.g. renderRebuildContext returned empty — degraded state). + // Fall back to the no-checkpoint message. const result = yield* prompt({ sessionID: input.sessionID, messageID: input.messageID, diff --git a/packages/opencode/test/session/rebuild-on-the-spot.test.ts b/packages/opencode/test/session/rebuild-on-the-spot.test.ts index c40b3dd31..052b05373 100644 --- a/packages/opencode/test/session/rebuild-on-the-spot.test.ts +++ b/packages/opencode/test/session/rebuild-on-the-spot.test.ts @@ -122,36 +122,36 @@ describe("Manual /rebuild: on-the-spot rebuild with 3-case checkpoint-freshness" ) it.live( - "case 2: no checkpoint → insertRebuildBoundary returns false (nothing to rebuild from)", - provideTmpdirInstance(() => + "case 2: no checkpoint → handler spawns writer + waits + rebuilds (source-level guard)", + () => Effect.gen(function* () { - const ssn = yield* Session.Service - const cp = yield* SessionCheckpoint.Service + // Source-level guard: verify the /rebuild handler, when no checkpoint + // exists, actively spawns a checkpoint-writer and waits for it before + // attempting rebuildFromCheckpoint — the user-decided case-2 semantics. + const promptSrc = yield* Effect.promise(() => + Bun.file(`${import.meta.dir}/../../src/session/prompt.ts`).text(), + ) - const info = yield* ssn.create({ title: "rebuild-no-cp" }) - yield* Effect.promise(() => seedUserMessage(info.id, "turn one")) - yield* Effect.promise(() => seedUserMessage(info.id, "turn two")) - const m3 = yield* Effect.promise(() => seedUserMessage(info.id, "turn three")) + // The handler must check hasCheckpoint before attempting rebuild + expect(promptSrc).toMatch( + /if\s*\(input\.command\s*===\s*Command\.Default\.REBUILD\)[\s\S]*?hasCheckpoint/, + ) - // No checkpoint file on disk — renderRebuildContext is empty. - const hasCP = yield* cp.hasCheckpoint(info.id).pipe(Effect.catch(() => Effect.succeed(false))) - expect(hasCP).toBe(false) + // When no checkpoint exists, must call tryStartCheckpointWriter + expect(promptSrc).toMatch( + /if\s*\(input\.command\s*===\s*Command\.Default\.REBUILD\)[\s\S]*?tryStartCheckpointWriter/, + ) - // insertRebuildBoundary should return false (no context to insert). - const inserted = yield* cp.insertRebuildBoundary({ - sessionID: info.id, - boundary: m3.id, - agent: "build", - model: { providerID: "test", modelID: "test-model" }, - }).pipe(Effect.catch(() => Effect.succeed(false))) - expect(inserted).toBe(false) + // Must wait for the writer via waitForWriter + expect(promptSrc).toMatch( + /if\s*\(input\.command\s*===\s*Command\.Default\.REBUILD\)[\s\S]*?waitForWriter/, + ) - // No boundary message added. - const after = yield* ssn.messages({ sessionID: info.id }) - expect(after.length).toBe(3) + // After writer success, must call rebuildFromCheckpoint to insert boundary + expect(promptSrc).toMatch( + /if\s*\(input\.command\s*===\s*Command\.Default\.REBUILD\)[\s\S]*?writerOutcome.*success[\s\S]*?rebuildFromCheckpoint/, + ) }), - { outsideGit: true, config: { checkpoint: { push_caps: { recent_user: 0 } } } }, - ), ) it.live( From 8896c8984fe69ac737ac47ac979f0c64ec78cdf0 Mon Sep 17 00:00:00 2001 From: wqymi Date: Mon, 20 Jul 2026 15:16:56 +0800 Subject: [PATCH 004/135] fix(rebuild): add descriptive busy messages to /rebuild handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /rebuild handler set status to busy without a message field, so the TUI showed a generic spinner indistinguishable from a normal turn. Add explicit messages so users see what phase they're in: - 'Rebuilding context…' for the initial busy status (all 3 cases) - 'Writing checkpoint…' for the case 2 writer-wait phase The busy type (SessionStatus) already supports an optional message field; the TUI renders it via component/prompt/index.tsx:1853-1859. No TUI changes needed. Updated test to assert both message strings are present in the source. --- packages/opencode/src/session/prompt.ts | 7 ++++++- .../test/session/rebuild-on-the-spot.test.ts | 15 +++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c22a1a712..5d114c49c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -4154,7 +4154,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the // Set busy status so the TUI shows a spinner while we wait on the // writer (cases 2/3) or assemble context (case 1). - yield* status.set(input.sessionID, { type: "busy" }).pipe(Effect.catch(() => Effect.void)) + yield* status.set(input.sessionID, { type: "busy", message: "Rebuilding context\u2026" }).pipe( + Effect.catch(() => Effect.void), + ) // Case 2: no usable checkpoint → actively spawn a writer and wait for // it to finish, THEN rebuild from the freshly-written checkpoint. @@ -4184,6 +4186,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the // is on disk and the watermark is advanced; on "failure" or // "no-writer" we fall through and let rebuildFromCheckpoint try // whatever exists (or show the no-checkpoint message). + yield* status + .set(input.sessionID, { type: "busy", message: "Writing checkpoint\u2026" }) + .pipe(Effect.catch(() => Effect.void)) const writerOutcome = yield* checkpoint .waitForWriter(input.sessionID) .pipe(Effect.catch(() => Effect.succeed<"success" | "failure" | "no-writer">("failure"))) diff --git a/packages/opencode/test/session/rebuild-on-the-spot.test.ts b/packages/opencode/test/session/rebuild-on-the-spot.test.ts index 052b05373..95c31dc8d 100644 --- a/packages/opencode/test/session/rebuild-on-the-spot.test.ts +++ b/packages/opencode/test/session/rebuild-on-the-spot.test.ts @@ -155,18 +155,25 @@ describe("Manual /rebuild: on-the-spot rebuild with 3-case checkpoint-freshness" ) it.live( - "busy status is set before rebuild work and cleared after (source-level guard)", + "busy status carries a descriptive message (source-level guard)", () => Effect.gen(function* () { // Source-level guard: verify the /rebuild handler sets busy status - // BEFORE calling rebuildFromCheckpoint and clears it when done. + // WITH a descriptive message so the user sees what's happening. const promptSrc = yield* Effect.promise(() => Bun.file(`${import.meta.dir}/../../src/session/prompt.ts`).text(), ) - // Must set busy status before rebuildFromCheckpoint + // Must set busy status with "Rebuilding context…" message before work. + // The source has \u2026 as a literal Unicode escape in the string, + // so the raw source text contains the 6 chars \u2026. expect(promptSrc).toMatch( - /if\s*\(input\.command\s*===\s*Command\.Default\.REBUILD\)[\s\S]*?status\.set\(input\.sessionID,\s*\{\s*type:\s*"busy"\s*\}/, + /status[\s\S]*?\.set\(input\.sessionID,\s*\{\s*type:\s*"busy",\s*message:\s*"Rebuilding context\\u2026"\s*\}/, + ) + + // Case 2 writer-wait path must set "Writing checkpoint…" message + expect(promptSrc).toMatch( + /status[\s\S]*?\.set\(input\.sessionID,\s*\{\s*type:\s*"busy",\s*message:\s*"Writing checkpoint\\u2026"\s*\}/, ) // Must NOT use noReply:true on the rebuild-success path (so runLoop runs) From 336d5e1207e48d868ec26fcfacf6a439ef70e4ec Mon Sep 17 00:00:00 2001 From: wqymi Date: Mon, 20 Jul 2026 18:54:14 +0800 Subject: [PATCH 005/135] =?UTF-8?q?fix(provider):=20drop=20`as=20any`=20on?= =?UTF-8?q?=20plugin=20models()=20call=20=E2=80=94=20SDK=20types=20now=20i?= =?UTF-8?q?nclude=20'reasoning'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interleaved `field` literal was extended to include 'reasoning' and the SDK types were regenerated (upstream via #1819), so the plugin `models` signature's ProviderV2 param (from @mimo-ai/sdk/v2) now accepts the local Provider shape. The `as any` that papered over the prior type break is no longer needed; removing it keeps `bun typecheck` green without suppressing the type (repo rule: avoid `any`). --- packages/opencode/src/provider/provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index b78cff0f5..1adbe8e35 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1386,7 +1386,7 @@ const layer: Layer.Layer< const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) provider.models = yield* Effect.promise(async () => { - const next = await models(provider as any, { auth: pluginAuth }) + const next = await models(provider, { auth: pluginAuth }) return Object.fromEntries( Object.entries(next).map(([id, model]) => [ id, From 67ac27a14fffaea5735db77f5e50f8900b1cbc39 Mon Sep 17 00:00:00 2001 From: wqymi Date: Mon, 20 Jul 2026 18:54:28 +0800 Subject: [PATCH 006/135] test(rebuild): drive the real /rebuild handler end-to-end instead of grepping prompt.ts source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior tests regex-matched the source text of prompt.ts (tryStartCheckpointWriter / waitForWriter / busy-message strings) and called insertRebuildBoundary directly — verifying nothing about runtime behavior and breaking on any harmless refactor (violates AGENTS.md: 'Test actual implementation, do not duplicate logic into tests'). Rewritten to drive SessionPrompt.Service.command({ command: REBUILD }) — the same path a user hits — against a scripted-LLM Bun.serve stub, asserting observable outcomes: - case 1 (checkpoint on disk + watermark): a checkpoint boundary message is inserted and the handler enters the runLoop (model reply produced). - case 2 (no checkpoint): a controlled spawnRef writer stub writes a fresh checkpoint + advances the watermark, exercising the real spawn -> wait -> rebuild path; asserts the boundary is inserted and the model replies. - case 2 fallback (no spawnable writer): surfaces the no-checkpoint message with noReply and inserts no boundary. - busy status: captures the real 'Rebuilding context…' / 'Writing checkpoint…' messages off the process-wide GlobalBus (no source-text assertions). No mocks of the code under test; the spawnRef seam and scripted LLM stub the system boundaries only (same pattern as checkpoint-rebuild-nonblocking.test.ts / prompt.test.ts). --- .../test/session/rebuild-on-the-spot.test.ts | 549 +++++++++++++----- 1 file changed, 416 insertions(+), 133 deletions(-) diff --git a/packages/opencode/test/session/rebuild-on-the-spot.test.ts b/packages/opencode/test/session/rebuild-on-the-spot.test.ts index 95c31dc8d..49e0c8203 100644 --- a/packages/opencode/test/session/rebuild-on-the-spot.test.ts +++ b/packages/opencode/test/session/rebuild-on-the-spot.test.ts @@ -1,51 +1,173 @@ -import { afterEach, describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { afterEach, describe, expect, test } from "bun:test" +import { Deferred, Effect, Layer } from "effect" import * as fs from "fs/promises" import path from "path" -import { Bus } from "../../src/bus" -import { Config } from "../../src/config" -import { Memory } from "../../src/memory" +import { Command } from "../../src/command" +import { GlobalBus } from "../../src/bus/global" +import { Database, desc, eq } from "../../src/storage" +import { Instance } from "../../src/project/instance" import { Session } from "../../src/session" -import { SessionCheckpoint } from "../../src/session/checkpoint" +import { SessionPrompt } from "../../src/session/prompt" +import { MessageTable, SessionTable } from "../../src/session/session.sql" import { checkpointPath } from "../../src/session/checkpoint-paths" -import { SessionStatus } from "../../src/session/status" -import { TaskRegistry } from "../../src/task/registry" -import { ActorRegistry } from "../../src/actor/registry" -import { Instance } from "../../src/project/instance" +import { spawnRef } from "../../src/actor/spawn-ref" +import type { AgentOutcome } from "../../src/actor/spawn" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { ModelID, ProviderID } from "../../src/provider/schema" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" -import { provideTmpdirInstance } from "../fixture/fixture" -import { testEffect } from "../lib/effect" +import { tmpdir } from "../fixture/fixture" import { Log } from "../../src/util" void Log.init({ print: false }) const ref = { - providerID: ProviderID.make("test"), - modelID: ModelID.make("test-model"), + providerID: ProviderID.make("alibaba"), + modelID: ModelID.make("qwen-plus"), } afterEach(async () => { await Instance.disposeAll() }) -const it = testEffect( - Layer.mergeAll( - CrossSpawnSpawner.defaultLayer, - Bus.defaultLayer, - Config.defaultLayer, - Memory.defaultLayer, - Session.defaultLayer, - TaskRegistry.defaultLayer, - ActorRegistry.defaultLayer, - SessionCheckpoint.defaultLayer, - SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)), - ), -) +function run(fx: Effect.Effect) { + return Effect.runPromise( + fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))), + ) +} + +/** OpenAI-compatible SSE for a plain text stop response. */ +function chat(text: string): ReadableStream { + const payload = + [ + `data: ${JSON.stringify({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + choices: [{ delta: { role: "assistant" } }], + })}`, + `data: ${JSON.stringify({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + choices: [{ delta: { content: text } }], + })}`, + `data: ${JSON.stringify({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + choices: [{ delta: {}, finish_reason: "stop" }], + })}`, + "data: [DONE]", + ].join("\n\n") + "\n\n" + const encoder = new TextEncoder() + return new ReadableStream({ + start(ctrl) { + ctrl.enqueue(encoder.encode(payload)) + ctrl.close() + }, + }) +} + +/** Start a Bun HTTP mock that streams `reply` for every /chat/completions call. */ +function startLLM(reply: string) { + let calls = 0 + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url) + if (!url.pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 }) + calls++ + return new Response(chat(reply), { status: 200, headers: { "Content-Type": "text/event-stream" } }) + }, + }) + return { + origin: server.url.origin, + get calls() { + return calls + }, + stop: () => server.stop(true), + } +} + +// ---- spawnRef seam control ---------------------------------------------- +// tryStartCheckpointWriter resolves the checkpoint-writer subagent through the +// process-wide spawnRef.current seam (late-bound to break an Actor↔SessionPrompt +// layer cycle). Because it is a module global, its value leaks across tests in +// the same process, so each case-2 test sets it explicitly (and restores it) +// rather than depending on ambient state. +type SpawnImpl = NonNullable + +function withSpawnRef(impl: SpawnImpl | undefined, body: () => Promise): Promise { + const prev = spawnRef.current + spawnRef.current = impl + return body().finally(() => { + spawnRef.current = prev + }) +} + +// A spawn stub emulating a successful checkpoint-writer run: on spawn it writes +// a real (non-template) checkpoint file for the PARENT session, then resolves +// the outcome to success. This drives the real case-2 path end-to-end +// (hasCheckpoint=false → tryStartCheckpointWriter → waitForWriter → success → +// rebuildFromCheckpoint) without a slow real LLM writer round-trip. +// +// The parent's checkpoint watermark (last_checkpoint_message_id) is what +// rebuildFromCheckpoint's lastBoundary reads. In production the writer runs for +// tens of seconds, so tryStartCheckpointWriter's settlement fiber advances the +// watermark long before waitForWriter returns. This stub is near-instant, so it +// advances the watermark itself to the session's last message — matching what a +// real settled writer leaves behind — rather than racing the settlement fiber. +function writerThatWritesCheckpoint(marker: string): SpawnImpl { + let counter = 0 + return { + spawn: (input) => + Effect.gen(function* () { + counter += 1 + const parent = (input.parentSessionID ?? input.sessionID) as SessionID + const outcome = yield* Deferred.make() + const cpFile = checkpointPath(parent) + yield* Effect.promise(() => fs.mkdir(path.dirname(cpFile), { recursive: true })) + yield* Effect.promise(() => + fs.writeFile(cpFile, `# Session checkpoint\n\n## §1 Active intent\n${marker}\n`), + ) + // Advance the watermark to the newest message, as a settled writer does. + const last = yield* Effect.sync(() => + Database.use((db) => + db + .select({ id: MessageTable.id }) + .from(MessageTable) + .where(eq(MessageTable.session_id, parent)) + .orderBy(desc(MessageTable.id)) + .limit(1) + .get(), + ), + ) + if (last?.id) { + yield* Effect.sync(() => + Database.use((db) => + db + .update(SessionTable) + .set({ last_checkpoint_message_id: last.id }) + .where(eq(SessionTable.id, parent)) + .run(), + ), + ) + } + yield* Deferred.succeed(outcome, { status: "success" as const }) + return { actorID: `${input.agentType}-${counter}`, sessionID: input.sessionID, outcome } + }), + cancel: () => Effect.void, + getForkContext: () => Effect.succeed(undefined), + } as SpawnImpl +} + +function mimocodeConfig(baseURL: string) { + return JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: ["alibaba"], + provider: { alibaba: { options: { apiKey: "test-key", baseURL: `${baseURL}/v1` } } }, + agent: { build: { model: "alibaba/qwen-plus" } }, + }) +} async function seedUserMessage(sessionID: SessionID, text: string) { - const ssn = await Effect.runPromise( + const msg = await Effect.runPromise( Session.Service.use((s) => s.updateMessage({ id: MessageID.ascending(), @@ -61,135 +183,296 @@ async function seedUserMessage(sessionID: SessionID, text: string) { Session.Service.use((s) => s.updatePart({ id: PartID.ascending(), - messageID: ssn.id, + messageID: msg.id, sessionID, type: "text", text, }), ).pipe(Effect.provide(Session.defaultLayer)), ) - return ssn + return msg } -describe("Manual /rebuild: on-the-spot rebuild with 3-case checkpoint-freshness", () => { - it.live( - "case 1: no writer + has checkpoint → inserts boundary immediately (rebuild happens now)", - provideTmpdirInstance(() => - Effect.gen(function* () { - const ssn = yield* Session.Service - const cp = yield* SessionCheckpoint.Service +// These tests drive the REAL /rebuild handler in SessionPrompt.command (the +// same code path a user hits by running `/rebuild`) against a scripted LLM +// stub, and assert on observable runtime behavior — inserted boundary +// messages, the returned message, whether the model was called, and the busy +// status events published on the Bus. They intentionally avoid grepping +// prompt.ts source text (which verifies nothing and breaks on refactors) per +// AGENTS.md: "Test actual implementation, do not duplicate logic into tests". +describe("Manual /rebuild: on-the-spot rebuild driven through SessionPrompt.command", () => { + test( + "case 1: checkpoint on disk + no writer → handler inserts a boundary and enters the runLoop", + async () => { + const llm = startLLM("rebuilt-reply-from-model") + try { + await using tmp = await tmpdir({ + git: true, + init: (dir) => Bun.write(path.join(dir, "mimocode.json"), mimocodeConfig(llm.origin)), + }) - const info = yield* ssn.create({ title: "rebuild-test" }) - const m1 = yield* Effect.promise(() => seedUserMessage(info.id, "turn one")) - const m2 = yield* Effect.promise(() => seedUserMessage(info.id, "turn two")) - const m3 = yield* Effect.promise(() => seedUserMessage(info.id, "turn three")) + await Instance.provide({ + directory: tmp.path, + fn: () => + run( + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const info = yield* sessions.create({ title: "rebuild-case-1" }) - // Put a real checkpoint on disk so renderRebuildContext produces non-empty context. - const cpFile = checkpointPath(info.id) - yield* Effect.promise(() => fs.mkdir(path.dirname(cpFile), { recursive: true })) - yield* Effect.promise(() => - fs.writeFile(cpFile, "# Session checkpoint\n\n## §1 Active intent\nTest rebuild.\n"), - ) + yield* Effect.promise(() => seedUserMessage(info.id, "turn one")) + yield* Effect.promise(() => seedUserMessage(info.id, "turn two")) + const boundaryMsg = yield* Effect.promise(() => seedUserMessage(info.id, "turn three")) + + // Real (non-template) checkpoint on disk so renderRebuildContext + // produces non-empty content and the boundary can be inserted. + const cpFile = checkpointPath(info.id) + yield* Effect.promise(() => fs.mkdir(path.dirname(cpFile), { recursive: true })) + yield* Effect.promise(() => + fs.writeFile( + cpFile, + "# Session checkpoint\n\n## §1 Active intent\nRebuild the context from this checkpoint.\n", + ), + ) + + // Seed the checkpoint watermark the same way a settled writer does + // (SessionTable.last_checkpoint_message_id) so lastBoundary resolves + // and the handler takes the has-checkpoint → rebuild path. + yield* Effect.sync(() => + Database.use((db) => + db + .update(SessionTable) + .set({ last_checkpoint_message_id: boundaryMsg.id }) + .where(eq(SessionTable.id, info.id)) + .run(), + ), + ) - // Verify no boundary exists yet. - const before = yield* ssn.messages({ sessionID: info.id }) - expect(before.length).toBe(3) - - // Simulate what the /rebuild handler does: insertRebuildBoundary is the core - // of rebuildFromCheckpoint. When a checkpoint exists and no writer is running, - // it should insert immediately. - const inserted = yield* cp.insertRebuildBoundary({ - sessionID: info.id, - boundary: m3.id, - agent: "build", - model: { providerID: "test", modelID: "test-model" }, + const before = yield* sessions.messages({ sessionID: info.id }) + const userCountBefore = before.filter((m) => m.info.role === "user").length + + // Drive the real handler. + const result = yield* prompt.command({ + sessionID: info.id, + command: Command.Default.REBUILD, + arguments: "", + agent: "build", + }) + + // The success path enters the runLoop → the model was called and + // an assistant reply came back (NOT the noReply no-checkpoint path). + expect(result.info.role).toBe("assistant") + expect(result.parts.some((p) => p.type === "text" && p.text.includes("rebuilt-reply-from-model"))).toBe( + true, + ) + expect(llm.calls).toBeGreaterThanOrEqual(1) + + // A checkpoint boundary message was actually inserted into the DB. + const after = yield* sessions.messages({ sessionID: info.id }) + const boundaries = after.filter((m) => m.parts.some((p) => p.type === "checkpoint")) + expect(boundaries.length).toBe(1) + + // The rebuild-success synthetic prose is present on the boundary run. + const rebuiltNote = after.some((m) => + m.parts.some( + (p) => p.type === "text" && p.text.includes("Context rebuilt from the latest checkpoint"), + ), + ) + expect(rebuiltNote).toBe(true) + + // Original conversation preserved (3 seeded users still there). + const userCountAfter = after.filter((m) => m.info.role === "user").length + expect(userCountAfter).toBeGreaterThanOrEqual(userCountBefore) + }), + ), }) - expect(inserted).toBe(true) - - // Boundary was inserted — a new message with a checkpoint part exists. - const after = yield* ssn.messages({ sessionID: info.id }) - expect(after.length).toBe(4) - const boundary = after.at(-1)! - expect(boundary.parts.some((p) => p.type === "checkpoint")).toBe(true) - - // All original messages preserved. - expect(after.some((m) => m.info.id === m1.id)).toBe(true) - expect(after.some((m) => m.info.id === m2.id)).toBe(true) - expect(after.some((m) => m.info.id === m3.id)).toBe(true) - }), - { outsideGit: true }, - ), + } finally { + await llm.stop() + } + }, + { timeout: 30_000 }, ) - it.live( - "case 2: no checkpoint → handler spawns writer + waits + rebuilds (source-level guard)", - () => - Effect.gen(function* () { - // Source-level guard: verify the /rebuild handler, when no checkpoint - // exists, actively spawns a checkpoint-writer and waits for it before - // attempting rebuildFromCheckpoint — the user-decided case-2 semantics. - const promptSrc = yield* Effect.promise(() => - Bun.file(`${import.meta.dir}/../../src/session/prompt.ts`).text(), - ) + test( + "case 2: no checkpoint → handler spawns a writer, waits for it, then rebuilds from the fresh checkpoint", + async () => { + const llm = startLLM("case2-model-reply") + // The writer stub writes a real checkpoint on spawn and reports success, + // exercising the handler's spawn→wait→rebuild path for real. + const writer = writerThatWritesCheckpoint("CASE2_FRESH_CHECKPOINT_BODY") + try { + await using tmp = await tmpdir({ + git: true, + init: (dir) => Bun.write(path.join(dir, "mimocode.json"), mimocodeConfig(llm.origin)), + }) - // The handler must check hasCheckpoint before attempting rebuild - expect(promptSrc).toMatch( - /if\s*\(input\.command\s*===\s*Command\.Default\.REBUILD\)[\s\S]*?hasCheckpoint/, - ) + await withSpawnRef(writer, () => + Instance.provide({ + directory: tmp.path, + fn: () => + run( + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const info = yield* sessions.create({ title: "rebuild-case-2" }) + yield* Effect.promise(() => seedUserMessage(info.id, "cold session, no checkpoint yet")) + yield* Effect.promise(() => seedUserMessage(info.id, "second turn on the cold session")) - // When no checkpoint exists, must call tryStartCheckpointWriter - expect(promptSrc).toMatch( - /if\s*\(input\.command\s*===\s*Command\.Default\.REBUILD\)[\s\S]*?tryStartCheckpointWriter/, - ) + // Cold session: no checkpoint file exists up front. + const result = yield* prompt.command({ + sessionID: info.id, + command: Command.Default.REBUILD, + arguments: "", + agent: "build", + }) - // Must wait for the writer via waitForWriter - expect(promptSrc).toMatch( - /if\s*\(input\.command\s*===\s*Command\.Default\.REBUILD\)[\s\S]*?waitForWriter/, - ) + // The writer wrote a checkpoint and the handler rebuilt from + // it → entered the runLoop → the model produced a reply. + expect(result.info.role).toBe("assistant") + expect( + result.parts.some((p) => p.type === "text" && p.text.includes("case2-model-reply")), + ).toBe(true) + expect(llm.calls).toBeGreaterThanOrEqual(1) - // After writer success, must call rebuildFromCheckpoint to insert boundary - expect(promptSrc).toMatch( - /if\s*\(input\.command\s*===\s*Command\.Default\.REBUILD\)[\s\S]*?writerOutcome.*success[\s\S]*?rebuildFromCheckpoint/, + // A rebuild boundary was inserted from the freshly-written checkpoint. + const msgs = yield* sessions.messages({ sessionID: info.id }) + const boundaries = msgs.filter((m) => m.parts.some((p) => p.type === "checkpoint")) + expect(boundaries.length).toBe(1) + expect( + msgs.some((m) => + m.parts.some( + (p) => p.type === "text" && p.text.includes("Context rebuilt from the latest checkpoint"), + ), + ), + ).toBe(true) + }), + ), + }), ) - }), + } finally { + await llm.stop() + } + }, + { timeout: 30_000 }, ) - it.live( - "busy status carries a descriptive message (source-level guard)", - () => - Effect.gen(function* () { - // Source-level guard: verify the /rebuild handler sets busy status - // WITH a descriptive message so the user sees what's happening. - const promptSrc = yield* Effect.promise(() => - Bun.file(`${import.meta.dir}/../../src/session/prompt.ts`).text(), - ) + test( + "case 2 fallback: no checkpoint + no spawnable writer → surfaces the no-checkpoint message without a model reply", + async () => { + const llm = startLLM("should-not-be-used-as-a-reply") + try { + await using tmp = await tmpdir({ + git: true, + init: (dir) => Bun.write(path.join(dir, "mimocode.json"), mimocodeConfig(llm.origin)), + }) - // Must set busy status with "Rebuilding context…" message before work. - // The source has \u2026 as a literal Unicode escape in the string, - // so the raw source text contains the 6 chars \u2026. - expect(promptSrc).toMatch( - /status[\s\S]*?\.set\(input\.sessionID,\s*\{\s*type:\s*"busy",\s*message:\s*"Rebuilding context\\u2026"\s*\}/, - ) + // Force NO writer: with spawnRef unset, tryStartCheckpointWriter cannot + // spawn and waitForWriter resolves "no-writer" → the handler must fall + // through to the no-checkpoint outcome (noReply, no runLoop). + await withSpawnRef(undefined, () => + Instance.provide({ + directory: tmp.path, + fn: () => + run( + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const info = yield* sessions.create({ title: "rebuild-case-2-fallback" }) + yield* Effect.promise(() => seedUserMessage(info.id, "cold session, no checkpoint yet")) - // Case 2 writer-wait path must set "Writing checkpoint…" message - expect(promptSrc).toMatch( - /status[\s\S]*?\.set\(input\.sessionID,\s*\{\s*type:\s*"busy",\s*message:\s*"Writing checkpoint\\u2026"\s*\}/, - ) + const result = yield* prompt.command({ + sessionID: info.id, + command: Command.Default.REBUILD, + arguments: "", + agent: "build", + }) + + // Handler surfaces the no-checkpoint message to the user… + expect( + result.parts.some( + (p) => p.type === "text" && p.text.includes("No checkpoint is available to rebuild from yet"), + ), + ).toBe(true) - // Must NOT use noReply:true on the rebuild-success path (so runLoop runs) - // The noReply:true should only appear in the no-checkpoint early-return path. - const rebuildBlock = promptSrc.slice( - promptSrc.indexOf("input.command === Command.Default.REBUILD"), + // …and did NOT enter the runLoop (noReply), so no assistant + // reply carrying the scripted text was produced. + const msgs = yield* sessions.messages({ sessionID: info.id }) + const modelReplied = msgs.some((m) => + m.parts.some((p) => p.type === "text" && p.text.includes("should-not-be-used-as-a-reply")), + ) + expect(modelReplied).toBe(false) + + // No rebuild boundary was inserted (nothing usable to rebuild from). + const boundaries = msgs.filter((m) => m.parts.some((p) => p.type === "checkpoint")) + expect(boundaries.length).toBe(0) + }), + ), + }), ) - // Find the two prompt() calls in the rebuild block - const firstPromptIdx = rebuildBlock.indexOf("yield* prompt({") - const secondPromptIdx = rebuildBlock.indexOf("yield* prompt({", firstPromptIdx + 1) - - // The second prompt (rebuild-success path) must NOT have noReply: true - if (secondPromptIdx >= 0) { - const secondPromptBlock = rebuildBlock.slice(secondPromptIdx, secondPromptIdx + 300) - expect(secondPromptBlock).not.toContain("noReply: true") + } finally { + await llm.stop() + } + }, + { timeout: 30_000 }, + ) + + test( + "busy status carries descriptive messages while the handler runs (observed on the Bus, not source text)", + async () => { + const llm = startLLM("busy-path-reply") + const writer = writerThatWritesCheckpoint("BUSY_CHECKPOINT_BODY") + const seen: Array = [] + // SessionStatus.set publishes on the instance Bus which also mirrors every + // event onto the process-wide GlobalBus. Subscribing here captures the + // real busy-status messages the handler emits, regardless of which Bus + // layer instance SessionPrompt.defaultLayer wired internally. + const onEvent = (e: { payload?: { type?: string; properties?: { status?: { type?: string; message?: string } } } }) => { + if (e?.payload?.type === "session.status" && e.payload.properties?.status?.type === "busy") { + seen.push(e.payload.properties.status.message) } - }), + } + GlobalBus.on("event", onEvent) + try { + await using tmp = await tmpdir({ + git: true, + init: (dir) => Bun.write(path.join(dir, "mimocode.json"), mimocodeConfig(llm.origin)), + }) + + await withSpawnRef(writer, () => + Instance.provide({ + directory: tmp.path, + fn: () => + run( + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + // Cold session → exercises BOTH busy messages: "Rebuilding + // context…" (set first) then "Writing checkpoint…" (set while + // waiting on the writer that this test provides). + const info = yield* sessions.create({ title: "rebuild-busy" }) + yield* Effect.promise(() => seedUserMessage(info.id, "no checkpoint here either")) + yield* Effect.promise(() => seedUserMessage(info.id, "second turn")) + + yield* prompt.command({ + sessionID: info.id, + command: Command.Default.REBUILD, + arguments: "", + agent: "build", + }) + }), + ), + }), + ) + } finally { + GlobalBus.off("event", onEvent) + await llm.stop() + } + + // The handler set busy with the human-readable messages the TUI shows. + expect(seen).toContain("Rebuilding context\u2026") + expect(seen).toContain("Writing checkpoint\u2026") + }, + { timeout: 30_000 }, ) }) From 3244ca732873207dd9346cc4c70cea96aa77a453 Mon Sep 17 00:00:00 2001 From: wqymi Date: Mon, 20 Jul 2026 22:41:04 +0800 Subject: [PATCH 007/135] fix(rebuild): manual /rebuild must not auto-reply (restore noReply) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #1752's core commit dropped noReply:true on the manual /rebuild success path (prompt.ts) so the runLoop would run after rebuild. That review-flagged 'deliberate behavior change' is the bug: a manual /rebuild is a user action whose intent is only to free/rebuild context — the user asked no question, so the model produces a spurious 'reply to nothing' turn (e.g. 'Ready for your next request'). Fix: restore noReply:true on the manual-/rebuild success path and clear busy status explicitly (the runLoop's onIdle no longer fires). The boundary is still inserted and the waiting UI ('Rebuilding context…' / 'Writing checkpoint…') still shows — only the spurious reply is gone. The AUTO-triggered rebuild path is untouched: it rebuilds mid-turn inside the runLoop and continues answering the pending user message, which is correct and necessary there. The distinction is structural — the auto path uses continue/return-continue inside loop() and never re-enters prompt(); only the manual handler calls prompt() with the synthetic note. Tests: rebuild-on-the-spot case 1 and case 2 now assert NO model reply after a manual /rebuild (llm.calls === 0, returned role != assistant), while still asserting the boundary insertion and busy-UI messages. --- packages/opencode/src/session/prompt.ts | 18 ++++++-- .../test/session/rebuild-on-the-spot.test.ts | 41 ++++++++++++++----- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 5d114c49c..627511c98 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -4245,10 +4245,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the return result } - // Checkpoint was inserted — enter the runLoop (no noReply) so the - // model sees the rebuilt context boundary and produces a response. - // The Runner's onIdle callback clears busy status automatically. - return yield* prompt({ + // Checkpoint was inserted. A MANUAL /rebuild is a user action whose + // whole intent is to free/rebuild the context — the user asked no + // question, so the model must NOT produce a reply. Return the synthetic + // note via noReply:true so the boundary is recorded and the waiting UI + // shown, but the runLoop is never entered (no spurious "reply to + // nothing" turn). The AUTO-triggered rebuild path (runLoop, prompt.ts + // ~3188/3761) is unaffected: it rebuilds mid-turn and `continue`s to + // answer the pending user message, which is correct there. + // Because the runLoop doesn't run, its onIdle won't clear busy status, + // so clear it explicitly here (mirrors the no-checkpoint paths above). + const result = yield* prompt({ sessionID: input.sessionID, messageID: input.messageID, agent: agentName, @@ -4259,7 +4266,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the synthetic: true, }, ], + noReply: true, }) + yield* status.set(input.sessionID, { type: "idle" }).pipe(Effect.catch(() => Effect.void)) + return result } const raw = input.arguments.match(argsRegex) ?? [] diff --git a/packages/opencode/test/session/rebuild-on-the-spot.test.ts b/packages/opencode/test/session/rebuild-on-the-spot.test.ts index 49e0c8203..51fbd45da 100644 --- a/packages/opencode/test/session/rebuild-on-the-spot.test.ts +++ b/packages/opencode/test/session/rebuild-on-the-spot.test.ts @@ -259,13 +259,28 @@ describe("Manual /rebuild: on-the-spot rebuild driven through SessionPrompt.comm agent: "build", }) - // The success path enters the runLoop → the model was called and - // an assistant reply came back (NOT the noReply no-checkpoint path). - expect(result.info.role).toBe("assistant") - expect(result.parts.some((p) => p.type === "text" && p.text.includes("rebuilt-reply-from-model"))).toBe( - true, + // A MANUAL /rebuild must NOT enter the runLoop: it inserts the + // boundary and returns the synthetic note WITHOUT producing a + // model reply (the user asked no question). So the returned + // message is the synthetic rebuild note (role "user", not an + // assistant turn), and the LLM was never called — no spurious + // "reply to nothing" turn. + expect(result.info.role).not.toBe("assistant") + expect( + result.parts.some( + (p) => p.type === "text" && p.text.includes("Context rebuilt from the latest checkpoint"), + ), + ).toBe(true) + expect( + result.parts.some((p) => p.type === "text" && p.text.includes("rebuilt-reply-from-model")), + ).toBe(false) + expect(llm.calls).toBe(0) + + // And no assistant reply carrying the scripted text landed in the DB. + const replied = (yield* sessions.messages({ sessionID: info.id })).some((m) => + m.parts.some((p) => p.type === "text" && p.text.includes("rebuilt-reply-from-model")), ) - expect(llm.calls).toBeGreaterThanOrEqual(1) + expect(replied).toBe(false) // A checkpoint boundary message was actually inserted into the DB. const after = yield* sessions.messages({ sessionID: info.id }) @@ -327,12 +342,18 @@ describe("Manual /rebuild: on-the-spot rebuild driven through SessionPrompt.comm }) // The writer wrote a checkpoint and the handler rebuilt from - // it → entered the runLoop → the model produced a reply. - expect(result.info.role).toBe("assistant") + // it, but a MANUAL /rebuild must NOT reply: it returns the + // synthetic rebuild note via noReply and never enters the + // runLoop, so the LLM is not called. expect( - result.parts.some((p) => p.type === "text" && p.text.includes("case2-model-reply")), + result.parts.some( + (p) => p.type === "text" && p.text.includes("Context rebuilt from the latest checkpoint"), + ), ).toBe(true) - expect(llm.calls).toBeGreaterThanOrEqual(1) + expect( + result.parts.some((p) => p.type === "text" && p.text.includes("case2-model-reply")), + ).toBe(false) + expect(llm.calls).toBe(0) // A rebuild boundary was inserted from the freshly-written checkpoint. const msgs = yield* sessions.messages({ sessionID: info.id }) From 91d3ef4e4361510e08ffe247d5cc31e98bd6899a Mon Sep 17 00:00:00 2001 From: wqymi Date: Thu, 23 Jul 2026 01:55:16 +0800 Subject: [PATCH 008/135] fix(rebuild): manual /rebuild inserts only the boundary, no fabricated user turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manual /rebuild handler previously did the right thing (Step A: rebuildFromCheckpoint → insertRebuildBoundary inserts the legitimate rebuild boundary as a role:"user" message with a checkpoint part — the SAME mechanism the auto-overflow and compaction rebuild paths use) but then fabricated a SECOND, standalone role:"user" turn ("Context rebuilt from the latest checkpoint…") via prompt({ noReply:true }). The auto paths never create that: they just `continue` the runLoop to answer the pending user message. noReply only suppressed the model REPLY; it did NOT stop createUserMessage from persisting the fabricated user message. So a manual /rebuild left extra role=user rows in the DB with zero assistant replies — the earlier noReply commit was a band-aid on the wrong layer. Real fix: remove the fabricated prompt({ noReply:true }) call for every /rebuild outcome. Manual /rebuild now mirrors the auto/compaction path — insert the boundary and settle — WITHOUT a second user turn: - success: return the freshly-inserted boundary message; surface "Context rebuilt…" on the SessionStatus / Bus status channel; go idle. - no usable checkpoint / degraded: return the existing last user message (persist nothing new); surface "No checkpoint available…" on the status channel; go idle. Manual /rebuild is a user-initiated maintenance action with no pending question, so after inserting the boundary it returns to idle (no model turn, no auto-reply) — the auto path `continue`s only because it has a pending message to answer. The noReply mechanism is preserved for other callers (e.g. /goal clear). Tests: rebuild-on-the-spot.test.ts now asserts that after a manual /rebuild the message table gains EXACTLY ONE new message (the boundary, role user + checkpoint part) — no fabricated "Context rebuilt…" user turn and no assistant reply — and that the outcome is surfaced on the status channel, not as a persisted user message. Keeps #1752's core value intact: on-the-spot rebuild, writer-freshness, and the "Rebuilding context…"/"Writing checkpoint…" busy UI. --- packages/opencode/src/session/prompt.ts | 115 +++++----- .../test/session/rebuild-on-the-spot.test.ts | 199 ++++++++++++------ 2 files changed, 190 insertions(+), 124 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 627511c98..3f06e8fd3 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -4142,16 +4142,42 @@ NOTE: At any point in time through this workflow you should feel free to ask the // 2. No usable checkpoint → start a writer and wait for it, then rebuild. // 3. Checkpoint exists + writer in-flight → wait (with timeout), rebuild // with the fresher checkpoint if it arrives, else fall back to existing. + // + // Manual /rebuild mirrors the AUTO rebuild/compaction path exactly: it + // inserts the legitimate rebuild BOUNDARY (a role:"user" message carrying + // a `checkpoint` part, via rebuildFromCheckpoint → insertRebuildBoundary) + // and then lets the session settle — WITHOUT fabricating a second, + // standalone user turn. The auto path (~prompt.ts:3205/3778) `continue`s + // the runLoop because it has a PENDING user message to answer; a manual + // /rebuild is a user-initiated maintenance action with NO pending + // question, so after inserting the boundary it simply returns to idle + // (no model turn, no auto-reply). + // + // The outcome ("context rebuilt" / "no checkpoint available yet") is + // surfaced to the user through the SessionStatus / Bus status channel — + // the same busy-status mechanism that drives "Rebuilding context…" / + // "Writing checkpoint…" — NOT through a persisted synthetic user message. // The busy status is set BEFORE any work so the TUI spinner lights up - // immediately; the Runner's onIdle callback clears it after the runLoop - // completes. For the no-checkpoint case (no work to do), we return a - // synthetic message without entering the runLoop, and clear idle in a - // finally block. + // immediately; because the runLoop is never entered, its onIdle won't + // clear busy status, so every return path clears idle explicitly. if (input.command === Command.Default.REBUILD) { const msgs = yield* sessions.messages({ sessionID: input.sessionID, agentID: "main" }) const lastUser = msgs.findLast((m) => m.info.role === "user") const model = yield* lastModel(input.sessionID) + // Emit the terminal outcome on the status channel, then return to idle. + // Returns the message the handler should hand back (never a fabricated + // user turn): the freshly-inserted boundary on success, else the + // existing last user message so callers still receive a WithParts. + const settle = Effect.fn("SessionPrompt.rebuild.settle")(function* (message: string) { + yield* status.set(input.sessionID, { type: "busy", message }).pipe(Effect.catch(() => Effect.void)) + yield* status.set(input.sessionID, { type: "idle" }).pipe(Effect.catch(() => Effect.void)) + }) + const noCheckpointMsg = + "No checkpoint is available to rebuild from yet — continue the conversation and a checkpoint will be written automatically." + const rebuiltMsg = + "Context rebuilt from the latest checkpoint. Recent messages are preserved; earlier context is now summarized." + // Set busy status so the TUI shows a spinner while we wait on the // writer (cases 2/3) or assemble context (case 1). yield* status.set(input.sessionID, { type: "busy", message: "Rebuilding context\u2026" }).pipe( @@ -4185,7 +4211,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the // safety bound (checkpoint.ts:985). On "success" the checkpoint file // is on disk and the watermark is advanced; on "failure" or // "no-writer" we fall through and let rebuildFromCheckpoint try - // whatever exists (or show the no-checkpoint message). + // whatever exists (or surface the no-checkpoint outcome). yield* status .set(input.sessionID, { type: "busy", message: "Writing checkpoint\u2026" }) .pipe(Effect.catch(() => Effect.void)) @@ -4193,23 +4219,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the .waitForWriter(input.sessionID) .pipe(Effect.catch(() => Effect.succeed<"success" | "failure" | "no-writer">("failure"))) if (writerOutcome !== "success") { - // Writer failed or wasn't running — tell the user. noReply since - // there's nothing for the model to respond to. - const result = yield* prompt({ - sessionID: input.sessionID, - messageID: input.messageID, - agent: agentName, - parts: [ - { - type: "text", - text: "No checkpoint is available to rebuild from yet — continue the conversation and a checkpoint will be written automatically.", - synthetic: true, - }, - ], - noReply: true, - }) - yield* status.set(input.sessionID, { type: "idle" }).pipe(Effect.catch(() => Effect.void)) - return result + // Writer failed or wasn't running — surface the outcome on the + // status channel and return to idle. No boundary was inserted and + // NO synthetic user turn is fabricated. + yield* settle(noCheckpointMsg) + return lastUser ?? msgs[msgs.length - 1]! } // Writer succeeded — fall through to rebuildFromCheckpoint which // will now find the freshly-written checkpoint and insert the @@ -4227,49 +4241,24 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (!inserted) { // Defensive: writer succeeded but boundary insertion still failed // (e.g. renderRebuildContext returned empty — degraded state). - // Fall back to the no-checkpoint message. - const result = yield* prompt({ - sessionID: input.sessionID, - messageID: input.messageID, - agent: agentName, - parts: [ - { - type: "text", - text: "No checkpoint is available to rebuild from yet — continue the conversation and a checkpoint will be written automatically.", - synthetic: true, - }, - ], - noReply: true, - }) - yield* status.set(input.sessionID, { type: "idle" }).pipe(Effect.catch(() => Effect.void)) - return result + // Surface the no-checkpoint outcome and return to idle. + yield* settle(noCheckpointMsg) + return lastUser ?? msgs[msgs.length - 1]! } - // Checkpoint was inserted. A MANUAL /rebuild is a user action whose - // whole intent is to free/rebuild the context — the user asked no - // question, so the model must NOT produce a reply. Return the synthetic - // note via noReply:true so the boundary is recorded and the waiting UI - // shown, but the runLoop is never entered (no spurious "reply to - // nothing" turn). The AUTO-triggered rebuild path (runLoop, prompt.ts - // ~3188/3761) is unaffected: it rebuilds mid-turn and `continue`s to - // answer the pending user message, which is correct there. - // Because the runLoop doesn't run, its onIdle won't clear busy status, - // so clear it explicitly here (mirrors the no-checkpoint paths above). - const result = yield* prompt({ - sessionID: input.sessionID, - messageID: input.messageID, - agent: agentName, - parts: [ - { - type: "text", - text: "Context rebuilt from the latest checkpoint. Recent messages are preserved; earlier context is now summarized.", - synthetic: true, - }, - ], - noReply: true, - }) - yield* status.set(input.sessionID, { type: "idle" }).pipe(Effect.catch(() => Effect.void)) - return result + // Boundary inserted (Step A — the shared, correct mechanism). A MANUAL + // /rebuild is a user action whose whole intent is to free/rebuild the + // context: the user asked no question, so the model must NOT reply and + // NO second user turn is fabricated. We surface the "context rebuilt" + // outcome on the status channel and return the boundary message itself + // (the newest role:"user" message carrying a checkpoint part), then go + // idle. The runLoop is never entered — mirroring the transparent + // boundary insertion the auto/compaction paths perform, minus their + // pending-message `continue`. + yield* settle(rebuiltMsg) + const after = yield* sessions.messages({ sessionID: input.sessionID, agentID: "main" }) + const boundaryMessage = after.findLast((m) => m.parts.some((p) => p.type === "checkpoint")) + return boundaryMessage ?? lastUser ?? after[after.length - 1]! } const raw = input.arguments.match(argsRegex) ?? [] diff --git a/packages/opencode/test/session/rebuild-on-the-spot.test.ts b/packages/opencode/test/session/rebuild-on-the-spot.test.ts index 51fbd45da..e9b7c2a74 100644 --- a/packages/opencode/test/session/rebuild-on-the-spot.test.ts +++ b/packages/opencode/test/session/rebuild-on-the-spot.test.ts @@ -200,11 +200,30 @@ async function seedUserMessage(sessionID: SessionID, text: string) { // status events published on the Bus. They intentionally avoid grepping // prompt.ts source text (which verifies nothing and breaks on refactors) per // AGENTS.md: "Test actual implementation, do not duplicate logic into tests". +// +// The core invariant they enforce is the real fix for #1752: a manual +// /rebuild inserts the legitimate rebuild BOUNDARY (a role:"user" message +// carrying a `checkpoint` part) and NOTHING ELSE — it must NOT fabricate a +// second, standalone "Context rebuilt…" user turn (the band-aid the earlier +// noReply approach left persisted), and it must NOT produce an assistant +// reply. Exactly ONE new message (the boundary) lands, mirroring the +// transparent boundary insertion the auto/compaction paths perform. The +// outcome is surfaced on the SessionStatus / Bus status channel, not as a +// persisted user message. describe("Manual /rebuild: on-the-spot rebuild driven through SessionPrompt.command", () => { test( - "case 1: checkpoint on disk + no writer → handler inserts a boundary and enters the runLoop", + "case 1: checkpoint on disk + no writer → inserts EXACTLY the boundary (no fabricated user turn, no reply)", async () => { const llm = startLLM("rebuilt-reply-from-model") + const seen: Array = [] + const onEvent = (e: { + payload?: { type?: string; properties?: { status?: { type?: string; message?: string } } } + }) => { + if (e?.payload?.type === "session.status" && e.payload.properties?.status?.type === "busy") { + seen.push(e.payload.properties.status.message) + } + } + GlobalBus.on("event", onEvent) try { await using tmp = await tmpdir({ git: true, @@ -249,7 +268,7 @@ describe("Manual /rebuild: on-the-spot rebuild driven through SessionPrompt.comm ) const before = yield* sessions.messages({ sessionID: info.id }) - const userCountBefore = before.filter((m) => m.info.role === "user").length + const countBefore = before.length // Drive the real handler. const result = yield* prompt.command({ @@ -259,49 +278,60 @@ describe("Manual /rebuild: on-the-spot rebuild driven through SessionPrompt.comm agent: "build", }) - // A MANUAL /rebuild must NOT enter the runLoop: it inserts the - // boundary and returns the synthetic note WITHOUT producing a - // model reply (the user asked no question). So the returned - // message is the synthetic rebuild note (role "user", not an - // assistant turn), and the LLM was never called — no spurious - // "reply to nothing" turn. + // A MANUAL /rebuild must NOT enter the runLoop: the user asked + // no question, so the LLM was never called. expect(result.info.role).not.toBe("assistant") - expect( - result.parts.some( - (p) => p.type === "text" && p.text.includes("Context rebuilt from the latest checkpoint"), - ), - ).toBe(true) - expect( - result.parts.some((p) => p.type === "text" && p.text.includes("rebuilt-reply-from-model")), - ).toBe(false) expect(llm.calls).toBe(0) - // And no assistant reply carrying the scripted text landed in the DB. - const replied = (yield* sessions.messages({ sessionID: info.id })).some((m) => - m.parts.some((p) => p.type === "text" && p.text.includes("rebuilt-reply-from-model")), - ) - expect(replied).toBe(false) - - // A checkpoint boundary message was actually inserted into the DB. const after = yield* sessions.messages({ sessionID: info.id }) + + // EXACTLY ONE new message landed — the rebuild boundary — and + // nothing else. This is the crux of the #1752 fix: no fabricated + // second "Context rebuilt…" user turn. + expect(after.length).toBe(countBefore + 1) + + // That one new message IS the boundary: role "user" carrying a + // `checkpoint` part (the shared, correct mechanism). const boundaries = after.filter((m) => m.parts.some((p) => p.type === "checkpoint")) expect(boundaries.length).toBe(1) + expect(boundaries[0]!.info.role).toBe("user") + + // The handler returns the boundary message itself, not a + // fabricated note. + expect(result.parts.some((p) => p.type === "checkpoint")).toBe(true) + + // No fabricated standalone "Context rebuilt…" user turn is + // persisted anywhere (the band-aid the old path left behind). + const fabricated = after.some( + (m) => + !m.parts.some((p) => p.type === "checkpoint") && + m.parts.some( + (p) => p.type === "text" && p.text.includes("Context rebuilt from the latest checkpoint"), + ), + ) + expect(fabricated).toBe(false) - // The rebuild-success synthetic prose is present on the boundary run. - const rebuiltNote = after.some((m) => - m.parts.some( - (p) => p.type === "text" && p.text.includes("Context rebuilt from the latest checkpoint"), - ), + // No assistant reply carrying the scripted text landed in the DB. + const replied = after.some((m) => + m.parts.some((p) => p.type === "text" && p.text.includes("rebuilt-reply-from-model")), ) - expect(rebuiltNote).toBe(true) + expect(replied).toBe(false) // Original conversation preserved (3 seeded users still there). + const userCountBefore = before.filter((m) => m.info.role === "user").length const userCountAfter = after.filter((m) => m.info.role === "user").length expect(userCountAfter).toBeGreaterThanOrEqual(userCountBefore) + + // Outcome surfaced on the status channel (not a persisted user + // message): the terminal "context rebuilt" message was emitted. + expect( + seen.some((m) => m?.includes("Context rebuilt from the latest checkpoint")), + ).toBe(true) }), ), }) } finally { + GlobalBus.off("event", onEvent) await llm.stop() } }, @@ -309,12 +339,21 @@ describe("Manual /rebuild: on-the-spot rebuild driven through SessionPrompt.comm ) test( - "case 2: no checkpoint → handler spawns a writer, waits for it, then rebuilds from the fresh checkpoint", + "case 2: no checkpoint → spawns a writer, waits, then inserts EXACTLY the fresh boundary (no fabricated turn, no reply)", async () => { const llm = startLLM("case2-model-reply") // The writer stub writes a real checkpoint on spawn and reports success, // exercising the handler's spawn→wait→rebuild path for real. const writer = writerThatWritesCheckpoint("CASE2_FRESH_CHECKPOINT_BODY") + const seen: Array = [] + const onEvent = (e: { + payload?: { type?: string; properties?: { status?: { type?: string; message?: string } } } + }) => { + if (e?.payload?.type === "session.status" && e.payload.properties?.status?.type === "busy") { + seen.push(e.payload.properties.status.message) + } + } + GlobalBus.on("event", onEvent) try { await using tmp = await tmpdir({ git: true, @@ -333,6 +372,9 @@ describe("Manual /rebuild: on-the-spot rebuild driven through SessionPrompt.comm yield* Effect.promise(() => seedUserMessage(info.id, "cold session, no checkpoint yet")) yield* Effect.promise(() => seedUserMessage(info.id, "second turn on the cold session")) + const before = yield* sessions.messages({ sessionID: info.id }) + const countBefore = before.length + // Cold session: no checkpoint file exists up front. const result = yield* prompt.command({ sessionID: info.id, @@ -341,36 +383,47 @@ describe("Manual /rebuild: on-the-spot rebuild driven through SessionPrompt.comm agent: "build", }) - // The writer wrote a checkpoint and the handler rebuilt from - // it, but a MANUAL /rebuild must NOT reply: it returns the - // synthetic rebuild note via noReply and never enters the - // runLoop, so the LLM is not called. - expect( - result.parts.some( - (p) => p.type === "text" && p.text.includes("Context rebuilt from the latest checkpoint"), - ), - ).toBe(true) - expect( - result.parts.some((p) => p.type === "text" && p.text.includes("case2-model-reply")), - ).toBe(false) + // A MANUAL /rebuild must NOT reply: the LLM is not called. + expect(result.info.role).not.toBe("assistant") expect(llm.calls).toBe(0) - // A rebuild boundary was inserted from the freshly-written checkpoint. - const msgs = yield* sessions.messages({ sessionID: info.id }) - const boundaries = msgs.filter((m) => m.parts.some((p) => p.type === "checkpoint")) + const after = yield* sessions.messages({ sessionID: info.id }) + + // EXACTLY ONE new message: the boundary rebuilt from the + // freshly-written checkpoint. No fabricated "Context rebuilt…" + // user turn. + expect(after.length).toBe(countBefore + 1) + + const boundaries = after.filter((m) => m.parts.some((p) => p.type === "checkpoint")) expect(boundaries.length).toBe(1) - expect( - msgs.some((m) => + expect(boundaries[0]!.info.role).toBe("user") + expect(result.parts.some((p) => p.type === "checkpoint")).toBe(true) + + const fabricated = after.some( + (m) => + !m.parts.some((p) => p.type === "checkpoint") && m.parts.some( (p) => p.type === "text" && p.text.includes("Context rebuilt from the latest checkpoint"), ), - ), + ) + expect(fabricated).toBe(false) + + const replied = after.some((m) => + m.parts.some((p) => p.type === "text" && p.text.includes("case2-model-reply")), + ) + expect(replied).toBe(false) + + // Outcome surfaced on the status channel, not a persisted user + // message. + expect( + seen.some((m) => m?.includes("Context rebuilt from the latest checkpoint")), ).toBe(true) }), ), }), ) } finally { + GlobalBus.off("event", onEvent) await llm.stop() } }, @@ -378,9 +431,18 @@ describe("Manual /rebuild: on-the-spot rebuild driven through SessionPrompt.comm ) test( - "case 2 fallback: no checkpoint + no spawnable writer → surfaces the no-checkpoint message without a model reply", + "case 2 fallback: no checkpoint + no spawnable writer → surfaces the no-checkpoint outcome on the status channel, persists nothing", async () => { const llm = startLLM("should-not-be-used-as-a-reply") + const seen: Array = [] + const onEvent = (e: { + payload?: { type?: string; properties?: { status?: { type?: string; message?: string } } } + }) => { + if (e?.payload?.type === "session.status" && e.payload.properties?.status?.type === "busy") { + seen.push(e.payload.properties.status.message) + } + } + GlobalBus.on("event", onEvent) try { await using tmp = await tmpdir({ git: true, @@ -389,7 +451,8 @@ describe("Manual /rebuild: on-the-spot rebuild driven through SessionPrompt.comm // Force NO writer: with spawnRef unset, tryStartCheckpointWriter cannot // spawn and waitForWriter resolves "no-writer" → the handler must fall - // through to the no-checkpoint outcome (noReply, no runLoop). + // through to the no-checkpoint outcome (status channel, no runLoop, no + // persisted message). await withSpawnRef(undefined, () => Instance.provide({ directory: tmp.path, @@ -401,6 +464,9 @@ describe("Manual /rebuild: on-the-spot rebuild driven through SessionPrompt.comm const info = yield* sessions.create({ title: "rebuild-case-2-fallback" }) yield* Effect.promise(() => seedUserMessage(info.id, "cold session, no checkpoint yet")) + const before = yield* sessions.messages({ sessionID: info.id }) + const countBefore = before.length + const result = yield* prompt.command({ sessionID: info.id, command: Command.Default.REBUILD, @@ -408,29 +474,40 @@ describe("Manual /rebuild: on-the-spot rebuild driven through SessionPrompt.comm agent: "build", }) - // Handler surfaces the no-checkpoint message to the user… - expect( - result.parts.some( + // Did NOT enter the runLoop (no reply produced). + expect(result.info.role).not.toBe("assistant") + + const after = yield* sessions.messages({ sessionID: info.id }) + + // NOTHING was persisted: no boundary (nothing to rebuild from) + // and no fabricated "No checkpoint…" user turn. The message + // count is unchanged. + expect(after.length).toBe(countBefore) + const boundaries = after.filter((m) => m.parts.some((p) => p.type === "checkpoint")) + expect(boundaries.length).toBe(0) + const fabricated = after.some((m) => + m.parts.some( (p) => p.type === "text" && p.text.includes("No checkpoint is available to rebuild from yet"), ), - ).toBe(true) + ) + expect(fabricated).toBe(false) - // …and did NOT enter the runLoop (noReply), so no assistant - // reply carrying the scripted text was produced. - const msgs = yield* sessions.messages({ sessionID: info.id }) - const modelReplied = msgs.some((m) => + // No assistant reply carrying the scripted text was produced. + const modelReplied = after.some((m) => m.parts.some((p) => p.type === "text" && p.text.includes("should-not-be-used-as-a-reply")), ) expect(modelReplied).toBe(false) - // No rebuild boundary was inserted (nothing usable to rebuild from). - const boundaries = msgs.filter((m) => m.parts.some((p) => p.type === "checkpoint")) - expect(boundaries.length).toBe(0) + // The outcome IS surfaced — on the status channel. + expect( + seen.some((m) => m?.includes("No checkpoint is available to rebuild from yet")), + ).toBe(true) }), ), }), ) } finally { + GlobalBus.off("event", onEvent) await llm.stop() } }, From 054775d08744e4ba487f9758c029d6446049a112 Mon Sep 17 00:00:00 2001 From: wqymi Date: Mon, 27 Jul 2026 20:12:50 +0800 Subject: [PATCH 009/135] fix(tui): make the /rebuild context boundary visible in the transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/rebuild` inserts one user message whose parts are a `checkpoint` part plus `synthetic: true` text parts. The TUI's PART_MAPPING covers only text/tool/reasoning, and UserMessage renders only when a NON-synthetic text part exists — so the whole boundary message drew zero rows and the user had no way to tell a rebuild happened, or where. Render the checkpoint part as a one-line badge row, reusing the badge pattern already used for cron fires and actor notifications. Deliberately a render-only fix: rebuild's model-facing context is NOT changed. Compaction gets its visibility for free because it writes a real `summary: true` assistant message (compaction.ts) whose text renders through TextPart; rebuild instead puts its summary inline on the boundary user turn as synthetic text. Both DO reach the model — the user-part converter filters on `!part.ignored`, not `!part.synthetic` (message-v2.ts) — so rebuild's context is already at least as informative as compaction's, without a second LLM call. Copying compaction's assistant-message shape would duplicate that text in context and alter model semantics for no comprehension gain, so only the render layer moves. test/session/rebuild-boundary-model-context.test.ts pins that equivalence: the synthetic rebuild content and the compaction summary assistant turn both survive into the model messages, and filterCompacted keeps the summary message. --- packages/opencode/src/cli/cmd/tui/i18n/en.ts | 4 + packages/opencode/src/cli/cmd/tui/i18n/zh.ts | 4 + packages/opencode/src/cli/cmd/tui/i18n/zht.ts | 4 + .../src/cli/cmd/tui/routes/session/index.tsx | 20 ++ .../cmd/tui/rebuild-boundary-marker.test.ts | 31 +++ .../rebuild-boundary-model-context.test.ts | 176 ++++++++++++++++++ 6 files changed, 239 insertions(+) create mode 100644 packages/opencode/test/cli/cmd/tui/rebuild-boundary-marker.test.ts create mode 100644 packages/opencode/test/session/rebuild-boundary-model-context.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/i18n/en.ts b/packages/opencode/src/cli/cmd/tui/i18n/en.ts index 039767425..5154882f4 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/en.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/en.ts @@ -560,6 +560,10 @@ export const dict: Record = { // Session badges "tui.session.badge.auto": "Auto", + // Context rebuild boundary marker (inserted by /rebuild) + "tui.session.rebuild_boundary.label": "context rebuilt", + "tui.session.rebuild_boundary.detail": "earlier messages summarized", + // Workspace trust "trust.title": "Accessing workspace:", "trust.safety_check": "Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open source project, or work from your team). If not, take a moment to review what's in this folder first.", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/zh.ts b/packages/opencode/src/cli/cmd/tui/i18n/zh.ts index 7347d5fe4..c067ca8f6 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/zh.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/zh.ts @@ -581,6 +581,10 @@ export const dict = { // Session badges "tui.session.badge.auto": "自动", + // Context rebuild boundary marker (inserted by /rebuild) + "tui.session.rebuild_boundary.label": "上下文已重建", + "tui.session.rebuild_boundary.detail": "较早消息已摘要", + // Workspace trust "trust.title": "访问工作区:", "trust.safety_check": "安全确认:这是你自己创建或信任的项目吗?(如你自己的代码、知名开源项目或团队内部项目)。如果不是,请先检查此目录下的内容。", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/zht.ts b/packages/opencode/src/cli/cmd/tui/i18n/zht.ts index 23cc47497..e74894971 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/zht.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/zht.ts @@ -550,6 +550,10 @@ export const dict = { // Session badges "tui.session.badge.auto": "自動", + // Context rebuild boundary marker (inserted by /rebuild) + "tui.session.rebuild_boundary.label": "上下文已重建", + "tui.session.rebuild_boundary.detail": "較早訊息已摘要", + // Workspace trust "trust.title": "存取工作區:", "trust.safety_check": "安全確認:這是你自己建立或信任的專案嗎?(如你自己的程式碼、知名開源專案或團隊內部專案)。如果不是,請先檢查此目錄下的內容。", diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index bc38ccb43..201167b53 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1536,7 +1536,16 @@ function UserMessage(props: { return parsed ? [parsed] : [] })[0] }) + // A context rebuild (`/rebuild`) inserts a single user message carrying a + // `checkpoint` part plus `synthetic: true` text parts (the rendered context + // and index). Neither renders — `checkpoint` has no PART_MAPPING entry and + // synthetic text is excluded from `text()` above — so the boundary used to be + // completely invisible in the transcript, unlike compaction which at least + // leaves a visible summary message behind. Surface it as a one-line marker + // row so the user can see that a rebuild happened and where. + const rebuildBoundary = createMemo(() => props.parts.some((x) => x.type === "checkpoint")) const { theme } = useTheme() + const t = useLanguage().t const [hover, setHover] = createSignal(false) const queued = createMemo(() => props.pending && props.message.id > props.pending) const color = createMemo(() => local.agent.color(props.message.agent)) @@ -1603,6 +1612,17 @@ function UserMessage(props: { ) }} + + + + + {" "} + ⟲ {t("tui.session.rebuild_boundary.label")}{" "} + + {t("tui.session.rebuild_boundary.detail")} + + + { + test("english copy names the rebuild and what happened to earlier messages", () => { + expect(en["tui.session.rebuild_boundary.label"]).toBe("context rebuilt") + expect(en["tui.session.rebuild_boundary.detail"]).toBe("earlier messages summarized") + }) + + test("localized dictionaries define the marker copy", () => { + for (const key of KEYS) { + for (const [name, dict] of Object.entries({ en, zh, zht })) { + expect(dict[key], `${name} is missing ${key}`).toBeTruthy() + } + // Untranslated keys silently fall back to English via the `base` merge in + // context/language.tsx, so an English string here means a missed + // translation rather than a crash — assert it is actually translated. + expect(zh[key]).not.toBe(en[key]) + expect(zht[key]).not.toBe(en[key]) + } + }) +}) diff --git a/packages/opencode/test/session/rebuild-boundary-model-context.test.ts b/packages/opencode/test/session/rebuild-boundary-model-context.test.ts new file mode 100644 index 000000000..2bbc91aef --- /dev/null +++ b/packages/opencode/test/session/rebuild-boundary-model-context.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from "bun:test" +import { MessageV2 } from "../../src/session/message-v2" +import type { Provider } from "../../src/provider" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { SessionID, MessageID, PartID } from "../../src/session/schema" + +// Pins the AI-facing semantics of the two context-boundary mechanisms so a +// future "let's make rebuild look like compaction" refactor cannot silently +// change what the model receives: +// +// compaction — boundary user message carries only a `compaction` part, which +// becomes the bare label "Summary of previous conversation:" +// (message-v2.ts). The actual summary text lives in a SEPARATE +// `summary: true` assistant message written by processCompaction +// (compaction.ts), and that assistant turn is NOT filtered out. +// +// rebuild — boundary user message carries a `checkpoint` part (label +// "Summary of previous conversation from checkpoint files:") PLUS the +// rendered checkpoint index / rebuild context as `synthetic: true` text +// parts. Synthetic text is excluded from the TUI transcript but IS sent to +// the model: the user-part filter is `!part.ignored`, not `!part.synthetic`. +// +// Net: both boundaries put a real summary into the model context. Rebuild's +// arrives inline on the boundary user turn; compaction's arrives on the +// following assistant turn. + +const sessionID = SessionID.make("session") +const providerID = ProviderID.make("test") + +const model: Provider.Model = { + id: ModelID.make("test-model"), + providerID, + api: { id: "test-model", url: "https://example.com", npm: "@ai-sdk/openai" }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 0, input: 0, output: 0 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-01-01", +} + +function userInfo(id: string): MessageV2.User { + return { + id, + sessionID, + role: "user", + time: { created: 0 }, + agent: "user", + model: { providerID, modelID: ModelID.make("test") }, + tools: {}, + mode: "", + } as unknown as MessageV2.User +} + +function summaryAssistantInfo(id: string, parentID: string): MessageV2.Assistant { + return { + id, + sessionID, + role: "assistant", + time: { created: 0 }, + parentID, + modelID: model.api.id, + providerID: model.providerID, + mode: "compaction", + agent: "compaction", + summary: true, + path: { cwd: "/", root: "/" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } as unknown as MessageV2.Assistant +} + +function basePart(messageID: string, id: string) { + return { + id: PartID.make(id), + sessionID, + messageID: MessageID.make(messageID), + } +} + +const INDEX_TEXT = "## Checkpoint\n\nDirectory: /tmp/cp/\n" +const REBUILD_TEXT = "## Rebuild context\n\nprior turns summarized here\n" +const COMPACTION_SUMMARY_TEXT = "The user asked for X; we did Y." + +describe("context boundaries: what reaches the model", () => { + test("rebuild boundary sends the checkpoint label AND the synthetic rebuild content", async () => { + const boundaryID = "m-rebuild-boundary" + const messages = await MessageV2.toModelMessages( + [ + { + info: userInfo(boundaryID), + parts: [ + { + ...basePart(boundaryID, "p1"), + type: "checkpoint", + checkpointDir: "", + checkpointNumber: 0, + coveredUpTo: MessageID.make("m-old"), + }, + { ...basePart(boundaryID, "p2"), type: "text", synthetic: true, text: INDEX_TEXT }, + { ...basePart(boundaryID, "p3"), type: "text", synthetic: true, text: REBUILD_TEXT }, + ] as MessageV2.Part[], + }, + ], + model, + ) + + expect(messages).toHaveLength(1) + expect(messages[0].role).toBe("user") + const rendered = JSON.stringify(messages[0].content) + expect(rendered).toContain("Summary of previous conversation from checkpoint files:") + // `synthetic: true` hides these from the transcript, never from the model. + expect(rendered).toContain("## Checkpoint") + expect(rendered).toContain("prior turns summarized here") + }) + + test("compaction boundary sends a bare label; the summary rides on the assistant turn", async () => { + const boundaryID = "m-compaction-boundary" + const summaryID = "m-compaction-summary" + const messages = await MessageV2.toModelMessages( + [ + { + info: userInfo(boundaryID), + parts: [{ ...basePart(boundaryID, "p1"), type: "compaction", auto: false }] as MessageV2.Part[], + }, + { + info: summaryAssistantInfo(summaryID, boundaryID), + parts: [ + { ...basePart(summaryID, "p2"), type: "text", text: COMPACTION_SUMMARY_TEXT }, + ] as MessageV2.Part[], + }, + ], + model, + ) + + expect(messages).toHaveLength(2) + expect(messages[0].role).toBe("user") + expect(JSON.stringify(messages[0].content)).toContain("Summary of previous conversation:") + // `summary: true` is NOT a filter — the compaction summary is a real + // assistant turn in the model context. + expect(messages[1].role).toBe("assistant") + expect(JSON.stringify(messages[1].content)).toContain(COMPACTION_SUMMARY_TEXT) + }) + + test("filterCompacted keeps the compaction summary assistant message after the boundary", () => { + const boundaryID = "m-compaction-boundary" + const summaryID = "m-compaction-summary" + // stream() yields newest-first; filterCompacted stops at the boundary and reverses. + const window = MessageV2.filterCompacted([ + { + info: summaryAssistantInfo(summaryID, boundaryID), + parts: [{ ...basePart(summaryID, "p2"), type: "text", text: COMPACTION_SUMMARY_TEXT }] as MessageV2.Part[], + }, + { + info: userInfo(boundaryID), + parts: [{ ...basePart(boundaryID, "p1"), type: "compaction", auto: false }] as MessageV2.Part[], + }, + { + info: userInfo("m-ancient"), + parts: [{ ...basePart("m-ancient", "p0"), type: "text", text: "dropped" }] as MessageV2.Part[], + }, + ]) + + expect(window.map((m) => String(m.info.id))).toEqual([boundaryID, summaryID]) + }) +}) From 6b3179537dd5176ca32396221c8b72e7237f103e Mon Sep 17 00:00:00 2001 From: Murat Date: Mon, 27 Jul 2026 15:12:36 +0200 Subject: [PATCH 010/135] fix(plugin): skip non-function exports in getLegacyPlugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External plugins fail to load because getLegacyPlugins throws TypeError when encountering any export that is not a function and doesn't have a 'server' property (e.g. constants, config objects, type re-exports). This causes the entire plugin to be silently skipped — the error is caught in applyPlugin's Effect.catch and logged, but hooks are never registered. Fix: change 'throw' to 'continue' so non-plugin exports are silently skipped, matching the behavior of getServerPlugin which already returns undefined for non-plugin values. This fixes the issue where external plugins (plugin: [...]) don't execute their module code in MiMoCode 0.38.9. --- packages/opencode/src/plugin/index.ts | 1472 ++++++++++++------------- 1 file changed, 736 insertions(+), 736 deletions(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 9a4713633..fd8a36058 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -1,736 +1,736 @@ -import type { - Hooks, - PluginInput, - Plugin as PluginInstance, - PluginModule, - WorkspaceAdaptor as PluginWorkspaceAdaptor, - ActorPreStopInput, - ActorPostStopInput, - ActorStopOutput, - ActorMatcher, -} from "@mimo-ai/plugin" -import { z } from "zod" -import { matchesActor } from "./matcher" -import { Config } from "../config" -import { Bus } from "../bus" -import { BusEvent } from "../bus/bus-event" -import { Log } from "../util" -import { createOpencodeClient } from "@mimo-ai/sdk" -import { Flag } from "../flag/flag" -import { CodexAuthPlugin } from "./codex" -import { XaiAuthPlugin } from "./xai" -import { MimoAuthPlugin, AnthropicProxyPlugin } from "./mimo" -import { Session } from "../session" -import type { SessionID } from "../session/schema" -import { NamedError } from "@mimo-ai/shared/util/error" -import { CopilotAuthPlugin } from "./github-copilot/copilot" -import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" -import { PoeAuthPlugin } from "opencode-poe-auth" -import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" -import { CheckpointSplitoverPlugin } from "./checkpoint-splitover" -import { SubagentProgressCheckerPlugin } from "./subagent-progress-checker" -import { Effect, Layer, Context, Stream } from "effect" -import { EffectBridge } from "@/effect" -import { InstanceState } from "@/effect" -import { errorMessage } from "@/util/error" -import { PluginLoader } from "./loader" -import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" -import { registerAdaptor } from "@/control-plane/adaptors" -import type { WorkspaceAdaptor } from "@/control-plane/types" -import { Glob } from "@mimo-ai/shared/util/glob" -import fs from "fs" -import path from "path" -import { pathToFileURL, fileURLToPath } from "url" - -const log = Log.create({ service: "plugin" }) - -export const HookEvent = { - Executed: BusEvent.define( - "hook.executed", - z.object({ - event: z.enum(["actor.preStop", "actor.postStop"]), - hookID: z.string(), - pluginName: z.string(), - actorID: z.string(), - agentType: z.string(), - durationMs: z.number(), - outcome: z.enum(["success", "error", "skipped"]), - continueRequested: z.boolean(), - reasonLength: z.number(), - }), - ), - ReActReentered: BusEvent.define( - "hook.react.reentered", - z.object({ - phase: z.enum(["pre", "post"]), - actorID: z.string(), - agentType: z.string(), - iteration: z.number(), - triggeredByPlugins: z.array(z.string()), - reasonPreview: z.string(), - }), - ), - ReActMaxReached: BusEvent.define( - "hook.react.max_reached", - z.object({ - phase: z.enum(["pre", "post"]), - actorID: z.string(), - agentType: z.string(), - }), - ), -} as const - -type HookEntry = { - hook: Hooks - pluginName: string - /** Stable per-event hook ID: `${pluginName}#${eventName}` */ - hookIDFor: (eventName: string) => string -} - -type State = { - hooks: Hooks[] - hooksWithMeta: HookEntry[] -} - -type FileHookState = { - hooks: Hooks[] - meta: HookEntry[] - dirs: string[] - /** Absolute path -> mtimeMs at load time, for cheap staleness checks. */ - files: Record - /** Mutable box: last staleness check timestamp (throttle). */ - lastCheck: { value: number } -} - -const FILE_HOOK_GLOB = "{hook,hooks}/*.{js,ts}" -const FILE_HOOK_CHECK_INTERVAL_MS = 500 - -export type ActorStopAggregatedDecision = ActorStopOutput & { - contributingPluginNames: string[] - contributingHookIDs: string[] -} - -// Hook names that follow the (input, output) => Promise trigger pattern -type TriggerName = { - [K in keyof Hooks]-?: NonNullable extends (input: any, output: any) => Promise ? K : never -}[keyof Hooks] - -export interface Interface { - readonly trigger: < - Name extends TriggerName, - Input = Parameters[Name]>[0], - Output = Parameters[Name]>[1], - >( - name: Name, - input: Input, - output: Output, - ) => Effect.Effect - readonly list: () => Effect.Effect - readonly init: () => Effect.Effect - readonly reloadFileHooks: () => Effect.Effect - readonly triggerActorPreStop: ( - input: ActorPreStopInput, - ) => Effect.Effect - readonly triggerActorPostStop: ( - input: ActorPostStopInput, - ) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/Plugin") {} - -// Built-in plugins that are directly imported (not installed from npm) -const INTERNAL_PLUGINS: PluginInstance[] = [ - MimoAuthPlugin, - AnthropicProxyPlugin, - CodexAuthPlugin, - XaiAuthPlugin, - CopilotAuthPlugin, - // gitlab/poe auth are external npm packages typed against the published - // upstream plugin package, which carries a duplicate (nominal) copy of the - // SDK client; cast through unknown to the workspace Plugin type. - GitlabAuthPlugin as unknown as PluginInstance, - PoeAuthPlugin as unknown as PluginInstance, - CloudflareWorkersAuthPlugin, - CloudflareAIGatewayAuthPlugin, - CheckpointSplitoverPlugin, - SubagentProgressCheckerPlugin, -] - -function isServerPlugin(value: unknown): value is PluginInstance { - return typeof value === "function" -} - -function getServerPlugin(value: unknown) { - if (isServerPlugin(value)) return value - if (!value || typeof value !== "object" || !("server" in value)) return - if (!isServerPlugin(value.server)) return - return value.server -} - -function getLegacyPlugins(mod: Record) { - const seen = new Set() - const result: PluginInstance[] = [] - - for (const entry of Object.values(mod)) { - if (seen.has(entry)) continue - seen.add(entry) - const plugin = getServerPlugin(entry) - if (!plugin) throw new TypeError("Plugin export is not a function") - result.push(plugin) - } - - return result -} - -async function applyPlugin( - load: PluginLoader.Loaded, - input: PluginInput, - hooks: Hooks[], - hooksWithMeta: HookEntry[], -) { - const plugin = readV1Plugin(load.mod, load.spec, "server", "detect") - if (plugin) { - await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg) - const pluginName = readPluginId(plugin.id, load.spec) ?? load.pkg?.pkg ?? load.spec - const hookObj = await (plugin as PluginModule).server(input, load.options) - hooks.push(hookObj) - hooksWithMeta.push({ - hook: hookObj, - pluginName, - hookIDFor: (event: string) => `${pluginName}#${event}`, - }) - return - } - - for (const server of getLegacyPlugins(load.mod)) { - const fnName = (server as { name?: string }).name - const pluginName = fnName && fnName !== "default" && fnName !== "" - ? fnName - : (load.pkg?.pkg ?? load.spec) - const hookObj = await server(input, load.options) - hooks.push(hookObj) - hooksWithMeta.push({ - hook: hookObj, - pluginName, - hookIDFor: (event: string) => `${pluginName}#${event}`, - }) - } -} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const bus = yield* Bus.Service - const config = yield* Config.Service - - const state = yield* InstanceState.make( - Effect.fn("Plugin.state")(function* (ctx) { - const hooks: Hooks[] = [] - const hooksWithMeta: HookEntry[] = [] - const bridge = yield* EffectBridge.make() - - function publishPluginError(message: string) { - bridge.fork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })) - } - - const { Server } = yield* Effect.promise(() => import("../server/server")) - - const client = createOpencodeClient({ - baseUrl: "http://localhost:4096", - directory: ctx.directory, - headers: Flag.MIMOCODE_SERVER_PASSWORD - ? { - Authorization: `Basic ${Buffer.from(`${Flag.MIMOCODE_SERVER_USERNAME ?? "mimocode"}:${Flag.MIMOCODE_SERVER_PASSWORD}`).toString("base64")}`, - } - : undefined, - fetch: async (...args) => (await Server.Default()).app.fetch(...args), - }) - const cfg = yield* config.get() - const input: PluginInput = { - client, - project: ctx.project, - worktree: ctx.worktree, - directory: ctx.directory, - experimental_workspace: { - register(type: string, adaptor: PluginWorkspaceAdaptor) { - registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor) - }, - }, - get serverUrl(): URL { - return Server.url ?? new URL("http://localhost:4096") - }, - // @ts-expect-error - $: typeof Bun === "undefined" ? undefined : Bun.$, - } - - for (const plugin of INTERNAL_PLUGINS) { - log.info("loading internal plugin", { name: plugin.name }) - const init = yield* Effect.tryPromise({ - try: () => plugin(input), - catch: (err) => { - log.error("failed to load internal plugin", { name: plugin.name, error: err }) - }, - }).pipe(Effect.option) - if (init._tag === "Some") { - hooks.push(init.value) - hooksWithMeta.push({ - hook: init.value, - pluginName: plugin.name, - hookIDFor: (event: string) => `${plugin.name}#${event}`, - }) - } - } - - // Load optional local extensions under src/ext/. Prefers the generated - // _manifest.ts (a fixed import specifier resolves inside Bun single-file - // executables, where filesystem scans do not); falls back to a directory - // scan for unbundled runs. Each *Plugin-named export is registered. - const extModules: Record> = {} - // @ts-ignore generated manifest; may not exist at type-check time - const manifest = yield* Effect.tryPromise(() => import("../ext/_manifest")).pipe(Effect.option) - if (manifest._tag === "Some") { - Object.assign( - extModules, - (manifest.value as { modules?: Record> }).modules ?? {}, - ) - } else { - const extDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "ext") - const extFiles = fs.existsSync(extDir) - ? fs.readdirSync(extDir).filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts") && f !== "_manifest.ts") - : [] - for (const entry of extFiles) { - const mod = yield* Effect.tryPromise({ - try: () => import(/* @vite-ignore */ pathToFileURL(path.join(extDir, entry)).href), - catch: (err) => log.error("failed to import extension", { name: entry, error: err }), - }).pipe(Effect.option) - if (mod._tag === "Some") extModules[entry.replace(/\.ts$/, "")] = mod.value as Record - } - } - for (const [name, value] of Object.entries(extModules)) { - // Only treat *Plugin-named function exports as plugins. Other modules - // (e.g. a CLI helper export) are not plugins and must not be invoked - // as plugin factories. - const overlay = Object.entries(value).find( - ([exportName, v]) => typeof v === "function" && exportName.endsWith("Plugin"), - )?.[1] as PluginInstance | undefined - if (!overlay) continue - log.info("loading extension", { name }) - const init = yield* Effect.tryPromise({ - try: () => overlay(input), - catch: (err) => log.error("failed to load extension", { name, error: err }), - }).pipe(Effect.option) - if (init._tag === "Some") { - hooks.push(init.value) - hooksWithMeta.push({ - hook: init.value, - pluginName: name, - hookIDFor: (event: string) => `${name}#${event}`, - }) - } - } - - const plugins = Flag.MIMOCODE_PURE ? [] : (cfg.plugin_origins ?? []) - if (Flag.MIMOCODE_PURE && cfg.plugin_origins?.length) { - log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length }) - } - if (plugins.length) yield* config.waitForDependencies() - - const loaded = yield* Effect.promise(() => - PluginLoader.loadExternal({ - items: plugins, - kind: "server", - report: { - start(candidate) { - log.info("loading plugin", { path: candidate.plan.spec }) - }, - missing(candidate, _retry, message) { - log.warn("plugin has no server entrypoint", { path: candidate.plan.spec, message }) - }, - error(candidate, _retry, stage, error, resolved) { - const spec = candidate.plan.spec - const cause = error instanceof Error ? (error.cause ?? error) : error - const message = stage === "load" ? errorMessage(error) : errorMessage(cause) - - if (stage === "install") { - const parsed = parsePluginSpecifier(spec) - log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message }) - publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`) - return - } - - if (stage === "compatibility") { - log.warn("plugin incompatible", { path: spec, error: message }) - publishPluginError(`Plugin ${spec} skipped: ${message}`) - return - } - - if (stage === "entry") { - log.error("failed to resolve plugin server entry", { path: spec, error: message }) - publishPluginError(`Failed to load plugin ${spec}: ${message}`) - return - } - - log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message }) - publishPluginError(`Failed to load plugin ${spec}: ${message}`) - }, - }, - }), - ) - for (const load of loaded) { - if (!load) continue - - // Keep plugin execution sequential so hook registration and execution - // order remains deterministic across plugin runs. - yield* Effect.tryPromise({ - try: () => applyPlugin(load, input, hooks, hooksWithMeta), - catch: (err) => { - const message = errorMessage(err) - log.error("failed to load plugin", { path: load.spec, error: message }) - return message - }, - }).pipe( - Effect.catch(() => { - // TODO: make proper events for this - // bus.publish(Session.Event.Error, { - // error: new NamedError.Unknown({ - // message: `Failed to load plugin ${load.spec}: ${message}`, - // }).toObject(), - // }) - return Effect.void - }), - ) - } - - // Notify plugins of current config - for (const hook of hooks) { - yield* Effect.tryPromise({ - try: () => Promise.resolve((hook as any).config?.(cfg)), - catch: (err) => { - log.error("plugin config hook failed", { error: err }) - }, - }).pipe(Effect.ignore) - } - - // Subscribe to bus events, fiber interrupted when scope closes - yield* bus.subscribeAll().pipe( - Stream.runForEach((input) => - Effect.sync(() => { - for (const hook of hooks) { - void hook["event"]?.({ event: input as any }) - } - }), - ), - Effect.forkScoped, - ) - - return { hooks, hooksWithMeta } - }), - ) - - const fileHookState = yield* InstanceState.make( - Effect.fn("Plugin.fileHooks")(function* () { - const hooks: Hooks[] = [] - const meta: HookEntry[] = [] - const files: Record = {} - yield* config.get() - const dirs = yield* config.directories() - - for (const dir of dirs) { - const matches = Glob.scanSync(FILE_HOOK_GLOB, { cwd: dir, absolute: true, dot: true, symlink: true }) - for (const match of matches) { - const stat = yield* Effect.tryPromise({ - try: () => fs.promises.stat(match), - catch: (err) => err, - }).pipe(Effect.catch(() => Effect.succeed(undefined))) - files[match] = stat?.mtimeMs ?? 0 - // Transpile and load the hook file. We use Bun.build to produce a - // temporary .js artifact, then dynamic-import that artifact. This - // avoids two pitfalls: (1) Bun's import() ignores query-string cache - // busters so re-imports return stale modules, (2) require() transpiles - // .ts in some contexts but not others (CI Linux edge case). - const mod = yield* Effect.tryPromise({ - try: async () => { - const result = await Bun.build({ - entrypoints: [match], - target: "bun", - format: "esm", - }) - if (!result.success) throw new Error(result.logs.map(String).join("\n")) - const blob = result.outputs[0] - const tmpFile = `${match}.${Date.now()}.mjs` - await Bun.write(tmpFile, blob) - try { - return await import(tmpFile) as Record - } finally { - fs.promises.unlink(tmpFile).catch(() => {}) - } - }, - catch: (err) => err, - }).pipe(Effect.catch((err) => { - log.error("failed to load file hook", { path: match, error: errorMessage(err) }) - return Effect.succeed(undefined) - })) - if (!mod) continue - const hookObj: Hooks = (mod.default ?? mod) as Hooks - if (hookObj && typeof hookObj === "object") { - const name = path.basename(match, path.extname(match)) - hooks.push(hookObj) - meta.push({ hook: hookObj, pluginName: `file:${name}`, hookIDFor: (event: string) => `file:${name}#${event}` }) - log.info("loaded file hook", { path: match, name }) - } - } - } - - // Dispatch bus events to file hooks' `event` handlers. Scoped to this - // cache entry: invalidation interrupts the fiber, and the rebuild - // re-subscribes with the fresh hook set. - if (hooks.some((hook) => typeof hook.event === "function")) { - yield* bus.subscribeAll().pipe( - Stream.runForEach((input) => - Effect.sync(() => { - for (const entry of meta) { - const fn = entry.hook.event - if (!fn) continue - try { - void Promise.resolve(fn({ event: input as any })).catch((err) => { - log.error("file hook event handler failed", { hook: entry.pluginName, error: errorMessage(err) }) - }) - } catch (err) { - log.error("file hook event handler failed", { hook: entry.pluginName, error: errorMessage(err) }) - } - } - }), - ), - Effect.forkScoped, - ) - } - - return { hooks, meta, dirs, files, lastCheck: { value: Date.now() } } - }), - ) - - // Staleness check: re-stat known hook files and re-glob hook dirs. Any - // mtime change, added, or removed file invalidates the cache so the next - // InstanceState.get rebuilds it. Covers ALL writers (editors, git, other - // processes) — not just this process's write/edit tools. Throttled to - // avoid stat storms on hot trigger paths. - const freshFileHooks = Effect.gen(function* () { - const fh = yield* InstanceState.get(fileHookState) - const now = Date.now() - if (now - fh.lastCheck.value < FILE_HOOK_CHECK_INTERVAL_MS) return fh - fh.lastCheck.value = now - - const stale = yield* Effect.promise(async () => { - const known = Object.keys(fh.files) - const seen = new Set() - for (const dir of fh.dirs) { - for (const match of Glob.scanSync(FILE_HOOK_GLOB, { cwd: dir, absolute: true, dot: true, symlink: true })) { - seen.add(match) - if (!(match in fh.files)) return true - } - } - for (const file of known) { - if (!seen.has(file)) return true - const stat = await fs.promises.stat(file).catch(() => undefined) - if ((stat?.mtimeMs ?? 0) !== fh.files[file]) return true - } - return false - }) - - if (!stale) return fh - log.info("file hooks changed on disk, reloading") - yield* InstanceState.invalidate(fileHookState) - return yield* InstanceState.get(fileHookState) - }) - - const aggregateDecision = ( - input: ActorPreStopInput | ActorPostStopInput, - eventName: "actor.preStop" | "actor.postStop", - ) => - Effect.gen(function* () { - const s = yield* InstanceState.get(state) - const fh = yield* freshFileHooks - const reasons: string[] = [] - const pluginNames: string[] = [] - const hookIDs: string[] = [] - let anyContinue = false - - for (const entry of [...s.hooksWithMeta, ...fh.meta]) { - const reg = entry.hook[eventName] - if (!reg) continue - - const fn = typeof reg === "function" ? reg : reg.run - const matcher: ActorMatcher | undefined = - typeof reg === "function" ? undefined : reg.matcher - - if (!matchesActor(matcher, input)) { - yield* bus.publish(HookEvent.Executed, { - event: eventName, - hookID: entry.hookIDFor(eventName), - pluginName: entry.pluginName, - actorID: input.actorID, - agentType: input.agentType, - durationMs: 0, - outcome: "skipped", - continueRequested: false, - reasonLength: 0, - }) - continue - } - - const startedAt = Date.now() - const o: ActorStopOutput = { continue: false } - let hookOutcome: "success" | "error" = "success" - // TODO: pass an AbortSignal to fn so plugin authors can wire cooperative - // cancellation into their fetch / DB calls. Effect interrupt only stops - // the awaiting fiber — the underlying Promise keeps running and may - // bus.publish events after the actor has been cleaned up. See spec - // Future work for full discussion. Strict in-process cancellation - // (子进程隔离) is out of scope; AbortSignal is the in-process ceiling. - yield* Effect.tryPromise({ - try: () => fn(input as never, o), - catch: (err) => err, - }).pipe( - Effect.tapError((err) => - Effect.gen(function* () { - hookOutcome = "error" - log.error(`${eventName} hook failed`, { pluginName: entry.pluginName, hookID: entry.hookIDFor(eventName), error: err }) - yield* bus.publish(Session.Event.Error, { - sessionID: input.sessionID as SessionID, - error: new NamedError.Unknown({ - message: `${eventName} hook (${entry.pluginName}) failed: ${errorMessage(err)}`, - }).toObject(), - }) - }), - ), - Effect.ignore, - ) - - const durationMs = Date.now() - startedAt - yield* bus.publish(HookEvent.Executed, { - event: eventName, - hookID: entry.hookIDFor(eventName), - pluginName: entry.pluginName, - actorID: input.actorID, - agentType: input.agentType, - durationMs, - outcome: hookOutcome, - continueRequested: o.continue === true, - reasonLength: o.reason?.length ?? 0, - }) - - if (o.continue === true && o.reason && o.reason.length > 0) { - anyContinue = true - reasons.push(o.reason) - pluginNames.push(entry.pluginName) - hookIDs.push(entry.hookIDFor(eventName)) - } else if (o.continue === true) { - log.warn(`${eventName} hook returned continue=true without reason; ignored`, { - pluginName: entry.pluginName, - }) - } - } - - const aggregated: ActorStopAggregatedDecision = { - continue: anyContinue, - reason: reasons.length > 0 ? reasons.join("\n\n") : undefined, - contributingPluginNames: pluginNames, - contributingHookIDs: hookIDs, - } - return aggregated - }) - - const triggerActorPreStop = Effect.fn("Plugin.triggerActorPreStop")(function* ( - input: ActorPreStopInput, - ) { - return yield* aggregateDecision(input, "actor.preStop") - }) - - const triggerActorPostStop = Effect.fn("Plugin.triggerActorPostStop")(function* ( - input: ActorPostStopInput, - ) { - return yield* aggregateDecision(input, "actor.postStop") - }) - - const HOOK_TIMEOUT_MS = 5000 - const CIRCUIT_BREAKER_THRESHOLD = 3 - const hookFailures = new Map() - - const trigger = Effect.fn("Plugin.trigger")(function* < - Name extends TriggerName, - Input = Parameters[Name]>[0], - Output = Parameters[Name]>[1], - >(name: Name, input: Input, output: Output) { - if (!name) return output - const s = yield* InstanceState.get(state) - const fh = yield* freshFileHooks - - for (const entry of s.hooksWithMeta) { - const fn = entry.hook[name] as any - if (!fn) continue - yield* Effect.promise(async () => fn(input, output)) - } - - for (const entry of fh.meta) { - const fn = entry.hook[name] as any - if (!fn) continue - const hookID = entry.hookIDFor(name) - - if ((hookFailures.get(hookID) ?? 0) >= CIRCUIT_BREAKER_THRESHOLD) { - log.warn("hook circuit-breaker open, skipping", { hook: hookID }) - continue - } - - const snapshot = structuredClone(output) - const failed = yield* Effect.tryPromise({ - try: async () => { - await Promise.race([ - Promise.resolve(fn(input, output)), - new Promise((_, reject) => - setTimeout(() => reject(new Error(`hook timed out after ${HOOK_TIMEOUT_MS}ms`)), HOOK_TIMEOUT_MS), - ), - ]) - }, - catch: (err) => err, - }).pipe( - Effect.map(() => false), - Effect.catch((err) => { - Object.assign(output as any, snapshot) - const count = (hookFailures.get(hookID) ?? 0) + 1 - hookFailures.set(hookID, count) - log.error("file hook failed, output rolled back", { - hook: hookID, - event: name, - error: errorMessage(err), - consecutiveFailures: count, - circuitOpen: count >= CIRCUIT_BREAKER_THRESHOLD, - }) - return Effect.succeed(true) - }), - ) - if (!failed) hookFailures.delete(hookID) - } - return output - }) - - const list = Effect.fn("Plugin.list")(function* () { - const s = yield* InstanceState.get(state) - return s.hooks - }) - - const init = Effect.fn("Plugin.init")(function* () { - yield* InstanceState.get(state) - yield* InstanceState.get(fileHookState) - }) - - const reloadFileHooks: Interface["reloadFileHooks"] = Effect.fn("Plugin.reloadFileHooks")(function* () { - yield* InstanceState.invalidate(fileHookState) - }) - - return Service.of({ trigger, list, init, reloadFileHooks, triggerActorPreStop, triggerActorPostStop }) - }), -) - -export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer)) - -export * as Plugin from "." +import type { + Hooks, + PluginInput, + Plugin as PluginInstance, + PluginModule, + WorkspaceAdaptor as PluginWorkspaceAdaptor, + ActorPreStopInput, + ActorPostStopInput, + ActorStopOutput, + ActorMatcher, +} from "@mimo-ai/plugin" +import { z } from "zod" +import { matchesActor } from "./matcher" +import { Config } from "../config" +import { Bus } from "../bus" +import { BusEvent } from "../bus/bus-event" +import { Log } from "../util" +import { createOpencodeClient } from "@mimo-ai/sdk" +import { Flag } from "../flag/flag" +import { CodexAuthPlugin } from "./codex" +import { XaiAuthPlugin } from "./xai" +import { MimoAuthPlugin, AnthropicProxyPlugin } from "./mimo" +import { Session } from "../session" +import type { SessionID } from "../session/schema" +import { NamedError } from "@mimo-ai/shared/util/error" +import { CopilotAuthPlugin } from "./github-copilot/copilot" +import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" +import { PoeAuthPlugin } from "opencode-poe-auth" +import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" +import { CheckpointSplitoverPlugin } from "./checkpoint-splitover" +import { SubagentProgressCheckerPlugin } from "./subagent-progress-checker" +import { Effect, Layer, Context, Stream } from "effect" +import { EffectBridge } from "@/effect" +import { InstanceState } from "@/effect" +import { errorMessage } from "@/util/error" +import { PluginLoader } from "./loader" +import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" +import { registerAdaptor } from "@/control-plane/adaptors" +import type { WorkspaceAdaptor } from "@/control-plane/types" +import { Glob } from "@mimo-ai/shared/util/glob" +import fs from "fs" +import path from "path" +import { pathToFileURL, fileURLToPath } from "url" + +const log = Log.create({ service: "plugin" }) + +export const HookEvent = { + Executed: BusEvent.define( + "hook.executed", + z.object({ + event: z.enum(["actor.preStop", "actor.postStop"]), + hookID: z.string(), + pluginName: z.string(), + actorID: z.string(), + agentType: z.string(), + durationMs: z.number(), + outcome: z.enum(["success", "error", "skipped"]), + continueRequested: z.boolean(), + reasonLength: z.number(), + }), + ), + ReActReentered: BusEvent.define( + "hook.react.reentered", + z.object({ + phase: z.enum(["pre", "post"]), + actorID: z.string(), + agentType: z.string(), + iteration: z.number(), + triggeredByPlugins: z.array(z.string()), + reasonPreview: z.string(), + }), + ), + ReActMaxReached: BusEvent.define( + "hook.react.max_reached", + z.object({ + phase: z.enum(["pre", "post"]), + actorID: z.string(), + agentType: z.string(), + }), + ), +} as const + +type HookEntry = { + hook: Hooks + pluginName: string + /** Stable per-event hook ID: `${pluginName}#${eventName}` */ + hookIDFor: (eventName: string) => string +} + +type State = { + hooks: Hooks[] + hooksWithMeta: HookEntry[] +} + +type FileHookState = { + hooks: Hooks[] + meta: HookEntry[] + dirs: string[] + /** Absolute path -> mtimeMs at load time, for cheap staleness checks. */ + files: Record + /** Mutable box: last staleness check timestamp (throttle). */ + lastCheck: { value: number } +} + +const FILE_HOOK_GLOB = "{hook,hooks}/*.{js,ts}" +const FILE_HOOK_CHECK_INTERVAL_MS = 500 + +export type ActorStopAggregatedDecision = ActorStopOutput & { + contributingPluginNames: string[] + contributingHookIDs: string[] +} + +// Hook names that follow the (input, output) => Promise trigger pattern +type TriggerName = { + [K in keyof Hooks]-?: NonNullable extends (input: any, output: any) => Promise ? K : never +}[keyof Hooks] + +export interface Interface { + readonly trigger: < + Name extends TriggerName, + Input = Parameters[Name]>[0], + Output = Parameters[Name]>[1], + >( + name: Name, + input: Input, + output: Output, + ) => Effect.Effect + readonly list: () => Effect.Effect + readonly init: () => Effect.Effect + readonly reloadFileHooks: () => Effect.Effect + readonly triggerActorPreStop: ( + input: ActorPreStopInput, + ) => Effect.Effect + readonly triggerActorPostStop: ( + input: ActorPostStopInput, + ) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Plugin") {} + +// Built-in plugins that are directly imported (not installed from npm) +const INTERNAL_PLUGINS: PluginInstance[] = [ + MimoAuthPlugin, + AnthropicProxyPlugin, + CodexAuthPlugin, + XaiAuthPlugin, + CopilotAuthPlugin, + // gitlab/poe auth are external npm packages typed against the published + // upstream plugin package, which carries a duplicate (nominal) copy of the + // SDK client; cast through unknown to the workspace Plugin type. + GitlabAuthPlugin as unknown as PluginInstance, + PoeAuthPlugin as unknown as PluginInstance, + CloudflareWorkersAuthPlugin, + CloudflareAIGatewayAuthPlugin, + CheckpointSplitoverPlugin, + SubagentProgressCheckerPlugin, +] + +function isServerPlugin(value: unknown): value is PluginInstance { + return typeof value === "function" +} + +function getServerPlugin(value: unknown) { + if (isServerPlugin(value)) return value + if (!value || typeof value !== "object" || !("server" in value)) return + if (!isServerPlugin(value.server)) return + return value.server +} + +function getLegacyPlugins(mod: Record) { + const seen = new Set() + const result: PluginInstance[] = [] + + for (const entry of Object.values(mod)) { + if (seen.has(entry)) continue + seen.add(entry) + const plugin = getServerPlugin(entry) + if (!plugin) continue + result.push(plugin) + } + + return result +} + +async function applyPlugin( + load: PluginLoader.Loaded, + input: PluginInput, + hooks: Hooks[], + hooksWithMeta: HookEntry[], +) { + const plugin = readV1Plugin(load.mod, load.spec, "server", "detect") + if (plugin) { + await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg) + const pluginName = readPluginId(plugin.id, load.spec) ?? load.pkg?.pkg ?? load.spec + const hookObj = await (plugin as PluginModule).server(input, load.options) + hooks.push(hookObj) + hooksWithMeta.push({ + hook: hookObj, + pluginName, + hookIDFor: (event: string) => `${pluginName}#${event}`, + }) + return + } + + for (const server of getLegacyPlugins(load.mod)) { + const fnName = (server as { name?: string }).name + const pluginName = fnName && fnName !== "default" && fnName !== "" + ? fnName + : (load.pkg?.pkg ?? load.spec) + const hookObj = await server(input, load.options) + hooks.push(hookObj) + hooksWithMeta.push({ + hook: hookObj, + pluginName, + hookIDFor: (event: string) => `${pluginName}#${event}`, + }) + } +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const bus = yield* Bus.Service + const config = yield* Config.Service + + const state = yield* InstanceState.make( + Effect.fn("Plugin.state")(function* (ctx) { + const hooks: Hooks[] = [] + const hooksWithMeta: HookEntry[] = [] + const bridge = yield* EffectBridge.make() + + function publishPluginError(message: string) { + bridge.fork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })) + } + + const { Server } = yield* Effect.promise(() => import("../server/server")) + + const client = createOpencodeClient({ + baseUrl: "http://localhost:4096", + directory: ctx.directory, + headers: Flag.MIMOCODE_SERVER_PASSWORD + ? { + Authorization: `Basic ${Buffer.from(`${Flag.MIMOCODE_SERVER_USERNAME ?? "mimocode"}:${Flag.MIMOCODE_SERVER_PASSWORD}`).toString("base64")}`, + } + : undefined, + fetch: async (...args) => (await Server.Default()).app.fetch(...args), + }) + const cfg = yield* config.get() + const input: PluginInput = { + client, + project: ctx.project, + worktree: ctx.worktree, + directory: ctx.directory, + experimental_workspace: { + register(type: string, adaptor: PluginWorkspaceAdaptor) { + registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor) + }, + }, + get serverUrl(): URL { + return Server.url ?? new URL("http://localhost:4096") + }, + // @ts-expect-error + $: typeof Bun === "undefined" ? undefined : Bun.$, + } + + for (const plugin of INTERNAL_PLUGINS) { + log.info("loading internal plugin", { name: plugin.name }) + const init = yield* Effect.tryPromise({ + try: () => plugin(input), + catch: (err) => { + log.error("failed to load internal plugin", { name: plugin.name, error: err }) + }, + }).pipe(Effect.option) + if (init._tag === "Some") { + hooks.push(init.value) + hooksWithMeta.push({ + hook: init.value, + pluginName: plugin.name, + hookIDFor: (event: string) => `${plugin.name}#${event}`, + }) + } + } + + // Load optional local extensions under src/ext/. Prefers the generated + // _manifest.ts (a fixed import specifier resolves inside Bun single-file + // executables, where filesystem scans do not); falls back to a directory + // scan for unbundled runs. Each *Plugin-named export is registered. + const extModules: Record> = {} + // @ts-ignore generated manifest; may not exist at type-check time + const manifest = yield* Effect.tryPromise(() => import("../ext/_manifest")).pipe(Effect.option) + if (manifest._tag === "Some") { + Object.assign( + extModules, + (manifest.value as { modules?: Record> }).modules ?? {}, + ) + } else { + const extDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "ext") + const extFiles = fs.existsSync(extDir) + ? fs.readdirSync(extDir).filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts") && f !== "_manifest.ts") + : [] + for (const entry of extFiles) { + const mod = yield* Effect.tryPromise({ + try: () => import(/* @vite-ignore */ pathToFileURL(path.join(extDir, entry)).href), + catch: (err) => log.error("failed to import extension", { name: entry, error: err }), + }).pipe(Effect.option) + if (mod._tag === "Some") extModules[entry.replace(/\.ts$/, "")] = mod.value as Record + } + } + for (const [name, value] of Object.entries(extModules)) { + // Only treat *Plugin-named function exports as plugins. Other modules + // (e.g. a CLI helper export) are not plugins and must not be invoked + // as plugin factories. + const overlay = Object.entries(value).find( + ([exportName, v]) => typeof v === "function" && exportName.endsWith("Plugin"), + )?.[1] as PluginInstance | undefined + if (!overlay) continue + log.info("loading extension", { name }) + const init = yield* Effect.tryPromise({ + try: () => overlay(input), + catch: (err) => log.error("failed to load extension", { name, error: err }), + }).pipe(Effect.option) + if (init._tag === "Some") { + hooks.push(init.value) + hooksWithMeta.push({ + hook: init.value, + pluginName: name, + hookIDFor: (event: string) => `${name}#${event}`, + }) + } + } + + const plugins = Flag.MIMOCODE_PURE ? [] : (cfg.plugin_origins ?? []) + if (Flag.MIMOCODE_PURE && cfg.plugin_origins?.length) { + log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length }) + } + if (plugins.length) yield* config.waitForDependencies() + + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: plugins, + kind: "server", + report: { + start(candidate) { + log.info("loading plugin", { path: candidate.plan.spec }) + }, + missing(candidate, _retry, message) { + log.warn("plugin has no server entrypoint", { path: candidate.plan.spec, message }) + }, + error(candidate, _retry, stage, error, resolved) { + const spec = candidate.plan.spec + const cause = error instanceof Error ? (error.cause ?? error) : error + const message = stage === "load" ? errorMessage(error) : errorMessage(cause) + + if (stage === "install") { + const parsed = parsePluginSpecifier(spec) + log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message }) + publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`) + return + } + + if (stage === "compatibility") { + log.warn("plugin incompatible", { path: spec, error: message }) + publishPluginError(`Plugin ${spec} skipped: ${message}`) + return + } + + if (stage === "entry") { + log.error("failed to resolve plugin server entry", { path: spec, error: message }) + publishPluginError(`Failed to load plugin ${spec}: ${message}`) + return + } + + log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message }) + publishPluginError(`Failed to load plugin ${spec}: ${message}`) + }, + }, + }), + ) + for (const load of loaded) { + if (!load) continue + + // Keep plugin execution sequential so hook registration and execution + // order remains deterministic across plugin runs. + yield* Effect.tryPromise({ + try: () => applyPlugin(load, input, hooks, hooksWithMeta), + catch: (err) => { + const message = errorMessage(err) + log.error("failed to load plugin", { path: load.spec, error: message }) + return message + }, + }).pipe( + Effect.catch(() => { + // TODO: make proper events for this + // bus.publish(Session.Event.Error, { + // error: new NamedError.Unknown({ + // message: `Failed to load plugin ${load.spec}: ${message}`, + // }).toObject(), + // }) + return Effect.void + }), + ) + } + + // Notify plugins of current config + for (const hook of hooks) { + yield* Effect.tryPromise({ + try: () => Promise.resolve((hook as any).config?.(cfg)), + catch: (err) => { + log.error("plugin config hook failed", { error: err }) + }, + }).pipe(Effect.ignore) + } + + // Subscribe to bus events, fiber interrupted when scope closes + yield* bus.subscribeAll().pipe( + Stream.runForEach((input) => + Effect.sync(() => { + for (const hook of hooks) { + void hook["event"]?.({ event: input as any }) + } + }), + ), + Effect.forkScoped, + ) + + return { hooks, hooksWithMeta } + }), + ) + + const fileHookState = yield* InstanceState.make( + Effect.fn("Plugin.fileHooks")(function* () { + const hooks: Hooks[] = [] + const meta: HookEntry[] = [] + const files: Record = {} + yield* config.get() + const dirs = yield* config.directories() + + for (const dir of dirs) { + const matches = Glob.scanSync(FILE_HOOK_GLOB, { cwd: dir, absolute: true, dot: true, symlink: true }) + for (const match of matches) { + const stat = yield* Effect.tryPromise({ + try: () => fs.promises.stat(match), + catch: (err) => err, + }).pipe(Effect.catch(() => Effect.succeed(undefined))) + files[match] = stat?.mtimeMs ?? 0 + // Transpile and load the hook file. We use Bun.build to produce a + // temporary .js artifact, then dynamic-import that artifact. This + // avoids two pitfalls: (1) Bun's import() ignores query-string cache + // busters so re-imports return stale modules, (2) require() transpiles + // .ts in some contexts but not others (CI Linux edge case). + const mod = yield* Effect.tryPromise({ + try: async () => { + const result = await Bun.build({ + entrypoints: [match], + target: "bun", + format: "esm", + }) + if (!result.success) throw new Error(result.logs.map(String).join("\n")) + const blob = result.outputs[0] + const tmpFile = `${match}.${Date.now()}.mjs` + await Bun.write(tmpFile, blob) + try { + return await import(tmpFile) as Record + } finally { + fs.promises.unlink(tmpFile).catch(() => {}) + } + }, + catch: (err) => err, + }).pipe(Effect.catch((err) => { + log.error("failed to load file hook", { path: match, error: errorMessage(err) }) + return Effect.succeed(undefined) + })) + if (!mod) continue + const hookObj: Hooks = (mod.default ?? mod) as Hooks + if (hookObj && typeof hookObj === "object") { + const name = path.basename(match, path.extname(match)) + hooks.push(hookObj) + meta.push({ hook: hookObj, pluginName: `file:${name}`, hookIDFor: (event: string) => `file:${name}#${event}` }) + log.info("loaded file hook", { path: match, name }) + } + } + } + + // Dispatch bus events to file hooks' `event` handlers. Scoped to this + // cache entry: invalidation interrupts the fiber, and the rebuild + // re-subscribes with the fresh hook set. + if (hooks.some((hook) => typeof hook.event === "function")) { + yield* bus.subscribeAll().pipe( + Stream.runForEach((input) => + Effect.sync(() => { + for (const entry of meta) { + const fn = entry.hook.event + if (!fn) continue + try { + void Promise.resolve(fn({ event: input as any })).catch((err) => { + log.error("file hook event handler failed", { hook: entry.pluginName, error: errorMessage(err) }) + }) + } catch (err) { + log.error("file hook event handler failed", { hook: entry.pluginName, error: errorMessage(err) }) + } + } + }), + ), + Effect.forkScoped, + ) + } + + return { hooks, meta, dirs, files, lastCheck: { value: Date.now() } } + }), + ) + + // Staleness check: re-stat known hook files and re-glob hook dirs. Any + // mtime change, added, or removed file invalidates the cache so the next + // InstanceState.get rebuilds it. Covers ALL writers (editors, git, other + // processes) — not just this process's write/edit tools. Throttled to + // avoid stat storms on hot trigger paths. + const freshFileHooks = Effect.gen(function* () { + const fh = yield* InstanceState.get(fileHookState) + const now = Date.now() + if (now - fh.lastCheck.value < FILE_HOOK_CHECK_INTERVAL_MS) return fh + fh.lastCheck.value = now + + const stale = yield* Effect.promise(async () => { + const known = Object.keys(fh.files) + const seen = new Set() + for (const dir of fh.dirs) { + for (const match of Glob.scanSync(FILE_HOOK_GLOB, { cwd: dir, absolute: true, dot: true, symlink: true })) { + seen.add(match) + if (!(match in fh.files)) return true + } + } + for (const file of known) { + if (!seen.has(file)) return true + const stat = await fs.promises.stat(file).catch(() => undefined) + if ((stat?.mtimeMs ?? 0) !== fh.files[file]) return true + } + return false + }) + + if (!stale) return fh + log.info("file hooks changed on disk, reloading") + yield* InstanceState.invalidate(fileHookState) + return yield* InstanceState.get(fileHookState) + }) + + const aggregateDecision = ( + input: ActorPreStopInput | ActorPostStopInput, + eventName: "actor.preStop" | "actor.postStop", + ) => + Effect.gen(function* () { + const s = yield* InstanceState.get(state) + const fh = yield* freshFileHooks + const reasons: string[] = [] + const pluginNames: string[] = [] + const hookIDs: string[] = [] + let anyContinue = false + + for (const entry of [...s.hooksWithMeta, ...fh.meta]) { + const reg = entry.hook[eventName] + if (!reg) continue + + const fn = typeof reg === "function" ? reg : reg.run + const matcher: ActorMatcher | undefined = + typeof reg === "function" ? undefined : reg.matcher + + if (!matchesActor(matcher, input)) { + yield* bus.publish(HookEvent.Executed, { + event: eventName, + hookID: entry.hookIDFor(eventName), + pluginName: entry.pluginName, + actorID: input.actorID, + agentType: input.agentType, + durationMs: 0, + outcome: "skipped", + continueRequested: false, + reasonLength: 0, + }) + continue + } + + const startedAt = Date.now() + const o: ActorStopOutput = { continue: false } + let hookOutcome: "success" | "error" = "success" + // TODO: pass an AbortSignal to fn so plugin authors can wire cooperative + // cancellation into their fetch / DB calls. Effect interrupt only stops + // the awaiting fiber — the underlying Promise keeps running and may + // bus.publish events after the actor has been cleaned up. See spec + // Future work for full discussion. Strict in-process cancellation + // (子进程隔离) is out of scope; AbortSignal is the in-process ceiling. + yield* Effect.tryPromise({ + try: () => fn(input as never, o), + catch: (err) => err, + }).pipe( + Effect.tapError((err) => + Effect.gen(function* () { + hookOutcome = "error" + log.error(`${eventName} hook failed`, { pluginName: entry.pluginName, hookID: entry.hookIDFor(eventName), error: err }) + yield* bus.publish(Session.Event.Error, { + sessionID: input.sessionID as SessionID, + error: new NamedError.Unknown({ + message: `${eventName} hook (${entry.pluginName}) failed: ${errorMessage(err)}`, + }).toObject(), + }) + }), + ), + Effect.ignore, + ) + + const durationMs = Date.now() - startedAt + yield* bus.publish(HookEvent.Executed, { + event: eventName, + hookID: entry.hookIDFor(eventName), + pluginName: entry.pluginName, + actorID: input.actorID, + agentType: input.agentType, + durationMs, + outcome: hookOutcome, + continueRequested: o.continue === true, + reasonLength: o.reason?.length ?? 0, + }) + + if (o.continue === true && o.reason && o.reason.length > 0) { + anyContinue = true + reasons.push(o.reason) + pluginNames.push(entry.pluginName) + hookIDs.push(entry.hookIDFor(eventName)) + } else if (o.continue === true) { + log.warn(`${eventName} hook returned continue=true without reason; ignored`, { + pluginName: entry.pluginName, + }) + } + } + + const aggregated: ActorStopAggregatedDecision = { + continue: anyContinue, + reason: reasons.length > 0 ? reasons.join("\n\n") : undefined, + contributingPluginNames: pluginNames, + contributingHookIDs: hookIDs, + } + return aggregated + }) + + const triggerActorPreStop = Effect.fn("Plugin.triggerActorPreStop")(function* ( + input: ActorPreStopInput, + ) { + return yield* aggregateDecision(input, "actor.preStop") + }) + + const triggerActorPostStop = Effect.fn("Plugin.triggerActorPostStop")(function* ( + input: ActorPostStopInput, + ) { + return yield* aggregateDecision(input, "actor.postStop") + }) + + const HOOK_TIMEOUT_MS = 5000 + const CIRCUIT_BREAKER_THRESHOLD = 3 + const hookFailures = new Map() + + const trigger = Effect.fn("Plugin.trigger")(function* < + Name extends TriggerName, + Input = Parameters[Name]>[0], + Output = Parameters[Name]>[1], + >(name: Name, input: Input, output: Output) { + if (!name) return output + const s = yield* InstanceState.get(state) + const fh = yield* freshFileHooks + + for (const entry of s.hooksWithMeta) { + const fn = entry.hook[name] as any + if (!fn) continue + yield* Effect.promise(async () => fn(input, output)) + } + + for (const entry of fh.meta) { + const fn = entry.hook[name] as any + if (!fn) continue + const hookID = entry.hookIDFor(name) + + if ((hookFailures.get(hookID) ?? 0) >= CIRCUIT_BREAKER_THRESHOLD) { + log.warn("hook circuit-breaker open, skipping", { hook: hookID }) + continue + } + + const snapshot = structuredClone(output) + const failed = yield* Effect.tryPromise({ + try: async () => { + await Promise.race([ + Promise.resolve(fn(input, output)), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`hook timed out after ${HOOK_TIMEOUT_MS}ms`)), HOOK_TIMEOUT_MS), + ), + ]) + }, + catch: (err) => err, + }).pipe( + Effect.map(() => false), + Effect.catch((err) => { + Object.assign(output as any, snapshot) + const count = (hookFailures.get(hookID) ?? 0) + 1 + hookFailures.set(hookID, count) + log.error("file hook failed, output rolled back", { + hook: hookID, + event: name, + error: errorMessage(err), + consecutiveFailures: count, + circuitOpen: count >= CIRCUIT_BREAKER_THRESHOLD, + }) + return Effect.succeed(true) + }), + ) + if (!failed) hookFailures.delete(hookID) + } + return output + }) + + const list = Effect.fn("Plugin.list")(function* () { + const s = yield* InstanceState.get(state) + return s.hooks + }) + + const init = Effect.fn("Plugin.init")(function* () { + yield* InstanceState.get(state) + yield* InstanceState.get(fileHookState) + }) + + const reloadFileHooks: Interface["reloadFileHooks"] = Effect.fn("Plugin.reloadFileHooks")(function* () { + yield* InstanceState.invalidate(fileHookState) + }) + + return Service.of({ trigger, list, init, reloadFileHooks, triggerActorPreStop, triggerActorPostStop }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer)) + +export * as Plugin from "." From 4fad57bbb612d495c7518ca677ea4cec9828d93e Mon Sep 17 00:00:00 2001 From: wqymi Date: Mon, 27 Jul 2026 21:19:18 +0800 Subject: [PATCH 011/135] fix(actor): make spawn the default and run the exception in the tool prompt Agents kept reaching for `actor run`, which blocks the whole conversation until the subagent finishes, so parallelism was lost on ordinary delegation. The cause was the prompt itself: `run` was listed first as the straightforward path and nearly every example used it. Reorder and rewrite the actor tool description so `spawn` is presented first and as THE DEFAULT (background, returns actor_id immediately, subagents run in parallel), and `run` is a narrow exception gated on a crisp test: only a tiny, fast lookup whose result you cannot make your next decision without in this turn. Examples flip to spawn (including a 3-way parallel fan-out) with one labelled run exception, and a collection section documents notifications plus wait/status. Behavior is unchanged - description/prompt text, action describe() strings and schema ordering only. Also corrects orchestrator.txt, which described `actor spawn` as blocking; both remain forbidden there for real work. --- .../src/session/prompt/orchestrator.txt | 2 +- packages/opencode/src/tool/actor.shell.txt | 51 +++++++---- packages/opencode/src/tool/actor.ts | 16 +++- packages/opencode/src/tool/actor.txt | 88 ++++++++++++++----- .../tool/actor-prompt-spawn-first.test.ts | 65 ++++++++++++++ 5 files changed, 175 insertions(+), 47 deletions(-) create mode 100644 packages/opencode/test/tool/actor-prompt-spawn-first.test.ts diff --git a/packages/opencode/src/session/prompt/orchestrator.txt b/packages/opencode/src/session/prompt/orchestrator.txt index 77b8e3e46..bc7ace606 100644 --- a/packages/opencode/src/session/prompt/orchestrator.txt +++ b/packages/opencode/src/session/prompt/orchestrator.txt @@ -62,7 +62,7 @@ It exposes several operations (the actual call syntax — JSON or shell — is w There are two different tools, and confusing them breaks you. The `session` tool creates and manages BACKGROUND child sessions — that is how ALL substantive work happens. The `actor` tool is a different thing, and most of its actions are traps for you: -- `actor run` / `actor spawn` start a BLOCKING subagent that runs INSIDE your own session and OCCUPIES your current turn until it finishes. NEVER use them to do real work — no writing code, editing files, running builds, planning an implementation, reviewing a diff, and never as a way to "wait for a child" to finish. Real work of every kind goes to a `session create` background child instead. +- `actor run` starts a BLOCKING subagent that runs INSIDE your own session and OCCUPIES your current turn until it finishes; `actor spawn` is non-blocking but still runs the subagent inside your own session. NEVER use either to do real work — no writing code, editing files, running builds, planning an implementation, reviewing a diff, and never as a way to "wait for a child" to finish. Real work of every kind goes to a `session create` background child instead. - Relaying and status are jobs of the `session` tool, not `actor`: use `session send` to relay a task into / nudge a child (reliable even for an idle or never-run child), and `session status` to peek at a child's derived liveness. Both return immediately. Core discipline: you MUST NEVER block your turn on any tool action. Your entire job is managing many sessions concurrently, so anything that waits synchronously for a child to finish — a blocking subagent, a "wait" action, a poll loop — defeats your purpose. `session create` is already non-blocking (correct); keep every action you take non-blocking too. diff --git a/packages/opencode/src/tool/actor.shell.txt b/packages/opencode/src/tool/actor.shell.txt index b170238d3..19f141c45 100644 --- a/packages/opencode/src/tool/actor.shell.txt +++ b/packages/opencode/src/tool/actor.shell.txt @@ -1,5 +1,9 @@ Launch a subagent for complex multi-step tasks autonomously. +`actor spawn` is THE DEFAULT: it returns an actor_id immediately, so subagents run in +PARALLEL in the background and you keep responding to the user. `actor run` BLOCKS the +whole conversation until the subagent finishes and is a rare exception. + Available subagent types are listed below this description (the registry appends them at request time per-agent). @@ -11,16 +15,17 @@ appends them at request time per-agent). # - # starts a line comment to end-of-line (quoted # is literal text) # - $vars are preserved as literal text (no expansion) -# block until done, return the subagent's final message inline: - actor run "" "" [--model ] [--actor ] [--timeout ] [--command ] [--context none|state|full] [--output-schema ''] - # bind it to one of your `task` tool tasks (TID from the `task` tool, e.g. T4): - actor run "" "" --task - -# return actor_id immediately, run independently: +# DEFAULT — return actor_id immediately, run in the background, stay responsive: actor spawn "" "" [--model ] [--actor ] [--command ] [--context none|state|full] [--output-schema ''] # bind it to one of your `task` tool tasks (TID from the `task` tool, e.g. T4): actor spawn "" "" --task +# EXCEPTION — block the whole conversation until done, return the final message inline. +# Only for a tiny, fast lookup whose answer gates your very next decision this turn: + actor run "" "" [--model ] [--actor ] [--timeout ] [--command ] [--context none|state|full] [--output-schema ''] + # bind it to one of your `task` tool tasks (TID from the `task` tool, e.g. T4): + actor run "" "" --task + # block on a previously-spawned actor's result: actor wait [--timeout ] @@ -32,16 +37,22 @@ appends them at request time per-agent). actor send "" [--session ] [--type ] # to_actor_id: 'main' for a session's main agent, or a subagent id like 'explore-1' # --session: target session id (defaults to current); --type: 'text' (default) or 'actor_notification' - # (--task is NOT valid here — it applies only to run/spawn for tying a subagent to a task_id) + # (--task is NOT valid here — it applies only to spawn/run for tying a subagent to a task_id) # list available models (optionally vision-only) to pick a --model value: actor models [--vision] [--limit ] Examples: - actor run explore "Find error recovery" "Scan src/parser.ts for catch blocks. Return file:line." + actor spawn explore "Find error recovery" "Scan src/parser.ts for catch blocks. Return file:line." + +# parallel investigations — the normal shape. Spawn them all, then let notifications +# deliver, or `actor wait ` / `actor status ` when you must synchronize: + actor spawn explore "Q1" "search for catch blocks" + actor spawn explore "Q2" "search for type narrowing" + actor spawn explore "Q3" "search for test fixtures" # pick a model/tier for this subagent (group name like ultra/standard/lite, or a literal provider/model): - actor run explore "Quick scan" "find catch blocks" --model lite + actor spawn explore "Quick scan" "find catch blocks" --model lite # tie this subagent to a task — pass a TID the `task` tool returned this session # (e.g. T1). On completion the postStop hook validates tasks//progress.md. @@ -49,28 +60,30 @@ Examples: actor spawn general "Implement auth" "build the login flow" --task T1 # any multi-line prompt — heredoc body is verbatim (no escape needed for " \ $ #): - actor run general "Type checker review" <"}} -{"operation":{"action":"run","subagent_type":"explore","description":"Investigate T4","prompt":"","task_id":"T4"}} -{"operation":{"action":"spawn","subagent_type":"general","description":"Long-running search","prompt":""}} +{"operation":{"action":"spawn","subagent_type":"explore","description":"Find error recovery","prompt":""}} +{"operation":{"action":"spawn","subagent_type":"explore","description":"Investigate T4","prompt":"","task_id":"T4"}} {"operation":{"action":"status","actor_id":""}} {"operation":{"action":"wait","actor_id":""}} {"operation":{"action":"cancel","actor_id":""}} {"operation":{"action":"send","to_actor_id":"","content":""}} +{"operation":{"action":"run","subagent_type":"explore","description":"One tiny blocking lookup","prompt":""}} ← exception only ## Operations (the `operation.action` field selects one) -- run: spawn a subagent and BLOCK until completion; result returned inline. - required: subagent_type, description, prompt - optional: actor_id, timeout_ms, command, context -- spawn: spawn a subagent and return actor_id IMMEDIATELY (background). +- spawn: **THE DEFAULT.** Launch a subagent in the BACKGROUND; returns actor_id + IMMEDIATELY so work proceeds in PARALLEL and your turn stays responsive. + Use it for essentially all delegation: investigation, analysis, review, + implementation, long searches — anything that takes real time. required: subagent_type, description, prompt optional: actor_id, command, context +- run: **RARE EXCEPTION.** Same launch, but BLOCKS the whole conversation until + the subagent finishes; result returned inline. Allowed ONLY when you + cannot make your very next decision without the result in THIS turn, and + the work is a tiny, fast, strictly-blocking lookup. NEVER use `run` for + ordinary analysis, review, or implementation work — those go to `spawn`. + If you are unsure, use `spawn`. + required: subagent_type, description, prompt + optional: actor_id, timeout_ms, command, context - status: poll actor state without blocking. required: actor_id. Returns: { status: "pending"|"running"|"idle"|"unknown", actor_id, turnCount, ... } - wait: block until actor completes (success/failure/cancelled) or timeout (default 10 min). @@ -32,12 +45,28 @@ Examples: ## When to use actor -- **Parallelize**: spawn multiple independent searches/analyses concurrently; `wait` on each. -- **Isolate heavy lifting**: delegate 10+ file reads to a subagent; you get only the synthesis. +- **Parallelize (the normal case)**: `spawn` every independent search/analysis at once so + they run concurrently; collect later via notification, or `wait`/`status` when you must + synchronize. +- **Isolate heavy lifting**: `spawn` a subagent for 10+ file reads; you get only the synthesis. - **Specialized search**: use `explore` for read-only code discovery (finding definitions, callers). - **Custom review**: `general` subagent verifies implementation against spec without bias. -- **Working on a tracked task**: when you spawn a subagent to do work for one of your active tasks (T1, T2, …) — investigation, focused review, dedicated implementation — pass `task_id` so the subagent's verbatim findings get captured to `tasks//progress.md` and reconciled into the next checkpoint. Pass ONLY a task ID the `task` tool returned this session; never invent one. If you haven't created the task yet, create it with the `task` tool first, or omit `task_id` and run ad-hoc. -- **Don't spawn for**: trivial single-file lookups, answers already in your context, or decisions on partial outputs. +- **Working on a tracked task**: when you spawn a subagent to do work for one of your active tasks (T1, T2, …) — investigation, focused review, dedicated implementation — pass `task_id` so the subagent's verbatim findings get captured to `tasks//progress.md` and reconciled into the next checkpoint. Pass ONLY a task ID the `task` tool returned this session; never invent one. If you haven't created the task yet, create it with the `task` tool first, or omit `task_id` and spawn ad-hoc. +- **Don't delegate at all for**: trivial single-file lookups, answers already in your context, or decisions on partial outputs. + +## Collecting spawned work + +`spawn` hands you an actor_id, not a result. Three ways to pick the result up: + +- **Notification (preferred)**: when the background actor finishes, its result arrives as a + notification in this conversation. Your turn does NOT auto-wake — you'll see it the next + time you respond. Keep working or keep talking to the user in the meantime. +- **`wait`**: block on one actor_id only when you have genuinely run out of other work and + must have that result before continuing. +- **`status`**: non-blocking poll of one actor (pending/running/idle) — use it to report + progress without stalling the conversation. + +Fan-out then fan-in: `spawn` 2-3 subagents in one message, then `wait` on each in turn. ## Writing the prompt @@ -60,10 +89,10 @@ You are the subagent's only briefing — it hasn't seen this conversation. ## Binding a subagent to a task -When you `run` or `spawn` a subagent that's doing work for a specific task, -pass the task's TID via `task_id` (e.g. `task_id: "T4"`) — but only a TID the -`task` tool actually returned this session. After the subagent finishes, the -system checks that `tasks//progress.md` exists with the required +When you `spawn` (or, exceptionally, `run`) a subagent that's doing work for a +specific task, pass the task's TID via `task_id` (e.g. `task_id: "T4"`) — but only +a TID the `task` tool actually returned this session. After the subagent finishes, +the system checks that `tasks//progress.md` exists with the required structure — if not, the subagent gets one more chance to write it before terminating. The next checkpoint writer then reads that file and integrates verbatim commands, outcome, and discoveries into the main checkpoint. @@ -75,9 +104,9 @@ postStop check then becomes a no-op. ## Usage notes -- **Resume the same subagent**: pass `actor_id` to `run`/`spawn` and the call resumes that subagent's session (continues with its prior messages and tool outputs). Without `actor_id`, a fresh subagent is created. -- **`run` vs `spawn` result delivery**: `run` blocks and returns the result inline. `spawn` returns the actor_id immediately; when the background actor finishes, its result appears as a notification in this conversation — your turn does NOT auto-wake to process it; you'll see it the next time you respond to the user. Use `wait` to block on the actor_id explicitly. -- **`wait` on persistent peers caveat**: `wait` is designed for ephemeral subagents you spawned via `run`/`spawn`. Persistent peers idle between turns and never produce a "done" outcome on success — `wait` on a peer will block until that peer fails or is cancelled. Use `send` + `status` to coordinate with peers instead. +- **Resume the same subagent**: pass `actor_id` to `spawn`/`run` and the call resumes that subagent's session (continues with its prior messages and tool outputs). Without `actor_id`, a fresh subagent is created. +- **`spawn` vs `run` result delivery**: `spawn` returns the actor_id immediately; when the background actor finishes, its result appears as a notification in this conversation — your turn does NOT auto-wake to process it; you'll see it the next time you respond to the user. Use `wait` to block on the actor_id explicitly. `run` instead blocks the conversation and returns the result inline — that stall is exactly why it's the exception. +- **`wait` on persistent peers caveat**: `wait` is designed for ephemeral subagents you spawned via `spawn`/`run`. Persistent peers idle between turns and never produce a "done" outcome on success — `wait` on a peer will block until that peer fails or is cancelled. Use `send` + `status` to coordinate with peers instead. - **`send` semantics**: fire-and-forget; returns within ~5 ms regardless of receiver load. The receiver picks the message up at the head of its next runLoop iteration. On unknown `to_actor_id`, `send` returns `{inboxID: null, error: "receiver not found"}` rather than throwing — handle the error path. - Trust the subagent's outputs generally, but the subagent doesn't see your full context (unless you pass `context="full"`); brief it accordingly. @@ -86,18 +115,29 @@ postStop check then becomes a no-op. user: "Find all places where parser.ts handles error recovery" -assistant: I'll spawn an explore subagent to scan parser-related files. -[actor({"operation":{"action":"run","subagent_type":"explore","description":"Find error recovery in parser","prompt":"Search src/parser.ts and adjacent files for error-recovery patterns. Return: each location's file:line + a one-sentence description of how it recovers. If you find catch blocks, panic-mode synchronization, or recovery sentinels, list them all."}})] +assistant: I'll spawn an explore subagent in the background to scan parser-related files. +[actor({"operation":{"action":"spawn","subagent_type":"explore","description":"Find error recovery in parser","prompt":"Search src/parser.ts and adjacent files for error-recovery patterns. Return: each location's file:line + a one-sentence description of how it recovers. If you find catch blocks, panic-mode synchronization, or recovery sentinels, list them all."}})] -user: "Verify the type checker is correct against spec.md §3" -assistant: I'll spawn a general subagent to do an independent review. -[actor({"operation":{"action":"run","subagent_type":"general","description":"Type checker spec review","prompt":"Read docs/spec.md §3 (Type System), then read src/types.ts and tests/types.test.ts. Report: (1) any §3 requirement not implemented; (2) any test that fails to cover a §3 requirement. Don't fix anything — just report findings."}})] +user: "Audit the type checker: spec compliance, test coverage, and perf hot spots" +assistant: Three independent questions — I'll spawn all three at once so they run in parallel, then collect. +[actor({"operation":{"action":"spawn","subagent_type":"general","description":"Type checker spec review","prompt":"Read docs/spec.md §3 (Type System), then src/types.ts. Report every §3 requirement not implemented. Don't fix anything."}})] +[actor({"operation":{"action":"spawn","subagent_type":"general","description":"Type checker coverage gaps","prompt":"Read docs/spec.md §3 and tests/types.test.ts. Report every §3 requirement with no test covering it. Don't fix anything."}})] +[actor({"operation":{"action":"spawn","subagent_type":"explore","description":"Type checker hot spots","prompt":"Find the hottest loops and repeated allocations in src/types.ts. Report file:line + why it's hot."}})] +assistant: All three are running. I'll fold their results in as they report; if I need them before then I'll `wait` on each actor_id. user: "investigate T4's failing tests in the type checker" assistant: T4 is an active task in my tracker. I'll spawn an explore subagent bound to T4 so its findings end up in tasks/T4/progress.md and the next checkpoint can integrate them. -[actor({"operation":{"action":"run","subagent_type":"explore","description":"Investigate T4 type checker failures","prompt":"Run `bun test src/types.test.ts` and report each failing case. For each failure: file:line of the assertion, the expected vs actual values, and the most likely root-cause hypothesis based on reading src/types.ts. Don't fix anything.","task_id":"T4"}})] +[actor({"operation":{"action":"spawn","subagent_type":"explore","description":"Investigate T4 type checker failures","prompt":"Run `bun test src/types.test.ts` and report each failing case. For each failure: file:line of the assertion, the expected vs actual values, and the most likely root-cause hypothesis based on reading src/types.ts. Don't fix anything.","task_id":"T4"}})] + + + +THE EXCEPTION — `run` blocks the conversation, so only for a tiny lookup whose answer +gates your very next decision in this same turn. +user: "Rename the config field everywhere — but only if it isn't part of the public API" +assistant: I can't pick between a rename and a deprecation shim until I know whether the field is exported. That's a one-shot blocking lookup, so I'll use `run`. +[actor({"operation":{"action":"run","subagent_type":"explore","description":"Is the field public API","prompt":"Check whether `retryLimit` in src/config.ts is re-exported from src/index.ts or any public entrypoint. Answer yes/no with file:line evidence. Nothing else."}})] diff --git a/packages/opencode/test/tool/actor-prompt-spawn-first.test.ts b/packages/opencode/test/tool/actor-prompt-spawn-first.test.ts new file mode 100644 index 000000000..35ae1fd74 --- /dev/null +++ b/packages/opencode/test/tool/actor-prompt-spawn-first.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test" +import ACTOR_DESCRIPTION from "../../src/tool/actor.txt" +import ACTOR_SHELL_DESCRIPTION from "../../src/tool/actor.shell.txt" + +// Agents kept reaching for the BLOCKING `run` action because the tool prompt +// listed it first and used it in nearly every example, which silently killed +// parallelism. These assertions pin the spawn-first steering so it can't +// regress back into a run-first description. +describe("actor tool prompt steers to spawn first", () => { + for (const [name, prompt] of [ + ["actor.txt", ACTOR_DESCRIPTION], + ["actor.shell.txt", ACTOR_SHELL_DESCRIPTION], + ] as const) { + describe(name, () => { + test("names spawn as the default", () => { + expect(prompt).toMatch(/spawn[^\n]*\bDEFAULT\b|\bDEFAULT\b[^\n]*spawn/i) + }) + + test("ties spawn to background + parallel work", () => { + expect(prompt).toMatch(/background/i) + expect(prompt).toMatch(/parallel/i) + }) + + test("marks run as blocking and as the exception", () => { + expect(prompt).toMatch(/\brun\b[^\n]*\bBLOCK/i) + expect(prompt).toMatch(/exception/i) + }) + + test("mentions the spawned-result collection pattern", () => { + expect(prompt).toMatch(/wait/i) + expect(prompt).toMatch(/status/i) + }) + + test("introduces spawn before run", () => { + const firstSpawn = prompt.search(/\bspawn\b/i) + const firstRun = prompt.search(/\brun\b/i) + expect(firstSpawn).toBeGreaterThanOrEqual(0) + expect(firstSpawn).toBeLessThan(firstRun) + }) + + test("uses spawn for the majority of examples", () => { + const spawnUses = prompt.match(/\bspawn\b/gi)?.length ?? 0 + const runUses = prompt.match(/\brun\b/gi)?.length ?? 0 + expect(spawnUses).toBeGreaterThan(runUses) + }) + }) + } + + test("actor.txt keeps at most one run example, labelled as the exception", () => { + const examples = ACTOR_DESCRIPTION.slice(ACTOR_DESCRIPTION.indexOf("## Examples")) + expect(examples.length).toBeGreaterThan(0) + const runExamples = examples.match(/"action":"run"/g)?.length ?? 0 + expect(runExamples).toBeLessThanOrEqual(1) + if (runExamples === 1) expect(examples).toMatch(/EXCEPTION/) + const spawnExamples = examples.match(/"action":"spawn"/g)?.length ?? 0 + expect(spawnExamples).toBeGreaterThanOrEqual(3) + }) + + test("actor.shell.txt demonstrates a parallel spawn fan-out", () => { + const spawnCommands = ACTOR_SHELL_DESCRIPTION.match(/^\s*actor spawn /gm)?.length ?? 0 + const runCommands = ACTOR_SHELL_DESCRIPTION.match(/^\s*actor run /gm)?.length ?? 0 + expect(spawnCommands).toBeGreaterThan(runCommands) + expect(spawnCommands).toBeGreaterThanOrEqual(3) + }) +}) From d7094829a1c2d0e6a6cb32f99e092efb1a899303 Mon Sep 17 00:00:00 2001 From: wqymi Date: Mon, 27 Jul 2026 21:28:01 +0800 Subject: [PATCH 012/135] revert(session): remove the empty-step guard that mis-flagged no-arg tool calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The empty/no-op tool-call loop guard (isEmptyStep + handleEmptyStep) treated a tool call with `input: {}` as an "empty step" with NO PROGRESS. That is wrong: plenty of legitimate tools take no arguments (e.g. `list_apps`), so a perfectly valid step got soft-nudged and, after EMPTY_STEP_MAX_RECOVERY, hard-halted the turn with a bogus "Empty tool call loop detected" terminal error. The design cannot be narrowed into correctness — "the model called a tool with no arguments" is indistinguishable from "the model made progress" without per-tool schema knowledge that the guard does not reliably have. Remove it: - delete src/session/prompt/empty-step-detection.ts and its two suites - drop isEmptyStep / handleEmptyStep / emptyStepStreak / hardHalt wiring and both branch call sites from src/session/prompt.ts - drop MIMOCODE_EMPTY_STEP_MAX_RECOVERY from src/flag/flag.ts - drop the invalid-output-continuation case that asserted the guard's halt This is a pure revert. An earlier revision of this branch also added a `leaked-toolcall-marker` detector (matching a text part whose whole trimmed content is "call:", "code", or a bare invoked tool name) plus a retry ladder. That is intentionally NOT included: the marker leak was a quirk of Claude Opus 4.8, which is no longer in use, so the detector would be dead code for a defect that no longer occurs. It also carried real downside — a legitimate one-word `code` text part next to a same-named tool would discard the whole step, and priming the model about `call:` plausibly makes the leak more likely, not less. --- packages/opencode/src/flag/flag.ts | 4 - packages/opencode/src/session/prompt.ts | 122 ------------- .../session/prompt/empty-step-detection.ts | 99 ----------- .../test/session/empty-step-detection.test.ts | 96 ----------- .../empty-step-guard-integration.test.ts | 162 ------------------ .../invalid-output-continuation.test.ts | 37 ---- 6 files changed, 520 deletions(-) delete mode 100644 packages/opencode/src/session/prompt/empty-step-detection.ts delete mode 100644 packages/opencode/test/session/empty-step-detection.test.ts delete mode 100644 packages/opencode/test/session/empty-step-guard-integration.test.ts diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index f78d0ac0f..ef539785a 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -95,10 +95,6 @@ export const Flag = { get MIMOCODE_FORCE_ANTHROPIC_REASONING_CONTENT() { return truthy("MIMOCODE_FORCE_ANTHROPIC_REASONING_CONTENT") }, - // Empty/no-op tool-call loop guard: number of soft nudges (remind → replan) - // before the harness hard-halts the turn. N consecutive empty steps beyond - // this many recovery attempts terminates the turn. Mirrors TEXT_NGRAM_MAX_RECOVERY. - MIMOCODE_EMPTY_STEP_MAX_RECOVERY: number("MIMOCODE_EMPTY_STEP_MAX_RECOVERY") ?? 2, // Consecutive-block repetition detection for streamed reasoning + text. // A block of at least N tokens repeating REPEAT_THRESHOLD times consecutively diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3955d8039..ba369f907 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -55,12 +55,6 @@ import { TEXT_NGRAM_RECOVERY_REMIND, TEXT_NGRAM_RECOVERY_REPLAN, } from "../session/prompt/text-ngram-detection" -import { - EMPTY_STEP_MAX_RECOVERY, - EMPTY_STEP_RECOVERY_REMIND, - EMPTY_STEP_RECOVERY_REPLAN, - isEmptyStep, -} from "../session/prompt/empty-step-detection" import { builtinSkillRoot, matchDocumentSkills } from "@/skill/builtin/extract" import { ToolRegistry } from "../tool" import { MCP } from "../mcp" @@ -2324,19 +2318,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the // prose text instead of a structured tool_use). Local to runLoop so each // fresh user turn starts clean. let textToolCallRetries = 0 - // Consecutive empty/no-op tool-call steps in this turn. Counts steps - // where the model "called a tool" with empty/invalid input, or produced - // no valid tool part and no substantive output at all (see isEmptyStep). - // A single non-empty step resets it. Escalates soft (remind → replan) - // then hard-halts once it exceeds EMPTY_STEP_MAX_RECOVERY, mirroring the - // text-ngram ladder. Local to runLoop so a fresh user turn starts clean. - let emptyStepStreak = 0 - // Set true when a guard hard-halts the turn (currently the empty-step - // guard). A hard halt is terminal: it must break out immediately and - // NOT be re-entered by the goalGate ReAct gate, which would - // otherwise inject a fresh user turn and re-drive a still-degraded model - // into the same loop. - let hardHalt = false const resolvedAgentID = agentID ?? "main" // Tracks plugin-driven cancellation (session.pre OR any session.userQuery.pre) // so session.post reports outcome="cancelled" instead of "error". @@ -2834,90 +2815,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the return true }) - // Empty/no-op tool-call loop guard. Symmetric across main and fork - // branches, mirroring handleTextRepeat's soft→hard ladder but keyed on - // *empty steps* (empty/invalid tool input, or a fully empty terminal) - // rather than repeated text n-grams — the gap TEXT_NGRAM and - // stepSignature both miss (an empty tool call has no text to match and - // is dropped by stepSignature's undefined path). - // - // Returns: - // "none" — the step was NOT empty; streak reset, caller continues - // normal classification. - // "continue" — empty step, still within the soft-nudge budget; a - // remind/replan reminder was injected, caller should loop. - // "halt" — empty streak exceeded EMPTY_STEP_MAX_RECOVERY; a - // terminal error was published, caller must break. - const handleEmptyStep = Effect.fn("SessionPrompt.handleEmptyStep")(function* (input: { - lastUser: MessageV2.User - assistant: MessageV2.Assistant - }) { - // Never mask a genuine terminal outcome as an "empty loop": an errored - // step, a content-filter/error finish, or an already-resolved - // structured/summary step must fall through to its own classifier - // handler (writeContentFilterError / writeModelError / final). Those - // are terminal safety/error events, not a spinning no-op. - if ( - input.assistant.error || - input.assistant.summary || - input.assistant.structured !== undefined || - input.assistant.finish === "content-filter" || - input.assistant.finish === "error" - ) { - return "none" as const - } - const parts = MessageV2.parts(input.assistant.id) - if (!isEmptyStep(parts)) { - emptyStepStreak = 0 - return "none" as const - } - emptyStepStreak++ - if (emptyStepStreak > EMPTY_STEP_MAX_RECOVERY) { - yield* slog.info("empty step: max recovery exceeded, terminating", { streak: emptyStepStreak }) - hardHalt = true - // Discard the empty turn from request history so it can neither - // strand the conversation on an assistant prefill nor poison later - // context (toModelMessages skips a message whose info.error is set). - if (!input.assistant.error) { - input.assistant.error = new NamedError.Unknown({ - message: `Empty tool call loop detected: ${emptyStepStreak} consecutive empty/no-op steps after ${EMPTY_STEP_MAX_RECOVERY} recovery attempts. Session terminated.`, - }).toObject() - yield* sessions.updateMessage(input.assistant) - } - yield* bus.publish(Session.Event.Error, { - sessionID, - error: new NamedError.Unknown({ - message: `Empty tool call loop detected: ${emptyStepStreak} consecutive empty/no-op steps after ${EMPTY_STEP_MAX_RECOVERY} recovery attempts. Session terminated.`, - }).toObject(), - }) - return "halt" as const - } - const recoveryText = - emptyStepStreak === 1 ? EMPTY_STEP_RECOVERY_REMIND : EMPTY_STEP_RECOVERY_REPLAN - const reentry = yield* sessions.updateMessage({ - id: MessageID.ascending(), - role: "user" as const, - sessionID, - agentID: input.lastUser.agentID, - agent: input.lastUser.agent, - model: input.lastUser.model, - tools: input.lastUser.tools, - format: input.lastUser.format, - time: { created: Date.now() }, - }) - yield* sessions.updatePart({ - id: PartID.ascending(), - messageID: reentry.id, - sessionID, - type: "text", - synthetic: true, - text: recoveryText, - } satisfies MessageV2.TextPart) - yield* slog.info("empty step: recovery injected", { streak: emptyStepStreak }) - return "continue" as const - }) - - // content-filter is terminal on first occurrence: re-sending the same // turn would just get filtered again, so there is no nudge / counter. // Write a user-visible error (rendered via the session.error toast) and @@ -3624,14 +3521,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the return "break" as const } - // Empty/no-op tool-call loop guard (fork branch). Intercept before - // classify would `continue` an empty tool-calls step: soft-nudge - // within budget, hard-halt once exceeded. A non-empty step returns - // "none" and falls through to normal classification. - const forkEmptyStep = yield* handleEmptyStep({ lastUser, assistant: handle.message }) - if (forkEmptyStep === "halt") return "break" as const - if (forkEmptyStep === "continue") return "continue" as const - const forkClassification = classifyAssistantStep({ phase: "after-process", lastUser, @@ -3851,14 +3740,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the return "break" as const } - // Empty/no-op tool-call loop guard (main branch). Intercept before - // classify would `continue` an empty tool-calls step: soft-nudge - // within budget, hard-halt once exceeded. A non-empty step returns - // "none" and falls through to normal classification. - const emptyStep = yield* handleEmptyStep({ lastUser, assistant: handle.message }) - if (emptyStep === "halt") return "break" as const - if (emptyStep === "continue") return "continue" as const - const classification = classifyAssistantStep({ phase: "after-process", lastUser, @@ -4006,9 +3887,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the } if (outcome === "break") { - // A hard halt is terminal — skip the ReAct re-entry gates so a - // degraded model can't be re-driven into the same empty loop. - if (hardHalt) break if (yield* goalGate(lastUser)) continue break } diff --git a/packages/opencode/src/session/prompt/empty-step-detection.ts b/packages/opencode/src/session/prompt/empty-step-detection.ts deleted file mode 100644 index 9715e1bc8..000000000 --- a/packages/opencode/src/session/prompt/empty-step-detection.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Flag } from "@/flag/flag" -import type { MessageV2 } from "../message-v2" - -/** - * Empty tool-call loop guard. - * - * Narrow purpose: some models (including frontier ones under certain workloads) - * occasionally emit a tool call with a completely empty argument object — - * i.e. they "called a tool" but passed nothing actionable. Re-looping just - * repeats the same empty call. This guard detects that specific shape and - * escalates via a soft→hard recovery ladder mirroring text-ngram-detection. - * - * IMPORTANT scope note: this guard does NOT try to catch "empty terminals" - * (steps that emit no tool call and no text). An empty terminal is a natural - * turn end, not a spin — the next user input drives the next turn. Treating - * it as a loop caused frequent false positives on legitimate quiet steps - * (task done, sub-agent returned, reasoning-only steps, provider-executed - * tool calls). Wall-clock / active deadlines and provider stream timeouts - * already backstop any actual "model produces nothing" pathology. - */ - -export const EMPTY_STEP_MAX_RECOVERY = Flag.MIMOCODE_EMPTY_STEP_MAX_RECOVERY - -/** - * Is this assistant step an empty tool call? - * - * True iff the step emitted one or more client (non-providerExecuted) tool - * parts AND every such tool part has an empty/invalid input — no keys, or - * only keys whose values are null/undefined/empty-string/whitespace. - * - * A step with ANY tool part that has real input is NOT empty. - * A step with no client tool part is NOT empty (empty terminals are allowed). - * A step with substantive text or reasoning alongside a bad tool call is NOT - * empty (the model is making some kind of progress). - * - * Provider-executed tool parts (e.g. server-side web search) are ignored: - * they are not client actions. - */ -export function isEmptyStep(parts: readonly MessageV2.Part[]): boolean { - const clientToolParts = parts.filter( - (part): part is Extract => - part.type === "tool" && !part.metadata?.providerExecuted, - ) - - // No client tool part → not an empty tool call. Empty terminals fall through - // to natural turn end; this guard only targets the specific "called a tool - // with no args" pathology. - if (clientToolParts.length === 0) return false - - // Substantive text or reasoning alongside a bad tool call → model is making - // progress, don't flag. - const hasSubstantiveText = parts.some( - (part) => part.type === "text" && !part.synthetic && !part.ignored && part.text.trim().length > 0, - ) - if (hasSubstantiveText) return false - const hasSubstantiveReasoning = parts.some( - (part) => part.type === "reasoning" && part.text.trim().length > 0, - ) - if (hasSubstantiveReasoning) return false - - // Every client tool part must have empty input. - return clientToolParts.every((part) => isEmptyInput(part.state.input)) -} - -/** - * An input object counts as empty when it has no keys, or every value is - * null/undefined/empty-string/whitespace-only. Nested objects/arrays with any - * content count as non-empty (the model passed *something*). - */ -function isEmptyInput(input: Record | undefined | null): boolean { - if (input === undefined || input === null) return true - const keys = Object.keys(input) - if (keys.length === 0) return true - return keys.every((k) => isEmptyValue(input[k])) -} - -function isEmptyValue(value: unknown): boolean { - if (value === undefined || value === null) return true - if (typeof value === "string") return value.trim().length === 0 - if (Array.isArray(value)) return value.length === 0 - if (typeof value === "object") return Object.keys(value as Record).length === 0 - // number / boolean → the model passed a real value. - return false -} - -export const EMPTY_STEP_RECOVERY_REMIND = [ - "", - "Your previous tool call had empty or missing arguments — the tool needs real input to make progress.", - "Retry the call with COMPLETE arguments, or if the tool is not the right next step, answer the user in plain text.", - "", -].join("\n") - -export const EMPTY_STEP_RECOVERY_REPLAN = [ - "", - "Second empty tool call. Final chance before this turn is halted.", - "Either issue a tool call with fully-populated arguments, or give a plain-text reply.", - "Any further empty-argument tool call will terminate this turn.", - "", -].join("\n") diff --git a/packages/opencode/test/session/empty-step-detection.test.ts b/packages/opencode/test/session/empty-step-detection.test.ts deleted file mode 100644 index a0347055c..000000000 --- a/packages/opencode/test/session/empty-step-detection.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { isEmptyStep } from "../../src/session/prompt/empty-step-detection" -import type { MessageV2 } from "../../src/session/message-v2" - -// Minimal part builders — only the fields isEmptyStep inspects. Cast through -// unknown so we don't have to satisfy the full PartBase shape (id/messageID/…) -// that isEmptyStep never reads. -function toolPart(input: Record, opts?: { providerExecuted?: boolean; status?: string }) { - return { - type: "tool", - tool: "read", - metadata: opts?.providerExecuted ? { providerExecuted: true } : undefined, - state: { status: opts?.status ?? "completed", input }, - } as unknown as MessageV2.Part -} - -function textPart(text: string, opts?: { synthetic?: boolean; ignored?: boolean }) { - return { - type: "text", - text, - synthetic: opts?.synthetic, - ignored: opts?.ignored, - } as unknown as MessageV2.Part -} - -function reasoningPart(text: string) { - return { type: "reasoning", text } as unknown as MessageV2.Part -} - -describe("isEmptyStep — case (a): tool call with empty/invalid input", () => { - test("tool call with no keys is empty", () => { - expect(isEmptyStep([toolPart({})])).toBe(true) - }) - - test("tool call whose only values are empty strings/whitespace is empty", () => { - expect(isEmptyStep([toolPart({ file_path: "", pattern: " " })])).toBe(true) - }) - - test("tool call whose values are null/undefined is empty", () => { - expect(isEmptyStep([toolPart({ a: null, b: undefined })])).toBe(true) - }) - - test("tool call with empty array / empty object values is empty", () => { - expect(isEmptyStep([toolPart({ items: [], opts: {} })])).toBe(true) - }) - - test("tool call with a real string argument is NOT empty", () => { - expect(isEmptyStep([toolPart({ file_path: "/tmp/x" })])).toBe(false) - }) - - test("tool call with a numeric/boolean argument is NOT empty", () => { - expect(isEmptyStep([toolPart({ limit: 0 })])).toBe(false) - expect(isEmptyStep([toolPart({ flag: false })])).toBe(false) - }) - - test("all tool parts empty => empty; any non-empty tool part => not empty", () => { - expect(isEmptyStep([toolPart({}), toolPart({ x: "" })])).toBe(true) - expect(isEmptyStep([toolPart({}), toolPart({ x: "real" })])).toBe(false) - }) - - test("provider-executed tool part is ignored for the has-tool test", () => { - // A provider-executed part is not a client action; without any real client - // tool call there is nothing to flag as empty (b-branch is disabled). - expect(isEmptyStep([toolPart({ q: "x" }, { providerExecuted: true })])).toBe(false) - }) -}) - -describe("isEmptyStep — (b) empty terminal is NOT flagged (allowed by design)", () => { - test("completely empty parts array is NOT empty (natural turn end)", () => { - expect(isEmptyStep([])).toBe(false) - }) - - test("only a synthetic text part is NOT empty (no client tool call to flag)", () => { - expect(isEmptyStep([textPart("...", { synthetic: true })])).toBe(false) - }) - - test("only whitespace text is NOT empty", () => { - expect(isEmptyStep([textPart(" \n ")])).toBe(false) - }) - - test("substantive text answer is NOT empty", () => { - expect(isEmptyStep([textPart("Here is your answer.")])).toBe(false) - }) - - test("substantive reasoning is NOT empty", () => { - expect(isEmptyStep([reasoningPart("Let me think about this...")])).toBe(false) - }) - - test("ignored text alone is NOT empty (no tool call to flag)", () => { - expect(isEmptyStep([textPart("stuff", { ignored: true })])).toBe(false) - }) - - test("provider-executed tool part alone is NOT empty (not a client action)", () => { - expect(isEmptyStep([toolPart({ q: "x" }, { providerExecuted: true })])).toBe(false) - }) -}) diff --git a/packages/opencode/test/session/empty-step-guard-integration.test.ts b/packages/opencode/test/session/empty-step-guard-integration.test.ts deleted file mode 100644 index 2ecac9064..000000000 --- a/packages/opencode/test/session/empty-step-guard-integration.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Integration tests for the empty/no-op tool-call loop guard (handleEmptyStep + - * isEmptyStep). Driven end-to-end through Session.prompt against a scripted - * HTTP LLM stub — same harness as classify-integration.test.ts. - * - * Root cause this guards: a degraded model can spin by emitting empty/no-op - * steps (empty terminal, or a tool call with empty arguments). TEXT_NGRAM only - * inspects text and stepSignature drops zero-tool steps, so neither counts the - * loop. The guard escalates soft (remind → replan) up to - * EMPTY_STEP_MAX_RECOVERY, then HARD-HALTS the turn. - * - * EMPTY_STEP_MAX_RECOVERY defaults to 2, so the ladder is: - * step 1 (empty) → streak 1 → REMIND nudge, continue - * step 2 (empty) → streak 2 → REPLAN nudge, continue - * step 3 (empty) → streak 3 > 2 → terminal error, break - * i.e. exactly 3 model calls before the turn is halted. - */ - -import path from "path" -import { afterEach, describe, expect, test } from "bun:test" -import { Effect, Layer } from "effect" -import { Instance } from "../../src/project/instance" -import { Session } from "../../src/session" -import { SessionPrompt } from "../../src/session/prompt" -import { EMPTY_STEP_MAX_RECOVERY } from "../../src/session/prompt/empty-step-detection" -import { Log } from "../../src/util" -import { tmpdir } from "../fixture/fixture" -import { startScriptedLLMServer, emptyStopResponse, textStopResponse, toolCallStopResponse } from "../lib/scripted-llm-server" - -void Log.init({ print: false }) - -afterEach(async () => { - await Instance.disposeAll() -}) - -function run(fx: Effect.Effect) { - return Effect.runPromise( - fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))), - ) -} - -function writeConfig(dir: string, origin: string) { - return Bun.write( - path.join(dir, "mimocode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - enabled_providers: ["alibaba"], - provider: { - alibaba: { options: { apiKey: "test-key", baseURL: `${origin}/v1` } }, - }, - agent: { build: { model: "alibaba/qwen-plus" } }, - }), - ) -} - -describe("empty/no-op tool-call loop guard — integration", () => { - test("repeated empty-args tool calls HARD-HALT the turn instead of looping forever", async () => { - await using tmp = await tmpdir({ git: true }) - // Every response is a tool call with empty args ({}). The stub repeats its - // last entry forever, so if the guard failed to halt this would spin - // indefinitely. We assert it terminates. (This is the specific "frontier - // model emits tool call with no args" pathology the guard targets.) - const stub = startScriptedLLMServer([ - { lines: toolCallStopResponse({ id: "call_1", name: "read", args: "{}" }) }, - ]) - try { - await writeConfig(tmp.path, stub.origin) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Effect.gen(function* () { - const sessions = yield* Session.Service - const prompt = yield* SessionPrompt.Service - const session = yield* sessions.create({ title: "empty-step-halt" }) - const result = yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - parts: [{ type: "text", text: "Do the task." }], - }) - expect(result.info.role).toBe("assistant") - if (result.info.role === "assistant") expect(result.info.error).toBeDefined() - expect(stub.captures.length).toBe(EMPTY_STEP_MAX_RECOVERY + 1) - }), - ), - }) - } finally { - await stub.stop() - } - }) - - test("empty terminal (no tool call, no text) is NOT halted by the empty-step guard", async () => { - await using tmp = await tmpdir({ git: true }) - // Empty terminal used to be flagged (b-branch) and could hard-halt the turn - // after EMPTY_STEP_MAX_RECOVERY. It is now allowed by isEmptyStep — no - // client tool call means nothing to loop-guard. Other invalid-output - // handling (autoContinueInvalidOutput) may still nudge, but the guard's - // terminal error must not fire. - const stub = startScriptedLLMServer([{ lines: emptyStopResponse() }]) - try { - await writeConfig(tmp.path, stub.origin) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Effect.gen(function* () { - const sessions = yield* Session.Service - const prompt = yield* SessionPrompt.Service - const session = yield* sessions.create({ title: "empty-terminal-allowed" }) - const result = yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - parts: [{ type: "text", text: "Do the task." }], - }) - expect(result.info.role).toBe("assistant") - // The empty-step guard specifically must NOT be the terminator. - if (result.info.role === "assistant" && result.info.error) { - expect(result.info.error.data?.message ?? "").not.toContain("Empty tool call loop detected") - } - }), - ), - }) - } finally { - await stub.stop() - } - }) - - test("a single empty-args tool call recovers when the next step produces a real answer (no halt)", async () => { - await using tmp = await tmpdir({ git: true }) - const stub = startScriptedLLMServer([ - // step 1: empty-args tool call → streak 1 → REMIND nudge, continue - { lines: toolCallStopResponse({ id: "call_1", name: "read", args: "{}" }) }, - // step 2: real answer → streak reset, loop exits cleanly - { lines: textStopResponse("here is the real answer") }, - ]) - try { - await writeConfig(tmp.path, stub.origin) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Effect.gen(function* () { - const sessions = yield* Session.Service - const prompt = yield* SessionPrompt.Service - const session = yield* sessions.create({ title: "empty-step-recover" }) - const result = yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - parts: [{ type: "text", text: "Do the task." }], - }) - expect(stub.captures.length).toBe(2) - expect(result.info.role).toBe("assistant") - if (result.info.role === "assistant") expect(result.info.error).toBeUndefined() - expect(result.parts.some((p) => p.type === "text" && p.text === "here is the real answer")).toBe(true) - }), - ), - }) - } finally { - await stub.stop() - } - }) -}) diff --git a/packages/opencode/test/session/invalid-output-continuation.test.ts b/packages/opencode/test/session/invalid-output-continuation.test.ts index 85f77bc7b..2472eb78e 100644 --- a/packages/opencode/test/session/invalid-output-continuation.test.ts +++ b/packages/opencode/test/session/invalid-output-continuation.test.ts @@ -14,7 +14,6 @@ import { Effect, Layer } from "effect" import { Instance } from "../../src/project/instance" import { Session } from "../../src/session" import { SessionPrompt } from "../../src/session/prompt" -import { Flag } from "../../src/flag/flag" import { Log } from "../../src/util" import { tmpdir } from "../fixture/fixture" import { @@ -282,40 +281,4 @@ describe("invalid-output continuation — integration", () => { await stub.stop() } }) - - test("repeated empty output is caught by the empty-step guard and halts the turn", async () => { - await using tmp = await tmpdir({ git: true }) - // Server repeats the last entry, so every call returns an empty stop. - // The empty/no-op tool-call guard (empty-step-detection) intercepts these - // empty terminals BEFORE autoContinueInvalidOutput and hard-halts the turn - // after EMPTY_STEP_MAX_RECOVERY soft nudges + 1 halting step. - const stub = startScriptedLLMServer([{ lines: emptyStopResponse() }]) - try { - await writeConfig(tmp.path, stub.origin) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Effect.gen(function* () { - const sessions = yield* Session.Service - const prompt = yield* SessionPrompt.Service - const session = yield* sessions.create({ title: "invalid-exhaust" }) - const result = yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - parts: [{ type: "text", text: "Answer my question." }], - }) - // EMPTY_STEP_MAX_RECOVERY soft nudges + 1 halting step. - expect(stub.captures.length).toBe(Flag.MIMOCODE_EMPTY_STEP_MAX_RECOVERY + 1) - expect(result.info.role).toBe("assistant") - if (result.info.role === "assistant") { - expect(result.info.error).toBeDefined() - } - }), - ), - }) - } finally { - await stub.stop() - } - }) }) From 0aca2747fa618b68a4346fb6bc6ef544953aae2b Mon Sep 17 00:00:00 2001 From: Murat Date: Mon, 27 Jul 2026 15:35:36 +0200 Subject: [PATCH 013/135] fix(plugin): prettier formatting --- packages/opencode/src/plugin/index.ts | 1466 ++++++++++++------------- 1 file changed, 730 insertions(+), 736 deletions(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index fd8a36058..0113763b3 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -1,736 +1,730 @@ -import type { - Hooks, - PluginInput, - Plugin as PluginInstance, - PluginModule, - WorkspaceAdaptor as PluginWorkspaceAdaptor, - ActorPreStopInput, - ActorPostStopInput, - ActorStopOutput, - ActorMatcher, -} from "@mimo-ai/plugin" -import { z } from "zod" -import { matchesActor } from "./matcher" -import { Config } from "../config" -import { Bus } from "../bus" -import { BusEvent } from "../bus/bus-event" -import { Log } from "../util" -import { createOpencodeClient } from "@mimo-ai/sdk" -import { Flag } from "../flag/flag" -import { CodexAuthPlugin } from "./codex" -import { XaiAuthPlugin } from "./xai" -import { MimoAuthPlugin, AnthropicProxyPlugin } from "./mimo" -import { Session } from "../session" -import type { SessionID } from "../session/schema" -import { NamedError } from "@mimo-ai/shared/util/error" -import { CopilotAuthPlugin } from "./github-copilot/copilot" -import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" -import { PoeAuthPlugin } from "opencode-poe-auth" -import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" -import { CheckpointSplitoverPlugin } from "./checkpoint-splitover" -import { SubagentProgressCheckerPlugin } from "./subagent-progress-checker" -import { Effect, Layer, Context, Stream } from "effect" -import { EffectBridge } from "@/effect" -import { InstanceState } from "@/effect" -import { errorMessage } from "@/util/error" -import { PluginLoader } from "./loader" -import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" -import { registerAdaptor } from "@/control-plane/adaptors" -import type { WorkspaceAdaptor } from "@/control-plane/types" -import { Glob } from "@mimo-ai/shared/util/glob" -import fs from "fs" -import path from "path" -import { pathToFileURL, fileURLToPath } from "url" - -const log = Log.create({ service: "plugin" }) - -export const HookEvent = { - Executed: BusEvent.define( - "hook.executed", - z.object({ - event: z.enum(["actor.preStop", "actor.postStop"]), - hookID: z.string(), - pluginName: z.string(), - actorID: z.string(), - agentType: z.string(), - durationMs: z.number(), - outcome: z.enum(["success", "error", "skipped"]), - continueRequested: z.boolean(), - reasonLength: z.number(), - }), - ), - ReActReentered: BusEvent.define( - "hook.react.reentered", - z.object({ - phase: z.enum(["pre", "post"]), - actorID: z.string(), - agentType: z.string(), - iteration: z.number(), - triggeredByPlugins: z.array(z.string()), - reasonPreview: z.string(), - }), - ), - ReActMaxReached: BusEvent.define( - "hook.react.max_reached", - z.object({ - phase: z.enum(["pre", "post"]), - actorID: z.string(), - agentType: z.string(), - }), - ), -} as const - -type HookEntry = { - hook: Hooks - pluginName: string - /** Stable per-event hook ID: `${pluginName}#${eventName}` */ - hookIDFor: (eventName: string) => string -} - -type State = { - hooks: Hooks[] - hooksWithMeta: HookEntry[] -} - -type FileHookState = { - hooks: Hooks[] - meta: HookEntry[] - dirs: string[] - /** Absolute path -> mtimeMs at load time, for cheap staleness checks. */ - files: Record - /** Mutable box: last staleness check timestamp (throttle). */ - lastCheck: { value: number } -} - -const FILE_HOOK_GLOB = "{hook,hooks}/*.{js,ts}" -const FILE_HOOK_CHECK_INTERVAL_MS = 500 - -export type ActorStopAggregatedDecision = ActorStopOutput & { - contributingPluginNames: string[] - contributingHookIDs: string[] -} - -// Hook names that follow the (input, output) => Promise trigger pattern -type TriggerName = { - [K in keyof Hooks]-?: NonNullable extends (input: any, output: any) => Promise ? K : never -}[keyof Hooks] - -export interface Interface { - readonly trigger: < - Name extends TriggerName, - Input = Parameters[Name]>[0], - Output = Parameters[Name]>[1], - >( - name: Name, - input: Input, - output: Output, - ) => Effect.Effect - readonly list: () => Effect.Effect - readonly init: () => Effect.Effect - readonly reloadFileHooks: () => Effect.Effect - readonly triggerActorPreStop: ( - input: ActorPreStopInput, - ) => Effect.Effect - readonly triggerActorPostStop: ( - input: ActorPostStopInput, - ) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/Plugin") {} - -// Built-in plugins that are directly imported (not installed from npm) -const INTERNAL_PLUGINS: PluginInstance[] = [ - MimoAuthPlugin, - AnthropicProxyPlugin, - CodexAuthPlugin, - XaiAuthPlugin, - CopilotAuthPlugin, - // gitlab/poe auth are external npm packages typed against the published - // upstream plugin package, which carries a duplicate (nominal) copy of the - // SDK client; cast through unknown to the workspace Plugin type. - GitlabAuthPlugin as unknown as PluginInstance, - PoeAuthPlugin as unknown as PluginInstance, - CloudflareWorkersAuthPlugin, - CloudflareAIGatewayAuthPlugin, - CheckpointSplitoverPlugin, - SubagentProgressCheckerPlugin, -] - -function isServerPlugin(value: unknown): value is PluginInstance { - return typeof value === "function" -} - -function getServerPlugin(value: unknown) { - if (isServerPlugin(value)) return value - if (!value || typeof value !== "object" || !("server" in value)) return - if (!isServerPlugin(value.server)) return - return value.server -} - -function getLegacyPlugins(mod: Record) { - const seen = new Set() - const result: PluginInstance[] = [] - - for (const entry of Object.values(mod)) { - if (seen.has(entry)) continue - seen.add(entry) - const plugin = getServerPlugin(entry) - if (!plugin) continue - result.push(plugin) - } - - return result -} - -async function applyPlugin( - load: PluginLoader.Loaded, - input: PluginInput, - hooks: Hooks[], - hooksWithMeta: HookEntry[], -) { - const plugin = readV1Plugin(load.mod, load.spec, "server", "detect") - if (plugin) { - await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg) - const pluginName = readPluginId(plugin.id, load.spec) ?? load.pkg?.pkg ?? load.spec - const hookObj = await (plugin as PluginModule).server(input, load.options) - hooks.push(hookObj) - hooksWithMeta.push({ - hook: hookObj, - pluginName, - hookIDFor: (event: string) => `${pluginName}#${event}`, - }) - return - } - - for (const server of getLegacyPlugins(load.mod)) { - const fnName = (server as { name?: string }).name - const pluginName = fnName && fnName !== "default" && fnName !== "" - ? fnName - : (load.pkg?.pkg ?? load.spec) - const hookObj = await server(input, load.options) - hooks.push(hookObj) - hooksWithMeta.push({ - hook: hookObj, - pluginName, - hookIDFor: (event: string) => `${pluginName}#${event}`, - }) - } -} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const bus = yield* Bus.Service - const config = yield* Config.Service - - const state = yield* InstanceState.make( - Effect.fn("Plugin.state")(function* (ctx) { - const hooks: Hooks[] = [] - const hooksWithMeta: HookEntry[] = [] - const bridge = yield* EffectBridge.make() - - function publishPluginError(message: string) { - bridge.fork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })) - } - - const { Server } = yield* Effect.promise(() => import("../server/server")) - - const client = createOpencodeClient({ - baseUrl: "http://localhost:4096", - directory: ctx.directory, - headers: Flag.MIMOCODE_SERVER_PASSWORD - ? { - Authorization: `Basic ${Buffer.from(`${Flag.MIMOCODE_SERVER_USERNAME ?? "mimocode"}:${Flag.MIMOCODE_SERVER_PASSWORD}`).toString("base64")}`, - } - : undefined, - fetch: async (...args) => (await Server.Default()).app.fetch(...args), - }) - const cfg = yield* config.get() - const input: PluginInput = { - client, - project: ctx.project, - worktree: ctx.worktree, - directory: ctx.directory, - experimental_workspace: { - register(type: string, adaptor: PluginWorkspaceAdaptor) { - registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor) - }, - }, - get serverUrl(): URL { - return Server.url ?? new URL("http://localhost:4096") - }, - // @ts-expect-error - $: typeof Bun === "undefined" ? undefined : Bun.$, - } - - for (const plugin of INTERNAL_PLUGINS) { - log.info("loading internal plugin", { name: plugin.name }) - const init = yield* Effect.tryPromise({ - try: () => plugin(input), - catch: (err) => { - log.error("failed to load internal plugin", { name: plugin.name, error: err }) - }, - }).pipe(Effect.option) - if (init._tag === "Some") { - hooks.push(init.value) - hooksWithMeta.push({ - hook: init.value, - pluginName: plugin.name, - hookIDFor: (event: string) => `${plugin.name}#${event}`, - }) - } - } - - // Load optional local extensions under src/ext/. Prefers the generated - // _manifest.ts (a fixed import specifier resolves inside Bun single-file - // executables, where filesystem scans do not); falls back to a directory - // scan for unbundled runs. Each *Plugin-named export is registered. - const extModules: Record> = {} - // @ts-ignore generated manifest; may not exist at type-check time - const manifest = yield* Effect.tryPromise(() => import("../ext/_manifest")).pipe(Effect.option) - if (manifest._tag === "Some") { - Object.assign( - extModules, - (manifest.value as { modules?: Record> }).modules ?? {}, - ) - } else { - const extDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "ext") - const extFiles = fs.existsSync(extDir) - ? fs.readdirSync(extDir).filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts") && f !== "_manifest.ts") - : [] - for (const entry of extFiles) { - const mod = yield* Effect.tryPromise({ - try: () => import(/* @vite-ignore */ pathToFileURL(path.join(extDir, entry)).href), - catch: (err) => log.error("failed to import extension", { name: entry, error: err }), - }).pipe(Effect.option) - if (mod._tag === "Some") extModules[entry.replace(/\.ts$/, "")] = mod.value as Record - } - } - for (const [name, value] of Object.entries(extModules)) { - // Only treat *Plugin-named function exports as plugins. Other modules - // (e.g. a CLI helper export) are not plugins and must not be invoked - // as plugin factories. - const overlay = Object.entries(value).find( - ([exportName, v]) => typeof v === "function" && exportName.endsWith("Plugin"), - )?.[1] as PluginInstance | undefined - if (!overlay) continue - log.info("loading extension", { name }) - const init = yield* Effect.tryPromise({ - try: () => overlay(input), - catch: (err) => log.error("failed to load extension", { name, error: err }), - }).pipe(Effect.option) - if (init._tag === "Some") { - hooks.push(init.value) - hooksWithMeta.push({ - hook: init.value, - pluginName: name, - hookIDFor: (event: string) => `${name}#${event}`, - }) - } - } - - const plugins = Flag.MIMOCODE_PURE ? [] : (cfg.plugin_origins ?? []) - if (Flag.MIMOCODE_PURE && cfg.plugin_origins?.length) { - log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length }) - } - if (plugins.length) yield* config.waitForDependencies() - - const loaded = yield* Effect.promise(() => - PluginLoader.loadExternal({ - items: plugins, - kind: "server", - report: { - start(candidate) { - log.info("loading plugin", { path: candidate.plan.spec }) - }, - missing(candidate, _retry, message) { - log.warn("plugin has no server entrypoint", { path: candidate.plan.spec, message }) - }, - error(candidate, _retry, stage, error, resolved) { - const spec = candidate.plan.spec - const cause = error instanceof Error ? (error.cause ?? error) : error - const message = stage === "load" ? errorMessage(error) : errorMessage(cause) - - if (stage === "install") { - const parsed = parsePluginSpecifier(spec) - log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message }) - publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`) - return - } - - if (stage === "compatibility") { - log.warn("plugin incompatible", { path: spec, error: message }) - publishPluginError(`Plugin ${spec} skipped: ${message}`) - return - } - - if (stage === "entry") { - log.error("failed to resolve plugin server entry", { path: spec, error: message }) - publishPluginError(`Failed to load plugin ${spec}: ${message}`) - return - } - - log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message }) - publishPluginError(`Failed to load plugin ${spec}: ${message}`) - }, - }, - }), - ) - for (const load of loaded) { - if (!load) continue - - // Keep plugin execution sequential so hook registration and execution - // order remains deterministic across plugin runs. - yield* Effect.tryPromise({ - try: () => applyPlugin(load, input, hooks, hooksWithMeta), - catch: (err) => { - const message = errorMessage(err) - log.error("failed to load plugin", { path: load.spec, error: message }) - return message - }, - }).pipe( - Effect.catch(() => { - // TODO: make proper events for this - // bus.publish(Session.Event.Error, { - // error: new NamedError.Unknown({ - // message: `Failed to load plugin ${load.spec}: ${message}`, - // }).toObject(), - // }) - return Effect.void - }), - ) - } - - // Notify plugins of current config - for (const hook of hooks) { - yield* Effect.tryPromise({ - try: () => Promise.resolve((hook as any).config?.(cfg)), - catch: (err) => { - log.error("plugin config hook failed", { error: err }) - }, - }).pipe(Effect.ignore) - } - - // Subscribe to bus events, fiber interrupted when scope closes - yield* bus.subscribeAll().pipe( - Stream.runForEach((input) => - Effect.sync(() => { - for (const hook of hooks) { - void hook["event"]?.({ event: input as any }) - } - }), - ), - Effect.forkScoped, - ) - - return { hooks, hooksWithMeta } - }), - ) - - const fileHookState = yield* InstanceState.make( - Effect.fn("Plugin.fileHooks")(function* () { - const hooks: Hooks[] = [] - const meta: HookEntry[] = [] - const files: Record = {} - yield* config.get() - const dirs = yield* config.directories() - - for (const dir of dirs) { - const matches = Glob.scanSync(FILE_HOOK_GLOB, { cwd: dir, absolute: true, dot: true, symlink: true }) - for (const match of matches) { - const stat = yield* Effect.tryPromise({ - try: () => fs.promises.stat(match), - catch: (err) => err, - }).pipe(Effect.catch(() => Effect.succeed(undefined))) - files[match] = stat?.mtimeMs ?? 0 - // Transpile and load the hook file. We use Bun.build to produce a - // temporary .js artifact, then dynamic-import that artifact. This - // avoids two pitfalls: (1) Bun's import() ignores query-string cache - // busters so re-imports return stale modules, (2) require() transpiles - // .ts in some contexts but not others (CI Linux edge case). - const mod = yield* Effect.tryPromise({ - try: async () => { - const result = await Bun.build({ - entrypoints: [match], - target: "bun", - format: "esm", - }) - if (!result.success) throw new Error(result.logs.map(String).join("\n")) - const blob = result.outputs[0] - const tmpFile = `${match}.${Date.now()}.mjs` - await Bun.write(tmpFile, blob) - try { - return await import(tmpFile) as Record - } finally { - fs.promises.unlink(tmpFile).catch(() => {}) - } - }, - catch: (err) => err, - }).pipe(Effect.catch((err) => { - log.error("failed to load file hook", { path: match, error: errorMessage(err) }) - return Effect.succeed(undefined) - })) - if (!mod) continue - const hookObj: Hooks = (mod.default ?? mod) as Hooks - if (hookObj && typeof hookObj === "object") { - const name = path.basename(match, path.extname(match)) - hooks.push(hookObj) - meta.push({ hook: hookObj, pluginName: `file:${name}`, hookIDFor: (event: string) => `file:${name}#${event}` }) - log.info("loaded file hook", { path: match, name }) - } - } - } - - // Dispatch bus events to file hooks' `event` handlers. Scoped to this - // cache entry: invalidation interrupts the fiber, and the rebuild - // re-subscribes with the fresh hook set. - if (hooks.some((hook) => typeof hook.event === "function")) { - yield* bus.subscribeAll().pipe( - Stream.runForEach((input) => - Effect.sync(() => { - for (const entry of meta) { - const fn = entry.hook.event - if (!fn) continue - try { - void Promise.resolve(fn({ event: input as any })).catch((err) => { - log.error("file hook event handler failed", { hook: entry.pluginName, error: errorMessage(err) }) - }) - } catch (err) { - log.error("file hook event handler failed", { hook: entry.pluginName, error: errorMessage(err) }) - } - } - }), - ), - Effect.forkScoped, - ) - } - - return { hooks, meta, dirs, files, lastCheck: { value: Date.now() } } - }), - ) - - // Staleness check: re-stat known hook files and re-glob hook dirs. Any - // mtime change, added, or removed file invalidates the cache so the next - // InstanceState.get rebuilds it. Covers ALL writers (editors, git, other - // processes) — not just this process's write/edit tools. Throttled to - // avoid stat storms on hot trigger paths. - const freshFileHooks = Effect.gen(function* () { - const fh = yield* InstanceState.get(fileHookState) - const now = Date.now() - if (now - fh.lastCheck.value < FILE_HOOK_CHECK_INTERVAL_MS) return fh - fh.lastCheck.value = now - - const stale = yield* Effect.promise(async () => { - const known = Object.keys(fh.files) - const seen = new Set() - for (const dir of fh.dirs) { - for (const match of Glob.scanSync(FILE_HOOK_GLOB, { cwd: dir, absolute: true, dot: true, symlink: true })) { - seen.add(match) - if (!(match in fh.files)) return true - } - } - for (const file of known) { - if (!seen.has(file)) return true - const stat = await fs.promises.stat(file).catch(() => undefined) - if ((stat?.mtimeMs ?? 0) !== fh.files[file]) return true - } - return false - }) - - if (!stale) return fh - log.info("file hooks changed on disk, reloading") - yield* InstanceState.invalidate(fileHookState) - return yield* InstanceState.get(fileHookState) - }) - - const aggregateDecision = ( - input: ActorPreStopInput | ActorPostStopInput, - eventName: "actor.preStop" | "actor.postStop", - ) => - Effect.gen(function* () { - const s = yield* InstanceState.get(state) - const fh = yield* freshFileHooks - const reasons: string[] = [] - const pluginNames: string[] = [] - const hookIDs: string[] = [] - let anyContinue = false - - for (const entry of [...s.hooksWithMeta, ...fh.meta]) { - const reg = entry.hook[eventName] - if (!reg) continue - - const fn = typeof reg === "function" ? reg : reg.run - const matcher: ActorMatcher | undefined = - typeof reg === "function" ? undefined : reg.matcher - - if (!matchesActor(matcher, input)) { - yield* bus.publish(HookEvent.Executed, { - event: eventName, - hookID: entry.hookIDFor(eventName), - pluginName: entry.pluginName, - actorID: input.actorID, - agentType: input.agentType, - durationMs: 0, - outcome: "skipped", - continueRequested: false, - reasonLength: 0, - }) - continue - } - - const startedAt = Date.now() - const o: ActorStopOutput = { continue: false } - let hookOutcome: "success" | "error" = "success" - // TODO: pass an AbortSignal to fn so plugin authors can wire cooperative - // cancellation into their fetch / DB calls. Effect interrupt only stops - // the awaiting fiber — the underlying Promise keeps running and may - // bus.publish events after the actor has been cleaned up. See spec - // Future work for full discussion. Strict in-process cancellation - // (子进程隔离) is out of scope; AbortSignal is the in-process ceiling. - yield* Effect.tryPromise({ - try: () => fn(input as never, o), - catch: (err) => err, - }).pipe( - Effect.tapError((err) => - Effect.gen(function* () { - hookOutcome = "error" - log.error(`${eventName} hook failed`, { pluginName: entry.pluginName, hookID: entry.hookIDFor(eventName), error: err }) - yield* bus.publish(Session.Event.Error, { - sessionID: input.sessionID as SessionID, - error: new NamedError.Unknown({ - message: `${eventName} hook (${entry.pluginName}) failed: ${errorMessage(err)}`, - }).toObject(), - }) - }), - ), - Effect.ignore, - ) - - const durationMs = Date.now() - startedAt - yield* bus.publish(HookEvent.Executed, { - event: eventName, - hookID: entry.hookIDFor(eventName), - pluginName: entry.pluginName, - actorID: input.actorID, - agentType: input.agentType, - durationMs, - outcome: hookOutcome, - continueRequested: o.continue === true, - reasonLength: o.reason?.length ?? 0, - }) - - if (o.continue === true && o.reason && o.reason.length > 0) { - anyContinue = true - reasons.push(o.reason) - pluginNames.push(entry.pluginName) - hookIDs.push(entry.hookIDFor(eventName)) - } else if (o.continue === true) { - log.warn(`${eventName} hook returned continue=true without reason; ignored`, { - pluginName: entry.pluginName, - }) - } - } - - const aggregated: ActorStopAggregatedDecision = { - continue: anyContinue, - reason: reasons.length > 0 ? reasons.join("\n\n") : undefined, - contributingPluginNames: pluginNames, - contributingHookIDs: hookIDs, - } - return aggregated - }) - - const triggerActorPreStop = Effect.fn("Plugin.triggerActorPreStop")(function* ( - input: ActorPreStopInput, - ) { - return yield* aggregateDecision(input, "actor.preStop") - }) - - const triggerActorPostStop = Effect.fn("Plugin.triggerActorPostStop")(function* ( - input: ActorPostStopInput, - ) { - return yield* aggregateDecision(input, "actor.postStop") - }) - - const HOOK_TIMEOUT_MS = 5000 - const CIRCUIT_BREAKER_THRESHOLD = 3 - const hookFailures = new Map() - - const trigger = Effect.fn("Plugin.trigger")(function* < - Name extends TriggerName, - Input = Parameters[Name]>[0], - Output = Parameters[Name]>[1], - >(name: Name, input: Input, output: Output) { - if (!name) return output - const s = yield* InstanceState.get(state) - const fh = yield* freshFileHooks - - for (const entry of s.hooksWithMeta) { - const fn = entry.hook[name] as any - if (!fn) continue - yield* Effect.promise(async () => fn(input, output)) - } - - for (const entry of fh.meta) { - const fn = entry.hook[name] as any - if (!fn) continue - const hookID = entry.hookIDFor(name) - - if ((hookFailures.get(hookID) ?? 0) >= CIRCUIT_BREAKER_THRESHOLD) { - log.warn("hook circuit-breaker open, skipping", { hook: hookID }) - continue - } - - const snapshot = structuredClone(output) - const failed = yield* Effect.tryPromise({ - try: async () => { - await Promise.race([ - Promise.resolve(fn(input, output)), - new Promise((_, reject) => - setTimeout(() => reject(new Error(`hook timed out after ${HOOK_TIMEOUT_MS}ms`)), HOOK_TIMEOUT_MS), - ), - ]) - }, - catch: (err) => err, - }).pipe( - Effect.map(() => false), - Effect.catch((err) => { - Object.assign(output as any, snapshot) - const count = (hookFailures.get(hookID) ?? 0) + 1 - hookFailures.set(hookID, count) - log.error("file hook failed, output rolled back", { - hook: hookID, - event: name, - error: errorMessage(err), - consecutiveFailures: count, - circuitOpen: count >= CIRCUIT_BREAKER_THRESHOLD, - }) - return Effect.succeed(true) - }), - ) - if (!failed) hookFailures.delete(hookID) - } - return output - }) - - const list = Effect.fn("Plugin.list")(function* () { - const s = yield* InstanceState.get(state) - return s.hooks - }) - - const init = Effect.fn("Plugin.init")(function* () { - yield* InstanceState.get(state) - yield* InstanceState.get(fileHookState) - }) - - const reloadFileHooks: Interface["reloadFileHooks"] = Effect.fn("Plugin.reloadFileHooks")(function* () { - yield* InstanceState.invalidate(fileHookState) - }) - - return Service.of({ trigger, list, init, reloadFileHooks, triggerActorPreStop, triggerActorPostStop }) - }), -) - -export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer)) - -export * as Plugin from "." +import type { + Hooks, + PluginInput, + Plugin as PluginInstance, + PluginModule, + WorkspaceAdaptor as PluginWorkspaceAdaptor, + ActorPreStopInput, + ActorPostStopInput, + ActorStopOutput, + ActorMatcher, +} from "@mimo-ai/plugin" +import { z } from "zod" +import { matchesActor } from "./matcher" +import { Config } from "../config" +import { Bus } from "../bus" +import { BusEvent } from "../bus/bus-event" +import { Log } from "../util" +import { createOpencodeClient } from "@mimo-ai/sdk" +import { Flag } from "../flag/flag" +import { CodexAuthPlugin } from "./codex" +import { XaiAuthPlugin } from "./xai" +import { MimoAuthPlugin, AnthropicProxyPlugin } from "./mimo" +import { Session } from "../session" +import type { SessionID } from "../session/schema" +import { NamedError } from "@mimo-ai/shared/util/error" +import { CopilotAuthPlugin } from "./github-copilot/copilot" +import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" +import { PoeAuthPlugin } from "opencode-poe-auth" +import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" +import { CheckpointSplitoverPlugin } from "./checkpoint-splitover" +import { SubagentProgressCheckerPlugin } from "./subagent-progress-checker" +import { Effect, Layer, Context, Stream } from "effect" +import { EffectBridge } from "@/effect" +import { InstanceState } from "@/effect" +import { errorMessage } from "@/util/error" +import { PluginLoader } from "./loader" +import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" +import { registerAdaptor } from "@/control-plane/adaptors" +import type { WorkspaceAdaptor } from "@/control-plane/types" +import { Glob } from "@mimo-ai/shared/util/glob" +import fs from "fs" +import path from "path" +import { pathToFileURL, fileURLToPath } from "url" + +const log = Log.create({ service: "plugin" }) + +export const HookEvent = { + Executed: BusEvent.define( + "hook.executed", + z.object({ + event: z.enum(["actor.preStop", "actor.postStop"]), + hookID: z.string(), + pluginName: z.string(), + actorID: z.string(), + agentType: z.string(), + durationMs: z.number(), + outcome: z.enum(["success", "error", "skipped"]), + continueRequested: z.boolean(), + reasonLength: z.number(), + }), + ), + ReActReentered: BusEvent.define( + "hook.react.reentered", + z.object({ + phase: z.enum(["pre", "post"]), + actorID: z.string(), + agentType: z.string(), + iteration: z.number(), + triggeredByPlugins: z.array(z.string()), + reasonPreview: z.string(), + }), + ), + ReActMaxReached: BusEvent.define( + "hook.react.max_reached", + z.object({ + phase: z.enum(["pre", "post"]), + actorID: z.string(), + agentType: z.string(), + }), + ), +} as const + +type HookEntry = { + hook: Hooks + pluginName: string + /** Stable per-event hook ID: `${pluginName}#${eventName}` */ + hookIDFor: (eventName: string) => string +} + +type State = { + hooks: Hooks[] + hooksWithMeta: HookEntry[] +} + +type FileHookState = { + hooks: Hooks[] + meta: HookEntry[] + dirs: string[] + /** Absolute path -> mtimeMs at load time, for cheap staleness checks. */ + files: Record + /** Mutable box: last staleness check timestamp (throttle). */ + lastCheck: { value: number } +} + +const FILE_HOOK_GLOB = "{hook,hooks}/*.{js,ts}" +const FILE_HOOK_CHECK_INTERVAL_MS = 500 + +export type ActorStopAggregatedDecision = ActorStopOutput & { + contributingPluginNames: string[] + contributingHookIDs: string[] +} + +// Hook names that follow the (input, output) => Promise trigger pattern +type TriggerName = { + [K in keyof Hooks]-?: NonNullable extends (input: any, output: any) => Promise ? K : never +}[keyof Hooks] + +export interface Interface { + readonly trigger: < + Name extends TriggerName, + Input = Parameters[Name]>[0], + Output = Parameters[Name]>[1], + >( + name: Name, + input: Input, + output: Output, + ) => Effect.Effect + readonly list: () => Effect.Effect + readonly init: () => Effect.Effect + readonly reloadFileHooks: () => Effect.Effect + readonly triggerActorPreStop: (input: ActorPreStopInput) => Effect.Effect + readonly triggerActorPostStop: (input: ActorPostStopInput) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Plugin") {} + +// Built-in plugins that are directly imported (not installed from npm) +const INTERNAL_PLUGINS: PluginInstance[] = [ + MimoAuthPlugin, + AnthropicProxyPlugin, + CodexAuthPlugin, + XaiAuthPlugin, + CopilotAuthPlugin, + // gitlab/poe auth are external npm packages typed against the published + // upstream plugin package, which carries a duplicate (nominal) copy of the + // SDK client; cast through unknown to the workspace Plugin type. + GitlabAuthPlugin as unknown as PluginInstance, + PoeAuthPlugin as unknown as PluginInstance, + CloudflareWorkersAuthPlugin, + CloudflareAIGatewayAuthPlugin, + CheckpointSplitoverPlugin, + SubagentProgressCheckerPlugin, +] + +function isServerPlugin(value: unknown): value is PluginInstance { + return typeof value === "function" +} + +function getServerPlugin(value: unknown) { + if (isServerPlugin(value)) return value + if (!value || typeof value !== "object" || !("server" in value)) return + if (!isServerPlugin(value.server)) return + return value.server +} + +function getLegacyPlugins(mod: Record) { + const seen = new Set() + const result: PluginInstance[] = [] + + for (const entry of Object.values(mod)) { + if (seen.has(entry)) continue + seen.add(entry) + const plugin = getServerPlugin(entry) + if (!plugin) continue + result.push(plugin) + } + + return result +} + +async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[], hooksWithMeta: HookEntry[]) { + const plugin = readV1Plugin(load.mod, load.spec, "server", "detect") + if (plugin) { + await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg) + const pluginName = readPluginId(plugin.id, load.spec) ?? load.pkg?.pkg ?? load.spec + const hookObj = await (plugin as PluginModule).server(input, load.options) + hooks.push(hookObj) + hooksWithMeta.push({ + hook: hookObj, + pluginName, + hookIDFor: (event: string) => `${pluginName}#${event}`, + }) + return + } + + for (const server of getLegacyPlugins(load.mod)) { + const fnName = (server as { name?: string }).name + const pluginName = fnName && fnName !== "default" && fnName !== "" ? fnName : (load.pkg?.pkg ?? load.spec) + const hookObj = await server(input, load.options) + hooks.push(hookObj) + hooksWithMeta.push({ + hook: hookObj, + pluginName, + hookIDFor: (event: string) => `${pluginName}#${event}`, + }) + } +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const bus = yield* Bus.Service + const config = yield* Config.Service + + const state = yield* InstanceState.make( + Effect.fn("Plugin.state")(function* (ctx) { + const hooks: Hooks[] = [] + const hooksWithMeta: HookEntry[] = [] + const bridge = yield* EffectBridge.make() + + function publishPluginError(message: string) { + bridge.fork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })) + } + + const { Server } = yield* Effect.promise(() => import("../server/server")) + + const client = createOpencodeClient({ + baseUrl: "http://localhost:4096", + directory: ctx.directory, + headers: Flag.MIMOCODE_SERVER_PASSWORD + ? { + Authorization: `Basic ${Buffer.from(`${Flag.MIMOCODE_SERVER_USERNAME ?? "mimocode"}:${Flag.MIMOCODE_SERVER_PASSWORD}`).toString("base64")}`, + } + : undefined, + fetch: async (...args) => (await Server.Default()).app.fetch(...args), + }) + const cfg = yield* config.get() + const input: PluginInput = { + client, + project: ctx.project, + worktree: ctx.worktree, + directory: ctx.directory, + experimental_workspace: { + register(type: string, adaptor: PluginWorkspaceAdaptor) { + registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor) + }, + }, + get serverUrl(): URL { + return Server.url ?? new URL("http://localhost:4096") + }, + // @ts-expect-error + $: typeof Bun === "undefined" ? undefined : Bun.$, + } + + for (const plugin of INTERNAL_PLUGINS) { + log.info("loading internal plugin", { name: plugin.name }) + const init = yield* Effect.tryPromise({ + try: () => plugin(input), + catch: (err) => { + log.error("failed to load internal plugin", { name: plugin.name, error: err }) + }, + }).pipe(Effect.option) + if (init._tag === "Some") { + hooks.push(init.value) + hooksWithMeta.push({ + hook: init.value, + pluginName: plugin.name, + hookIDFor: (event: string) => `${plugin.name}#${event}`, + }) + } + } + + // Load optional local extensions under src/ext/. Prefers the generated + // _manifest.ts (a fixed import specifier resolves inside Bun single-file + // executables, where filesystem scans do not); falls back to a directory + // scan for unbundled runs. Each *Plugin-named export is registered. + const extModules: Record> = {} + // @ts-ignore generated manifest; may not exist at type-check time + const manifest = yield* Effect.tryPromise(() => import("../ext/_manifest")).pipe(Effect.option) + if (manifest._tag === "Some") { + Object.assign( + extModules, + (manifest.value as { modules?: Record> }).modules ?? {}, + ) + } else { + const extDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "ext") + const extFiles = fs.existsSync(extDir) + ? fs.readdirSync(extDir).filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts") && f !== "_manifest.ts") + : [] + for (const entry of extFiles) { + const mod = yield* Effect.tryPromise({ + try: () => import(/* @vite-ignore */ pathToFileURL(path.join(extDir, entry)).href), + catch: (err) => log.error("failed to import extension", { name: entry, error: err }), + }).pipe(Effect.option) + if (mod._tag === "Some") extModules[entry.replace(/\.ts$/, "")] = mod.value as Record + } + } + for (const [name, value] of Object.entries(extModules)) { + // Only treat *Plugin-named function exports as plugins. Other modules + // (e.g. a CLI helper export) are not plugins and must not be invoked + // as plugin factories. + const overlay = Object.entries(value).find( + ([exportName, v]) => typeof v === "function" && exportName.endsWith("Plugin"), + )?.[1] as PluginInstance | undefined + if (!overlay) continue + log.info("loading extension", { name }) + const init = yield* Effect.tryPromise({ + try: () => overlay(input), + catch: (err) => log.error("failed to load extension", { name, error: err }), + }).pipe(Effect.option) + if (init._tag === "Some") { + hooks.push(init.value) + hooksWithMeta.push({ + hook: init.value, + pluginName: name, + hookIDFor: (event: string) => `${name}#${event}`, + }) + } + } + + const plugins = Flag.MIMOCODE_PURE ? [] : (cfg.plugin_origins ?? []) + if (Flag.MIMOCODE_PURE && cfg.plugin_origins?.length) { + log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length }) + } + if (plugins.length) yield* config.waitForDependencies() + + const loaded = yield* Effect.promise(() => + PluginLoader.loadExternal({ + items: plugins, + kind: "server", + report: { + start(candidate) { + log.info("loading plugin", { path: candidate.plan.spec }) + }, + missing(candidate, _retry, message) { + log.warn("plugin has no server entrypoint", { path: candidate.plan.spec, message }) + }, + error(candidate, _retry, stage, error, resolved) { + const spec = candidate.plan.spec + const cause = error instanceof Error ? (error.cause ?? error) : error + const message = stage === "load" ? errorMessage(error) : errorMessage(cause) + + if (stage === "install") { + const parsed = parsePluginSpecifier(spec) + log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message }) + publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`) + return + } + + if (stage === "compatibility") { + log.warn("plugin incompatible", { path: spec, error: message }) + publishPluginError(`Plugin ${spec} skipped: ${message}`) + return + } + + if (stage === "entry") { + log.error("failed to resolve plugin server entry", { path: spec, error: message }) + publishPluginError(`Failed to load plugin ${spec}: ${message}`) + return + } + + log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message }) + publishPluginError(`Failed to load plugin ${spec}: ${message}`) + }, + }, + }), + ) + for (const load of loaded) { + if (!load) continue + + // Keep plugin execution sequential so hook registration and execution + // order remains deterministic across plugin runs. + yield* Effect.tryPromise({ + try: () => applyPlugin(load, input, hooks, hooksWithMeta), + catch: (err) => { + const message = errorMessage(err) + log.error("failed to load plugin", { path: load.spec, error: message }) + return message + }, + }).pipe( + Effect.catch(() => { + // TODO: make proper events for this + // bus.publish(Session.Event.Error, { + // error: new NamedError.Unknown({ + // message: `Failed to load plugin ${load.spec}: ${message}`, + // }).toObject(), + // }) + return Effect.void + }), + ) + } + + // Notify plugins of current config + for (const hook of hooks) { + yield* Effect.tryPromise({ + try: () => Promise.resolve((hook as any).config?.(cfg)), + catch: (err) => { + log.error("plugin config hook failed", { error: err }) + }, + }).pipe(Effect.ignore) + } + + // Subscribe to bus events, fiber interrupted when scope closes + yield* bus.subscribeAll().pipe( + Stream.runForEach((input) => + Effect.sync(() => { + for (const hook of hooks) { + void hook["event"]?.({ event: input as any }) + } + }), + ), + Effect.forkScoped, + ) + + return { hooks, hooksWithMeta } + }), + ) + + const fileHookState = yield* InstanceState.make( + Effect.fn("Plugin.fileHooks")(function* () { + const hooks: Hooks[] = [] + const meta: HookEntry[] = [] + const files: Record = {} + yield* config.get() + const dirs = yield* config.directories() + + for (const dir of dirs) { + const matches = Glob.scanSync(FILE_HOOK_GLOB, { cwd: dir, absolute: true, dot: true, symlink: true }) + for (const match of matches) { + const stat = yield* Effect.tryPromise({ + try: () => fs.promises.stat(match), + catch: (err) => err, + }).pipe(Effect.catch(() => Effect.succeed(undefined))) + files[match] = stat?.mtimeMs ?? 0 + // Transpile and load the hook file. We use Bun.build to produce a + // temporary .js artifact, then dynamic-import that artifact. This + // avoids two pitfalls: (1) Bun's import() ignores query-string cache + // busters so re-imports return stale modules, (2) require() transpiles + // .ts in some contexts but not others (CI Linux edge case). + const mod = yield* Effect.tryPromise({ + try: async () => { + const result = await Bun.build({ + entrypoints: [match], + target: "bun", + format: "esm", + }) + if (!result.success) throw new Error(result.logs.map(String).join("\n")) + const blob = result.outputs[0] + const tmpFile = `${match}.${Date.now()}.mjs` + await Bun.write(tmpFile, blob) + try { + return (await import(tmpFile)) as Record + } finally { + fs.promises.unlink(tmpFile).catch(() => {}) + } + }, + catch: (err) => err, + }).pipe( + Effect.catch((err) => { + log.error("failed to load file hook", { path: match, error: errorMessage(err) }) + return Effect.succeed(undefined) + }), + ) + if (!mod) continue + const hookObj: Hooks = (mod.default ?? mod) as Hooks + if (hookObj && typeof hookObj === "object") { + const name = path.basename(match, path.extname(match)) + hooks.push(hookObj) + meta.push({ + hook: hookObj, + pluginName: `file:${name}`, + hookIDFor: (event: string) => `file:${name}#${event}`, + }) + log.info("loaded file hook", { path: match, name }) + } + } + } + + // Dispatch bus events to file hooks' `event` handlers. Scoped to this + // cache entry: invalidation interrupts the fiber, and the rebuild + // re-subscribes with the fresh hook set. + if (hooks.some((hook) => typeof hook.event === "function")) { + yield* bus.subscribeAll().pipe( + Stream.runForEach((input) => + Effect.sync(() => { + for (const entry of meta) { + const fn = entry.hook.event + if (!fn) continue + try { + void Promise.resolve(fn({ event: input as any })).catch((err) => { + log.error("file hook event handler failed", { hook: entry.pluginName, error: errorMessage(err) }) + }) + } catch (err) { + log.error("file hook event handler failed", { hook: entry.pluginName, error: errorMessage(err) }) + } + } + }), + ), + Effect.forkScoped, + ) + } + + return { hooks, meta, dirs, files, lastCheck: { value: Date.now() } } + }), + ) + + // Staleness check: re-stat known hook files and re-glob hook dirs. Any + // mtime change, added, or removed file invalidates the cache so the next + // InstanceState.get rebuilds it. Covers ALL writers (editors, git, other + // processes) — not just this process's write/edit tools. Throttled to + // avoid stat storms on hot trigger paths. + const freshFileHooks = Effect.gen(function* () { + const fh = yield* InstanceState.get(fileHookState) + const now = Date.now() + if (now - fh.lastCheck.value < FILE_HOOK_CHECK_INTERVAL_MS) return fh + fh.lastCheck.value = now + + const stale = yield* Effect.promise(async () => { + const known = Object.keys(fh.files) + const seen = new Set() + for (const dir of fh.dirs) { + for (const match of Glob.scanSync(FILE_HOOK_GLOB, { cwd: dir, absolute: true, dot: true, symlink: true })) { + seen.add(match) + if (!(match in fh.files)) return true + } + } + for (const file of known) { + if (!seen.has(file)) return true + const stat = await fs.promises.stat(file).catch(() => undefined) + if ((stat?.mtimeMs ?? 0) !== fh.files[file]) return true + } + return false + }) + + if (!stale) return fh + log.info("file hooks changed on disk, reloading") + yield* InstanceState.invalidate(fileHookState) + return yield* InstanceState.get(fileHookState) + }) + + const aggregateDecision = ( + input: ActorPreStopInput | ActorPostStopInput, + eventName: "actor.preStop" | "actor.postStop", + ) => + Effect.gen(function* () { + const s = yield* InstanceState.get(state) + const fh = yield* freshFileHooks + const reasons: string[] = [] + const pluginNames: string[] = [] + const hookIDs: string[] = [] + let anyContinue = false + + for (const entry of [...s.hooksWithMeta, ...fh.meta]) { + const reg = entry.hook[eventName] + if (!reg) continue + + const fn = typeof reg === "function" ? reg : reg.run + const matcher: ActorMatcher | undefined = typeof reg === "function" ? undefined : reg.matcher + + if (!matchesActor(matcher, input)) { + yield* bus.publish(HookEvent.Executed, { + event: eventName, + hookID: entry.hookIDFor(eventName), + pluginName: entry.pluginName, + actorID: input.actorID, + agentType: input.agentType, + durationMs: 0, + outcome: "skipped", + continueRequested: false, + reasonLength: 0, + }) + continue + } + + const startedAt = Date.now() + const o: ActorStopOutput = { continue: false } + let hookOutcome: "success" | "error" = "success" + // TODO: pass an AbortSignal to fn so plugin authors can wire cooperative + // cancellation into their fetch / DB calls. Effect interrupt only stops + // the awaiting fiber — the underlying Promise keeps running and may + // bus.publish events after the actor has been cleaned up. See spec + // Future work for full discussion. Strict in-process cancellation + // (子进程隔离) is out of scope; AbortSignal is the in-process ceiling. + yield* Effect.tryPromise({ + try: () => fn(input as never, o), + catch: (err) => err, + }).pipe( + Effect.tapError((err) => + Effect.gen(function* () { + hookOutcome = "error" + log.error(`${eventName} hook failed`, { + pluginName: entry.pluginName, + hookID: entry.hookIDFor(eventName), + error: err, + }) + yield* bus.publish(Session.Event.Error, { + sessionID: input.sessionID as SessionID, + error: new NamedError.Unknown({ + message: `${eventName} hook (${entry.pluginName}) failed: ${errorMessage(err)}`, + }).toObject(), + }) + }), + ), + Effect.ignore, + ) + + const durationMs = Date.now() - startedAt + yield* bus.publish(HookEvent.Executed, { + event: eventName, + hookID: entry.hookIDFor(eventName), + pluginName: entry.pluginName, + actorID: input.actorID, + agentType: input.agentType, + durationMs, + outcome: hookOutcome, + continueRequested: o.continue === true, + reasonLength: o.reason?.length ?? 0, + }) + + if (o.continue === true && o.reason && o.reason.length > 0) { + anyContinue = true + reasons.push(o.reason) + pluginNames.push(entry.pluginName) + hookIDs.push(entry.hookIDFor(eventName)) + } else if (o.continue === true) { + log.warn(`${eventName} hook returned continue=true without reason; ignored`, { + pluginName: entry.pluginName, + }) + } + } + + const aggregated: ActorStopAggregatedDecision = { + continue: anyContinue, + reason: reasons.length > 0 ? reasons.join("\n\n") : undefined, + contributingPluginNames: pluginNames, + contributingHookIDs: hookIDs, + } + return aggregated + }) + + const triggerActorPreStop = Effect.fn("Plugin.triggerActorPreStop")(function* (input: ActorPreStopInput) { + return yield* aggregateDecision(input, "actor.preStop") + }) + + const triggerActorPostStop = Effect.fn("Plugin.triggerActorPostStop")(function* (input: ActorPostStopInput) { + return yield* aggregateDecision(input, "actor.postStop") + }) + + const HOOK_TIMEOUT_MS = 5000 + const CIRCUIT_BREAKER_THRESHOLD = 3 + const hookFailures = new Map() + + const trigger = Effect.fn("Plugin.trigger")(function* < + Name extends TriggerName, + Input = Parameters[Name]>[0], + Output = Parameters[Name]>[1], + >(name: Name, input: Input, output: Output) { + if (!name) return output + const s = yield* InstanceState.get(state) + const fh = yield* freshFileHooks + + for (const entry of s.hooksWithMeta) { + const fn = entry.hook[name] as any + if (!fn) continue + yield* Effect.promise(async () => fn(input, output)) + } + + for (const entry of fh.meta) { + const fn = entry.hook[name] as any + if (!fn) continue + const hookID = entry.hookIDFor(name) + + if ((hookFailures.get(hookID) ?? 0) >= CIRCUIT_BREAKER_THRESHOLD) { + log.warn("hook circuit-breaker open, skipping", { hook: hookID }) + continue + } + + const snapshot = structuredClone(output) + const failed = yield* Effect.tryPromise({ + try: async () => { + await Promise.race([ + Promise.resolve(fn(input, output)), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`hook timed out after ${HOOK_TIMEOUT_MS}ms`)), HOOK_TIMEOUT_MS), + ), + ]) + }, + catch: (err) => err, + }).pipe( + Effect.map(() => false), + Effect.catch((err) => { + Object.assign(output as any, snapshot) + const count = (hookFailures.get(hookID) ?? 0) + 1 + hookFailures.set(hookID, count) + log.error("file hook failed, output rolled back", { + hook: hookID, + event: name, + error: errorMessage(err), + consecutiveFailures: count, + circuitOpen: count >= CIRCUIT_BREAKER_THRESHOLD, + }) + return Effect.succeed(true) + }), + ) + if (!failed) hookFailures.delete(hookID) + } + return output + }) + + const list = Effect.fn("Plugin.list")(function* () { + const s = yield* InstanceState.get(state) + return s.hooks + }) + + const init = Effect.fn("Plugin.init")(function* () { + yield* InstanceState.get(state) + yield* InstanceState.get(fileHookState) + }) + + const reloadFileHooks: Interface["reloadFileHooks"] = Effect.fn("Plugin.reloadFileHooks")(function* () { + yield* InstanceState.invalidate(fileHookState) + }) + + return Service.of({ trigger, list, init, reloadFileHooks, triggerActorPreStop, triggerActorPostStop }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer)) + +export * as Plugin from "." From 70f0eb6859b6e35306c30977b3d8535b5eef1ac4 Mon Sep 17 00:00:00 2001 From: Murat Date: Mon, 27 Jul 2026 15:41:04 +0200 Subject: [PATCH 014/135] ci: comprehensive CI/CD pipeline with multi-language support Replaces separate lint.yml, test.yml, typecheck.yml with unified ci.yml: - typecheck: bun typecheck - lint: oxlint + prettier check - test: sharded unit tests (4 shards) - security: skylos scan on plugin directory - quality: repowise health on plugin directory - python: auto-detects pyproject.toml, runs ruff/mypy/pytest - rust: auto-detects Cargo.toml, runs cargo check/test - go: auto-detects go.mod, runs go vet/test Multi-language: Python, Rust, Go jobs auto-detect and skip if no files found. --- .github/workflows/ci.yml | 173 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..eafc262c1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,173 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CI: true + +jobs: + typecheck: + name: Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-bun + - name: Run typecheck + run: bun typecheck + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-bun + - name: Run oxlint + run: bun lint + - name: Check formatting + run: npx prettier --check "packages/opencode/src/**/*.ts" "packages/opencode/test/**/*.ts" + + test: + name: Test (shard ${{ matrix.shard }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: ["1/4", "2/4", "3/4", "4/4"] + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-bun + - name: Configure git identity + run: | + git config --global user.email "ci@mimo.ai" + git config --global user.name "mimo-ci" + - name: Run unit tests (shard ${{ matrix.shard }}) + timeout-minutes: 8 + working-directory: packages/opencode + run: bun run test:ci --shard ${{ matrix.shard }} + - name: Upload JUnit + if: always() + uses: actions/upload-artifact@v7 + with: + name: junit-shard-${{ strategy.job-index }} + path: packages/opencode/.artifacts/unit/junit.xml + + security: + name: Security Scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-bun + - name: Install skylos + run: pip install skylos + - name: Run skylos security scan + run: skylos suite packages/opencode/src/plugin --json || true + - name: Upload results + if: always() + uses: actions/upload-artifact@v7 + with: + name: skylos-results + path: packages/opencode/.artifacts/security/ + + quality: + name: Code Quality + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-bun + - name: Install repowise + run: pip install repowise + - name: Run repowise health + run: repowise health packages/opencode/src/plugin --json || true + - name: Upload results + if: always() + uses: actions/upload-artifact@v7 + with: + name: repowise-results + path: packages/opencode/.artifacts/quality/ + + # Multi-language support: detect and test additional languages + python: + name: Python (if present) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Check for Python files + id: check + run: | + if find . -name "pyproject.toml" -o -name "setup.py" -o -name "requirements.txt" | grep -v node_modules | head -1; then + echo "found=true" >> $GITHUB_OUTPUT + fi + - name: Setup Python + if: steps.check.outputs.found == 'true' + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install Python deps + if: steps.check.outputs.found == 'true' + run: | + pip install ruff mypy pytest + - name: Run ruff + if: steps.check.outputs.found == 'true' + run: ruff check . + - name: Run mypy + if: steps.check.outputs.found == 'true' + run: mypy . --ignore-missing-imports + - name: Run pytest + if: steps.check.outputs.found == 'true' + run: python -m pytest tests/ -x --timeout=60 || true + + rust: + name: Rust (if present) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Check for Cargo.toml + id: check + run: | + if find . -name "Cargo.toml" | grep -v node_modules | head -1; then + echo "found=true" >> $GITHUB_OUTPUT + fi + - name: Setup Rust + if: steps.check.outputs.found == 'true' + uses: dtolnay/rust-toolchain@stable + - name: Cargo check + if: steps.check.outputs.found == 'true' + run: cargo check + - name: Cargo test + if: steps.check.outputs.found == 'true' + run: cargo test --no-fail-fast || true + + go: + name: Go (if present) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Check for go.mod + id: check + run: | + if find . -name "go.mod" | grep -v node_modules | head -1; then + echo "found=true" >> $GITHUB_OUTPUT + fi + - name: Setup Go + if: steps.check.outputs.found == 'true' + uses: actions/setup-go@v5 + with: + go-version: '1.22' + - name: Go vet + if: steps.check.outputs.found == 'true' + run: go vet ./... + - name: Go test + if: steps.check.outputs.found == 'true' + run: go test ./... || true From b890aca6de060f5c4dbb33793cdc5476d6c9ea6d Mon Sep 17 00:00:00 2001 From: Murat Date: Mon, 27 Jul 2026 15:45:47 +0200 Subject: [PATCH 015/135] ci: remove old workflows (replaced by ci.yml) --- .github/workflows/lint.yml | 21 --------------- .github/workflows/test.yml | 47 --------------------------------- .github/workflows/typecheck.yml | 21 --------------- 3 files changed, 89 deletions(-) delete mode 100644 .github/workflows/lint.yml delete mode 100644 .github/workflows/test.yml delete mode 100644 .github/workflows/typecheck.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index e68c4803c..000000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: lint - -on: - push: - branches: [main, dev] - pull_request: - branches: [main, dev] - workflow_dispatch: - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Setup Bun - uses: ./.github/actions/setup-bun - - - name: Run oxlint - run: bun lint diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index c601b0d60..000000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: test - -on: - push: - branches: [main, dev] - pull_request: - branches: [main, dev] - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - unit: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - shard: ["1/4", "2/4", "3/4", "4/4"] - name: unit (shard ${{ matrix.shard }}) - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Setup Bun - uses: ./.github/actions/setup-bun - - - name: Configure git identity - run: | - git config --global user.email "ci@mimo.ai" - git config --global user.name "mimo-ci" - - - name: Run unit tests (shard ${{ matrix.shard }}) - timeout-minutes: 8 - working-directory: packages/opencode - run: bun run test:ci --shard ${{ matrix.shard }} - - - name: Upload JUnit - if: always() - uses: actions/upload-artifact@v7 - with: - name: junit-shard-${{ strategy.job-index }} - path: packages/opencode/.artifacts/unit/junit.xml diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml deleted file mode 100644 index 903ca0eba..000000000 --- a/.github/workflows/typecheck.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: typecheck - -on: - push: - branches: [main, dev] - pull_request: - branches: [main, dev] - workflow_dispatch: - -jobs: - typecheck: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Setup Bun - uses: ./.github/actions/setup-bun - - - name: Run typecheck - run: bun typecheck From bd0be88e9520ba067a6b9a7d6183188ba9e94ed6 Mon Sep 17 00:00:00 2001 From: wqymi Date: Mon, 27 Jul 2026 21:46:12 +0800 Subject: [PATCH 016/135] docs(actor): move the run-exception note off the JSON example line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other example in actor.txt is clean, copy-pasteable JSON. The run example carried a trailing prose annotation on the same line, and the model imitates the shape of these examples line-by-line rather than the parsed JSON — so the arrow could be copied verbatim into a real call. Move the note to its own line above, matching the prose convention the EXCEPTION example block further down already uses. --- packages/opencode/src/tool/actor.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/actor.txt b/packages/opencode/src/tool/actor.txt index 3f361d82a..db9609ffc 100644 --- a/packages/opencode/src/tool/actor.txt +++ b/packages/opencode/src/tool/actor.txt @@ -14,7 +14,8 @@ Examples: {"operation":{"action":"wait","actor_id":""}} {"operation":{"action":"cancel","actor_id":""}} {"operation":{"action":"send","to_actor_id":"","content":""}} -{"operation":{"action":"run","subagent_type":"explore","description":"One tiny blocking lookup","prompt":""}} ← exception only +THE EXCEPTION — `run` blocks the conversation; only for a tiny lookup that gates this very turn: +{"operation":{"action":"run","subagent_type":"explore","description":"One tiny blocking lookup","prompt":""}} ## Operations (the `operation.action` field selects one) From e56ecb0311c8874ea36562892eac86246239a051 Mon Sep 17 00:00:00 2001 From: wqymi Date: Mon, 27 Jul 2026 22:29:44 +0800 Subject: [PATCH 017/135] fix(checkpoint): book the writer's real outcome when the wait bound expires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #1938. That PR stopped a merely-slow writer from being booked as a failure by returning a distinct "timeout" from waitForWriter, which prune's `result !== "failure"` guard skips. Correct, but it left two holes. 1. Hitting the bound became completely silent. waitForWriter returned and prune's watcher returned, neither logging — yet the +300s log line is exactly the evidence #1938 was diagnosed from. waitForWriter now logs "checkpoint writer wait bound expired — writer still in flight". 2. The real outcome was booked nowhere. prune's watcher fiber is the only holder of the per-fire accounting and writerFailures is private to the prune layer, so once the watcher returned on "timeout" a writer that genuinely FAILED past 300s ticked no counter — making MAX_WRITER_FAILURES unreachable for exactly the slow regime #1938 is about, so a permanently-broken-but-slow writer retried forever with no give-up warning. Symmetrically, a writer that SUCCEEDED past 300s never cleared a counter left at 1-2 by earlier fast failures, so a later fast failure could trip "gave up" for a session whose writers demonstrably work. The watcher now extends its wait across bound expiries and accounts for the settled result, capped at MAX_WRITER_WAIT_EXTENSIONS (~1h) so a writer that never settles cannot pin the fiber for the life of the process. Two microsecond-wide re-entry windows are documented rather than papered over. Tests: prune.test.ts gains the two cases that pin the prune-side consequence #1938 is actually about (timeouts never tick the counter and a late success clears it; a late failure is still counted so the cap stays reachable) — the existing test only asserted waitForWriter's return value. The timeout test now also pins the BOUND (still pending at 4 minutes, so shrinking it to 1s fails) and asserts the writer is still running after the expiry, replacing a dead `not.toBe("failure")` implied by the line above it. Also folds the stale 5-min-padding comment into the current block and corrects checkpoint-align.ts's now-conditional claim about writerFailures. --- .../opencode/src/session/checkpoint-align.ts | 7 +- packages/opencode/src/session/checkpoint.ts | 29 ++++--- packages/opencode/src/session/prune.ts | 48 +++++++++-- .../checkpoint-writer-wait-timeout.test.ts | 21 +++-- packages/opencode/test/session/prune.test.ts | 79 +++++++++++++++++++ 5 files changed, 162 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/session/checkpoint-align.ts b/packages/opencode/src/session/checkpoint-align.ts index 08ad5f42d..a59b6b56f 100644 --- a/packages/opencode/src/session/checkpoint-align.ts +++ b/packages/opencode/src/session/checkpoint-align.ts @@ -10,8 +10,11 @@ type AlignMsg = { * * Used to align a delta slice's start so the LLM does not see an orphan * tool_result. If no qualifying message exists in `[0, idx]`, returns 0 - * (caller may still receive an LLM rejection, in which case writerFailures - * increments via the existing path — degenerate sessions only). + * (caller may still receive an LLM rejection; that surfaces as a writer + * failure, which increments writerFailures only once prune's retry watcher + * observes the writer SETTLE — a rejection landing past the watcher's wait + * bound is still booked, but the watcher's wait is capped, so a writer that + * never settles at all is counted nowhere — degenerate sessions only). * * If `idx` is past the end of `msgs`, returns `idx` unchanged: the empty * delta is a legitimate (post-watermark) state. diff --git a/packages/opencode/src/session/checkpoint.ts b/packages/opencode/src/session/checkpoint.ts index 980896688..c06055585 100644 --- a/packages/opencode/src/session/checkpoint.ts +++ b/packages/opencode/src/session/checkpoint.ts @@ -982,13 +982,14 @@ export const layer: Layer.Layer< const state = writers.get(sessionID) if (!state) return "no-writer" as const - // v2 writers manage 3 file types and frequently take 60-180s; pad to - // 5min so a long-but-honest writer is not mistaken for a failure by - // the prune retry watcher. AgentOutcome → WriterOutcome translation: - // success → "success", failure / cancelled → "failure". + // v2 writers manage 3 file types and frequently take 60-180s, so the + // wait is bounded at 5min rather than left unbounded. AgentOutcome → + // WriterOutcome translation: success → "success", failure / cancelled → + // "failure", bound expired with the writer still unsettled → "timeout". // - // The bound expiring is NOT a writer failure. This timeout does not - // cancel the writer, and the settle watcher that owns the watermark + // The bound expiring is NOT a writer failure — the padding is not what + // keeps the two apart, the distinct return value is. This timeout does + // not cancel the writer, and the settle watcher that owns the watermark // advance (see tryStartCheckpointWriter) awaits the SAME Deferred with no // bound — so a slow-but-successful writer still advances // last_checkpoint_message_id after we stop waiting. Reporting "failure" @@ -996,13 +997,23 @@ export const layer: Layer.Layer< // and MAX_WRITER_FAILURES such waits then tripped "gave up after max // consecutive failures", permanently disabling checkpointing for a // session whose every writer had actually succeeded. Report "timeout" so - // callers can distinguish "still in flight" from "settled unsuccessfully" - // (prune's `result !== "failure"` guard already skips the counter). + // callers can distinguish "still in flight" from "settled unsuccessfully"; + // prune's `result !== "failure"` guard skips the counter, and prune keeps + // waiting so the writer's real outcome is still booked (see prune.ts). const outcome = yield* Deferred.await(state.writing).pipe( Effect.timeout(300_000), Effect.catch(() => Effect.succeed("timeout" as const)), ) - if (outcome === "timeout") return "timeout" as const + if (outcome === "timeout") { + // Hitting the bound must stay observable: the caller reports neither a + // success nor a failure, so without this line a writer stuck past 5min + // produces no log at all until it finally settles. + log.info("checkpoint writer wait bound expired — writer still in flight", { + sessionID, + boundMs: 300_000, + }) + return "timeout" as const + } return outcome.status === "success" ? ("success" as const) : ("failure" as const) }) diff --git a/packages/opencode/src/session/prune.ts b/packages/opencode/src/session/prune.ts index b1effce0f..5f7baccac 100644 --- a/packages/opencode/src/session/prune.ts +++ b/packages/opencode/src/session/prune.ts @@ -25,6 +25,11 @@ const DEFAULT_CACHE_TTL = 300_000 // checkpoint thresholds. Users can override via cfg.checkpoint.reserved. const CHECKPOINT_RESERVED = 13_000 const MAX_WRITER_FAILURES = 3 +// How many times the retry watcher re-enters the bounded waitForWriter (5min +// each) before it stops waiting for a writer that has not settled. Caps the +// watcher fiber's lifetime at ~1h so a permanently stuck writer cannot pin it +// for the life of the process. +const MAX_WRITER_WAIT_EXTENSIONS = 12 /** * Default checkpoint thresholds by context window size. @@ -297,7 +302,9 @@ export const layer: Layer.Layer< // Fork a watcher that settles after the detached writer fiber // finishes. On success, clear the failure counter. On failure, // increment the counter; if below MAX_WRITER_FAILURES, clear the - // session's crossed thresholds so the next iteration retries. + // session's crossed thresholds so the next iteration retries. The + // wait is extended across bound expiries so what gets booked is the + // writer's REAL outcome, never the mere fact that it was slow. // // Known narrow race: between tryStartCheckpointWriter returning "started" and // the watcher's forkDetach scheduling, a very-fast writer fiber can @@ -309,15 +316,44 @@ export const layer: Layer.Layer< // tryStartCheckpointWriter return the Deferred handle so the watcher doesn't // re-read the writers map. yield* Effect.gen(function* () { - const result = yield* checkpoint.waitForWriter(input.sessionID) + // waitForWriter is bounded (5min). Expiry means "still in flight", + // not "failed" — but returning here would book the writer's real + // outcome NOWHERE: this fiber is the only thing holding the + // per-fire accounting, and writerFailures is private to this + // layer, so the checkpoint settle watcher cannot reach it. A + // writer that genuinely fails at, say, 400s would then never tick + // the counter (making the cap unreachable for exactly the slow + // regime that motivated the "timeout" outcome), and a writer that + // succeeds at 400s would never CLEAR a counter left at 1-2 by + // earlier fast failures. So keep waiting across bound expiries and + // account for the settled result. + // + // Each extension re-enters waitForWriter, which re-reads the + // writers map. Two microsecond-wide windows are accepted rather + // than papered over: (1) the writer settles between our expiry and + // the re-entry, so the entry is gone and we get "no-writer" — no + // outcome to attribute, same as the pre-existing fast-writer race + // noted above; (2) a queued writer was drained into a fresh entry in + // that window, so we book ITS outcome instead — still a real + // outcome for this session. Extensions are capped so a permanently + // stuck writer cannot hold this fiber forever. + let result = yield* checkpoint.waitForWriter(input.sessionID) + for (let extension = 1; result === "timeout"; extension++) { + if (extension > MAX_WRITER_WAIT_EXTENSIONS) { + log.warn("checkpoint writer still in flight after max wait extensions — stopped waiting", { + sessionID: input.sessionID, + extensions: MAX_WRITER_WAIT_EXTENSIONS, + }) + return + } + result = yield* checkpoint.waitForWriter(input.sessionID) + } if (result === "success") { writerFailures.delete(input.sessionID) return } - // "no-writer" and "timeout" both mean "not a settled failure". A - // timed-out wait leaves the writer in flight (and still able to - // advance the watermark), so counting it would retire a - // merely-slow-but-working writer. Only a real failure ticks below. + // Only "no-writer" reaches here besides "failure": the writer + // settled before we could observe it, so there is nothing to book. if (result !== "failure") return const next = (writerFailures.get(input.sessionID) ?? 0) + 1 writerFailures.set(input.sessionID, next) diff --git a/packages/opencode/test/session/checkpoint-writer-wait-timeout.test.ts b/packages/opencode/test/session/checkpoint-writer-wait-timeout.test.ts index 906b8078f..3cd7e0e3a 100644 --- a/packages/opencode/test/session/checkpoint-writer-wait-timeout.test.ts +++ b/packages/opencode/test/session/checkpoint-writer-wait-timeout.test.ts @@ -108,11 +108,18 @@ describe("SessionCheckpoint.waitForWriter", () => { }) expect(started).toBe("started") - // Drive past the 5-minute internal bound on the TestClock. The writer's - // Deferred is still unresolved, so the wait expires while the writer is - // genuinely in flight. + // Pin the BOUND, not just the outcome. At 4 minutes the wait must still + // be pending: without this, shrinking the bound to (say) 1s would + // reintroduce the original bug in a new shape — every honest 60-180s + // writer would report "timeout" — and a lone `adjust("6 minutes")` + // assertion would still pass. const fiber = yield* Effect.forkChild(svc.waitForWriter(info.id)) - yield* TestClock.adjust("6 minutes") + yield* TestClock.adjust("4 minutes") + expect(fiber.pollUnsafe()).toBeUndefined() + + // Now cross the 5-minute bound. The writer's Deferred is still + // unresolved, so the wait expires while the writer is genuinely in flight. + yield* TestClock.adjust("2 minutes") const result = yield* Fiber.join(fiber) // Regression: this used to be "failure", which made the prune retry @@ -120,7 +127,11 @@ describe("SessionCheckpoint.waitForWriter", () => { // "gave up after max consecutive failures" — permanently disabling // checkpointing for a session whose writers were only slow. expect(result).toBe("timeout") - expect(result).not.toBe("failure") + + // The expiry must not have cancelled or retired the writer: it is still + // in flight and still owns the watermark advance. This is the property + // that makes "timeout" honest rather than a renamed failure. + expect(yield* svc.isWriterRunning(info.id)).toBe(true) }), ), ) diff --git a/packages/opencode/test/session/prune.test.ts b/packages/opencode/test/session/prune.test.ts index 47b2b7506..2fb2957c9 100644 --- a/packages/opencode/test/session/prune.test.ts +++ b/packages/opencode/test/session/prune.test.ts @@ -367,6 +367,85 @@ describe("SessionPrune.fireCheckpoints writer-failure retry", () => { { checkpoint: { thresholds: ["50%"] } }, ) }) + + // The PR's whole thesis lives here, not in waitForWriter's return value: a + // bounded wait that expires must not be booked as a writer failure, and must + // not lose the writer's real outcome either. The stub's outcome queue is + // shifted once per waitForWriter call, so a "timeout" entry models one + // expired 5-minute bound followed by whatever comes next. + test("a timed-out wait never ticks the counter, and the writer's real success clears it", async () => { + const harness = makeRetryHarness() + const promptOps = {} as any + + await runWithHarness( + harness, + Effect.gen(function* () { + const svc = yield* SessionPrune.Service + const ssn = yield* SessionNs.Service + const info = yield* ssn.create({}) + const model = createModel({ context: 100_000, output: 32_000 }) + + // Fires 1-2 fail fast → counter 1, 2 (each clears crossed, so the next + // fire re-enqueues). Fire 3's writer blows through two bound expiries + // and THEN succeeds: the counter must be cleared, not frozen at 2. + harness.outcomes.push("failure", "failure", "timeout", "timeout", "success") + for (let i = 0; i < 3; i++) { + yield* svc.fireCheckpoints({ sessionID: info.id, model, tokens: makeTokens(), promptOps }) + yield* Effect.sleep(100) + } + expect(harness.state.enqueueCount).toBe(3) + + // Re-cross the threshold and fail fast twice more. Because fire 3's + // late success reset the counter, these land at 1 and 2 — below the cap + // — so each clears crossed and the following fire enqueues again. + // If a post-timeout success did NOT clear the counter (the gap this + // test pins), the first of these would land at 3, trip "gave up", keep + // crossed set, and the final fire would not enqueue → 5, not 6. + yield* svc.resetThresholds(info.id) + harness.outcomes.push("failure", "failure", "failure") + for (let i = 0; i < 3; i++) { + yield* svc.fireCheckpoints({ sessionID: info.id, model, tokens: makeTokens(), promptOps }) + yield* Effect.sleep(100) + } + expect(harness.state.enqueueCount).toBe(6) + }), + { checkpoint: { thresholds: ["50%"] } }, + ) + }) + + test("a writer that fails past the wait bound is still counted, so the cap stays reachable", async () => { + const harness = makeRetryHarness() + const promptOps = {} as any + + await runWithHarness( + harness, + Effect.gen(function* () { + const svc = yield* SessionPrune.Service + const ssn = yield* SessionNs.Service + const info = yield* ssn.create({}) + const model = createModel({ context: 100_000, output: 32_000 }) + + // Three writers, each slow enough to blow the bound and then genuinely + // failing — the realistic shape for a writer doing ~13 sequential LLM + // round-trips. If the watcher gave up at the bound instead of waiting, + // no failure would ever be booked: crossed would never be cleared, so + // fire 2 would not even enqueue and this would read 1. + harness.outcomes.push("timeout", "failure", "timeout", "failure", "timeout", "failure") + for (let i = 0; i < 3; i++) { + yield* svc.fireCheckpoints({ sessionID: info.id, model, tokens: makeTokens(), promptOps }) + yield* Effect.sleep(100) + } + expect(harness.state.enqueueCount).toBe(3) + + // Fire 3's watcher hit the cap, so crossed was left set → no enqueue. + // A permanently-broken-but-slow writer now gives up like a fast one. + yield* svc.fireCheckpoints({ sessionID: info.id, model, tokens: makeTokens(), promptOps }) + yield* Effect.sleep(100) + expect(harness.state.enqueueCount).toBe(3) + }), + { checkpoint: { thresholds: ["50%"] } }, + ) + }) }) describe("defaultThresholdsFor (Part 2 density)", () => { From 7d4f3c8624e17249cec938f73f4fb1b24b7f7ced Mon Sep 17 00:00:00 2001 From: wqymi Date: Mon, 20 Jul 2026 19:59:59 +0800 Subject: [PATCH 018/135] fix(worktree): set git identity on isolated worktree creation to stop hostname-fallback authorship A separate worktree checkout shares the object/ref store but has its own config, so it does NOT inherit the parent repo's LOCAL git identity. When global identity is also empty, git commit autodetects user@hostname (e.g. MI ), leaking the machine hostname and wrong authorship into pushed commits. setup() now resolves the parent repo's identity (git -C config user.name/email, which walks local->global->system) and pins it into the new worktree's own local config, right after the HEAD-attach assertion and inside the existing per-repo lock. If the parent has no identity at all, it falls back to a stable mimocode identity (mimocode ) so the worktree is never left without one. --- packages/opencode/src/worktree/index.ts | 16 +++++++ packages/opencode/test/worktree/index.test.ts | 48 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index ac13468ae..d0d459ccf 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -276,6 +276,22 @@ export const layer: Layer.Layer< message: `Worktree HEAD is not attached to ${expected} (got ${head || "detached HEAD"})`, }) } + + // A separate worktree checkout shares the object/ref store but has its + // own config, so it does NOT inherit the parent repo's LOCAL identity. + // If global identity is also empty, `git commit` here would autodetect + // `user@hostname` (e.g. `MI `), leaking the machine + // hostname + wrong authorship into pushed commits. Resolve the parent's + // identity (walks local->global->system) and pin it into the new + // worktree's own local config; fall back to a stable mimocode identity + // so the worktree is NEVER left without one. Reading an unset key exits + // non-zero / empty, which the `git()` runner returns as empty text. + const parentName = (yield* git(["config", "user.name"], { cwd: ctx.worktree })).text.trim() + const parentEmail = (yield* git(["config", "user.email"], { cwd: ctx.worktree })).text.trim() + const name = parentName || "mimocode" + const email = parentEmail || "mimocode@users.noreply.github.com" + yield* git(["config", "user.name", name], { cwd: info.directory }) + yield* git(["config", "user.email", email], { cwd: info.directory }) }), ) diff --git a/packages/opencode/test/worktree/index.test.ts b/packages/opencode/test/worktree/index.test.ts index a165ea852..2fefbf23f 100644 --- a/packages/opencode/test/worktree/index.test.ts +++ b/packages/opencode/test/worktree/index.test.ts @@ -1,4 +1,5 @@ import { describe, expect } from "bun:test" +import { $ } from "bun" import { Effect, Layer } from "effect" import { Worktree } from "../../src/worktree" import { testEffect } from "../lib/effect" @@ -28,3 +29,50 @@ describe("Worktree.head / isPristine", () => { ), ) }) + +describe("Worktree.setup git identity", () => { + it.live("pins the parent repo identity into the new worktree's local config", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const wt = yield* Worktree.Service + const info = yield* wt.makeWorktreeInfo() + yield* wt.createFromInfo(info) + // The fixture sets the parent repo's local identity to Test/test@mimocode.test. + const name = (yield* Effect.promise(() => $`git config user.name`.cwd(info.directory).quiet().text())).trim() + const email = ( + yield* Effect.promise(() => $`git config user.email`.cwd(info.directory).quiet().text()) + ).trim() + expect(name).toBe("Test") + expect(email).toBe("test@mimocode.test") + yield* wt.remove({ directory: info.directory }) + }), + { git: true }, + ), + ) + + it.live("falls back to a stable mimocode identity when the parent has none", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + // Strip the parent's identity so the fallback path is exercised. + yield* Effect.promise(() => $`git config --unset user.name`.cwd(dir).quiet().nothrow()) + yield* Effect.promise(() => $`git config --unset user.email`.cwd(dir).quiet().nothrow()) + const wt = yield* Worktree.Service + const info = yield* wt.makeWorktreeInfo() + yield* wt.createFromInfo(info) + const name = (yield* Effect.promise(() => $`git config user.name`.cwd(info.directory).quiet().text())).trim() + const email = ( + yield* Effect.promise(() => $`git config user.email`.cwd(info.directory).quiet().text()) + ).trim() + expect(name).toBe("mimocode") + expect(email).toBe("mimocode@users.noreply.github.com") + // Sanity: identity is never left empty (the hostname-fallback trigger). + expect(name.length).toBeGreaterThan(0) + expect(email.length).toBeGreaterThan(0) + yield* wt.remove({ directory: info.directory }) + }), + { git: true }, + ), + ) +}) From 430bbe3c3f0745ed418d9f1c5033b0edf64d5f37 Mon Sep 17 00:00:00 2001 From: wqymi Date: Mon, 20 Jul 2026 20:08:52 +0800 Subject: [PATCH 019/135] fix(bash): git-identity env floor + align worktree fallback (layer 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer-1 (worktree/index.ts setup) only covers worktrees mimocode creates in code. It does NOT cover worktrees/commits an agent makes via the bash tool (git worktree add / git clone / committing in an ad-hoc dir) — those still fall back to MI . Layer-2 adds a floor in BashTool.shellEnv(): resolve the repo identity once per worktree (git config user.name/email at Instance.worktree via the Git service) and inject GIT_AUTHOR_NAME/EMAIL + GIT_COMMITTER_NAME/EMAIL into every bash env. Fall back to a stable mimocode-agent[bot] identity when the repo has none, and guard the non-git case (Instance.worktree === '/') so we never read git config at root. Layering / git precedence: explicit -c / repo-or-worktree LOCAL config (layer 1) > GIT_AUTHOR_*/COMMITTER_* env (layer 2 floor) > global > autodetect user@hostname. The floor is placed below process.env (an operator-set GIT_AUTHOR_* still wins) and above plugin extra.env (a plugin can still override), and only fills vars not already present in process.env. Complementary, not conflicting. Also aligns layer-1's fallback identity to mimocode-agent[bot] for consistency. Adds bash-env floor tests (inherit from repo config, bot fallback for a non-git project, operator-override wins) alongside the existing worktree identity tests. --- packages/opencode/src/tool/bash.ts | 40 +++++++ packages/opencode/src/worktree/index.ts | 4 +- packages/opencode/test/tool/bash.test.ts | 108 ++++++++++++++++++ packages/opencode/test/worktree/index.test.ts | 4 +- 4 files changed, 152 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index c1d6b9f11..88bb73a90 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -19,6 +19,7 @@ import { SessionCwd } from "./session-cwd" import { BashArity } from "@/permission/arity" import * as Truncate from "./truncate" import { Plugin } from "@/plugin" +import { Git } from "@/git" import { Effect, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" @@ -449,6 +450,34 @@ export const BashTool = Tool.define( const fs = yield* AppFileSystem.Service const trunc = yield* Truncate.Service const plugin = yield* Plugin.Service + const gitSvc = yield* Git.Service + + // Layer-2 floor for git authorship: an agent may create a worktree/clone or + // commit in an ad-hoc dir via this bash tool, bypassing Worktree.setup()'s + // per-worktree local-config fix. Without an identity, `git commit` + // autodetects `user@hostname` (e.g. `MI `), leaking the + // machine hostname + wrong authorship into pushed commits. We inject + // GIT_AUTHOR_*/COMMITTER_* env as a FLOOR (below repo/worktree local config, + // which still wins). Resolved once per worktree and memoized. + const AGENT_NAME = "mimocode-agent[bot]" + const AGENT_EMAIL = "mimocode-agent[bot]@users.noreply.github.com" + const gitIdentityCache = new Map() + const resolveGitIdentity = Effect.fn("BashTool.resolveGitIdentity")(function* () { + const worktree = Instance.worktree + const cached = gitIdentityCache.get(worktree) + if (cached) return cached + // Non-git projects set worktree to "/"; never read git config at root. + if (worktree === "/") { + const bot = { name: AGENT_NAME, email: AGENT_EMAIL } + gitIdentityCache.set(worktree, bot) + return bot + } + const name = (yield* gitSvc.run(["config", "user.name"], { cwd: worktree })).text().trim() + const email = (yield* gitSvc.run(["config", "user.email"], { cwd: worktree })).text().trim() + const identity = { name: name || AGENT_NAME, email: email || AGENT_EMAIL } + gitIdentityCache.set(worktree, identity) + return identity + }) const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) { const lines = yield* spawner @@ -519,12 +548,23 @@ export const BashTool = Tool.define( { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} }, ) + const identity = yield* resolveGitIdentity() + // Only fill vars the operator hasn't already set, so an explicit + // GIT_AUTHOR_* in the environment still wins over our floor. + const gitFloor: Record = {} + if (!process.env["GIT_AUTHOR_NAME"]) gitFloor["GIT_AUTHOR_NAME"] = identity.name + if (!process.env["GIT_AUTHOR_EMAIL"]) gitFloor["GIT_AUTHOR_EMAIL"] = identity.email + if (!process.env["GIT_COMMITTER_NAME"]) gitFloor["GIT_COMMITTER_NAME"] = identity.name + if (!process.env["GIT_COMMITTER_EMAIL"]) gitFloor["GIT_COMMITTER_EMAIL"] = identity.email return { ...process.env, // Python ignores the console code page when stdout is a pipe and falls // back to the ANSI code page (GBK on zh-CN), producing mojibake. Force // UTF-8 for child Python processes on Windows. ...(process.platform === "win32" ? { PYTHONIOENCODING: "utf-8" } : {}), + // Git authorship floor: below process.env (operator override wins) but + // above plugin extra.env (a plugin can still override). + ...gitFloor, ...extra.env, } }) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index d0d459ccf..010193965 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -288,8 +288,8 @@ export const layer: Layer.Layer< // non-zero / empty, which the `git()` runner returns as empty text. const parentName = (yield* git(["config", "user.name"], { cwd: ctx.worktree })).text.trim() const parentEmail = (yield* git(["config", "user.email"], { cwd: ctx.worktree })).text.trim() - const name = parentName || "mimocode" - const email = parentEmail || "mimocode@users.noreply.github.com" + const name = parentName || "mimocode-agent[bot]" + const email = parentEmail || "mimocode-agent[bot]@users.noreply.github.com" yield* git(["config", "user.name", name], { cwd: info.directory }) yield* git(["config", "user.email", email], { cwd: info.directory }) }), diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 1a0ea5a53..863bb7a86 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -15,6 +15,7 @@ import { SessionID, MessageID } from "../../src/session/schema" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { AppFileSystem } from "@mimo-ai/shared/filesystem" import { Plugin } from "../../src/plugin" +import { Git } from "../../src/git" const runtime = ManagedRuntime.make( Layer.mergeAll( @@ -23,6 +24,7 @@ const runtime = ManagedRuntime.make( Plugin.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, + Git.defaultLayer, ), ) @@ -190,6 +192,112 @@ describe("tool.bash", () => { }) }) +describe("tool.bash git identity floor", () => { + const savedEnv = () => ({ + GIT_AUTHOR_NAME: process.env["GIT_AUTHOR_NAME"], + GIT_AUTHOR_EMAIL: process.env["GIT_AUTHOR_EMAIL"], + GIT_COMMITTER_NAME: process.env["GIT_COMMITTER_NAME"], + GIT_COMMITTER_EMAIL: process.env["GIT_COMMITTER_EMAIL"], + }) + const restoreEnv = (saved: ReturnType) => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } + const printGitEnv = + process.platform === "win32" + ? "echo GIT_AUTHOR_NAME=$env:GIT_AUTHOR_NAME; echo GIT_AUTHOR_EMAIL=$env:GIT_AUTHOR_EMAIL; echo GIT_COMMITTER_NAME=$env:GIT_COMMITTER_NAME; echo GIT_COMMITTER_EMAIL=$env:GIT_COMMITTER_EMAIL" + : "echo GIT_AUTHOR_NAME=$GIT_AUTHOR_NAME; echo GIT_AUTHOR_EMAIL=$GIT_AUTHOR_EMAIL; echo GIT_COMMITTER_NAME=$GIT_COMMITTER_NAME; echo GIT_COMMITTER_EMAIL=$GIT_COMMITTER_EMAIL" + + each("injects the 4 GIT_* vars inherited from the repo config", async () => { + const saved = savedEnv() + restoreEnv({ + GIT_AUTHOR_NAME: undefined, + GIT_AUTHOR_EMAIL: undefined, + GIT_COMMITTER_NAME: undefined, + GIT_COMMITTER_EMAIL: undefined, + }) + try { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initBash() + const result = await Effect.runPromise( + bash.execute({ command: printGitEnv, description: "print git env" }, ctx), + ) + // The tmpdir git fixture sets user.name=Test / user.email=test@mimocode.test. + expect(result.metadata.output).toContain("GIT_AUTHOR_NAME=Test") + expect(result.metadata.output).toContain("GIT_AUTHOR_EMAIL=test@mimocode.test") + expect(result.metadata.output).toContain("GIT_COMMITTER_NAME=Test") + expect(result.metadata.output).toContain("GIT_COMMITTER_EMAIL=test@mimocode.test") + }, + }) + } finally { + restoreEnv(saved) + } + }) + + each("falls back to the stable bot identity for a non-git project (worktree=/)", async () => { + const saved = savedEnv() + restoreEnv({ + GIT_AUTHOR_NAME: undefined, + GIT_AUTHOR_EMAIL: undefined, + GIT_COMMITTER_NAME: undefined, + GIT_COMMITTER_EMAIL: undefined, + }) + try { + // outsideGit -> a truly non-git project -> Instance.worktree === "/". + await using tmp = await tmpdir({ outsideGit: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + expect(Instance.worktree).toBe("/") + const bash = await initBash() + const result = await Effect.runPromise( + bash.execute({ command: printGitEnv, description: "print git env" }, ctx), + ) + expect(result.metadata.output).toContain("GIT_AUTHOR_NAME=mimocode-agent[bot]") + expect(result.metadata.output).toContain("GIT_AUTHOR_EMAIL=mimocode-agent[bot]@users.noreply.github.com") + expect(result.metadata.output).toContain("GIT_COMMITTER_NAME=mimocode-agent[bot]") + expect(result.metadata.output).toContain("GIT_COMMITTER_EMAIL=mimocode-agent[bot]@users.noreply.github.com") + }, + }) + } finally { + restoreEnv(saved) + } + }) + + each("does not override an operator-set GIT_AUTHOR_NAME", async () => { + const saved = savedEnv() + restoreEnv({ + GIT_AUTHOR_NAME: "Operator", + GIT_AUTHOR_EMAIL: undefined, + GIT_COMMITTER_NAME: undefined, + GIT_COMMITTER_EMAIL: undefined, + }) + try { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initBash() + const result = await Effect.runPromise( + bash.execute({ command: printGitEnv, description: "print git env" }, ctx), + ) + // process.env value wins over the floor. + expect(result.metadata.output).toContain("GIT_AUTHOR_NAME=Operator") + // Unset ones still get the floor. + expect(result.metadata.output).toContain("GIT_COMMITTER_NAME=Test") + }, + }) + } finally { + restoreEnv(saved) + } + }) +}) + describe("tool.bash permissions", () => { each("asks for bash permission with correct pattern", async () => { await using tmp = await tmpdir() diff --git a/packages/opencode/test/worktree/index.test.ts b/packages/opencode/test/worktree/index.test.ts index 2fefbf23f..f4831d8f7 100644 --- a/packages/opencode/test/worktree/index.test.ts +++ b/packages/opencode/test/worktree/index.test.ts @@ -65,8 +65,8 @@ describe("Worktree.setup git identity", () => { const email = ( yield* Effect.promise(() => $`git config user.email`.cwd(info.directory).quiet().text()) ).trim() - expect(name).toBe("mimocode") - expect(email).toBe("mimocode@users.noreply.github.com") + expect(name).toBe("mimocode-agent[bot]") + expect(email).toBe("mimocode-agent[bot]@users.noreply.github.com") // Sanity: identity is never left empty (the hostname-fallback trigger). expect(name.length).toBeGreaterThan(0) expect(email.length).toBeGreaterThan(0) From 3b33ee42d20c4a997db9c888c252c1b7fa3d2bbb Mon Sep 17 00:00:00 2001 From: wqymi Date: Mon, 27 Jul 2026 18:22:42 +0800 Subject: [PATCH 020/135] fix(git-identity): use mimo@xiaomi.com as the fallback identity The fallback identity email was fabricated by analogy to the real opencode-agent[bot] GitHub App address. Replace it with the intended mimo@xiaomi.com / "MiMo Code" pair in both layers (worktree setup local config + bash shellEnv floor) and their tests. --- packages/opencode/src/tool/bash.ts | 10 +++++----- packages/opencode/src/worktree/index.ts | 4 ++-- packages/opencode/test/tool/bash.test.ts | 10 +++++----- packages/opencode/test/worktree/index.test.ts | 4 ++-- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 88bb73a90..1e21dca16 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -459,8 +459,8 @@ export const BashTool = Tool.define( // machine hostname + wrong authorship into pushed commits. We inject // GIT_AUTHOR_*/COMMITTER_* env as a FLOOR (below repo/worktree local config, // which still wins). Resolved once per worktree and memoized. - const AGENT_NAME = "mimocode-agent[bot]" - const AGENT_EMAIL = "mimocode-agent[bot]@users.noreply.github.com" + const AGENT_NAME = "MiMo Code" + const AGENT_EMAIL = "mimo@xiaomi.com" const gitIdentityCache = new Map() const resolveGitIdentity = Effect.fn("BashTool.resolveGitIdentity")(function* () { const worktree = Instance.worktree @@ -468,9 +468,9 @@ export const BashTool = Tool.define( if (cached) return cached // Non-git projects set worktree to "/"; never read git config at root. if (worktree === "/") { - const bot = { name: AGENT_NAME, email: AGENT_EMAIL } - gitIdentityCache.set(worktree, bot) - return bot + const fallback = { name: AGENT_NAME, email: AGENT_EMAIL } + gitIdentityCache.set(worktree, fallback) + return fallback } const name = (yield* gitSvc.run(["config", "user.name"], { cwd: worktree })).text().trim() const email = (yield* gitSvc.run(["config", "user.email"], { cwd: worktree })).text().trim() diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 010193965..b564392a4 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -288,8 +288,8 @@ export const layer: Layer.Layer< // non-zero / empty, which the `git()` runner returns as empty text. const parentName = (yield* git(["config", "user.name"], { cwd: ctx.worktree })).text.trim() const parentEmail = (yield* git(["config", "user.email"], { cwd: ctx.worktree })).text.trim() - const name = parentName || "mimocode-agent[bot]" - const email = parentEmail || "mimocode-agent[bot]@users.noreply.github.com" + const name = parentName || "MiMo Code" + const email = parentEmail || "mimo@xiaomi.com" yield* git(["config", "user.name", name], { cwd: info.directory }) yield* git(["config", "user.email", email], { cwd: info.directory }) }), diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 863bb7a86..7434f30d9 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -239,7 +239,7 @@ describe("tool.bash git identity floor", () => { } }) - each("falls back to the stable bot identity for a non-git project (worktree=/)", async () => { + each("falls back to the stable fallback identity for a non-git project (worktree=/)", async () => { const saved = savedEnv() restoreEnv({ GIT_AUTHOR_NAME: undefined, @@ -258,10 +258,10 @@ describe("tool.bash git identity floor", () => { const result = await Effect.runPromise( bash.execute({ command: printGitEnv, description: "print git env" }, ctx), ) - expect(result.metadata.output).toContain("GIT_AUTHOR_NAME=mimocode-agent[bot]") - expect(result.metadata.output).toContain("GIT_AUTHOR_EMAIL=mimocode-agent[bot]@users.noreply.github.com") - expect(result.metadata.output).toContain("GIT_COMMITTER_NAME=mimocode-agent[bot]") - expect(result.metadata.output).toContain("GIT_COMMITTER_EMAIL=mimocode-agent[bot]@users.noreply.github.com") + expect(result.metadata.output).toContain("GIT_AUTHOR_NAME=MiMo Code") + expect(result.metadata.output).toContain("GIT_AUTHOR_EMAIL=mimo@xiaomi.com") + expect(result.metadata.output).toContain("GIT_COMMITTER_NAME=MiMo Code") + expect(result.metadata.output).toContain("GIT_COMMITTER_EMAIL=mimo@xiaomi.com") }, }) } finally { diff --git a/packages/opencode/test/worktree/index.test.ts b/packages/opencode/test/worktree/index.test.ts index f4831d8f7..baacaf25e 100644 --- a/packages/opencode/test/worktree/index.test.ts +++ b/packages/opencode/test/worktree/index.test.ts @@ -65,8 +65,8 @@ describe("Worktree.setup git identity", () => { const email = ( yield* Effect.promise(() => $`git config user.email`.cwd(info.directory).quiet().text()) ).trim() - expect(name).toBe("mimocode-agent[bot]") - expect(email).toBe("mimocode-agent[bot]@users.noreply.github.com") + expect(name).toBe("MiMo Code") + expect(email).toBe("mimo@xiaomi.com") // Sanity: identity is never left empty (the hostname-fallback trigger). expect(name.length).toBeGreaterThan(0) expect(email.length).toBeGreaterThan(0) From f592fe408ec672405226fcab3df9896780cfa191 Mon Sep 17 00:00:00 2001 From: wqymi Date: Mon, 27 Jul 2026 21:22:52 +0800 Subject: [PATCH 021/135] fix(git-identity): shorten fallback name to MiMo The fallback git-identity name for agent-authored commits is now "MiMo" instead of "MiMo Code"; the fallback email stays mimo@xiaomi.com. --- packages/opencode/src/tool/bash.ts | 2 +- packages/opencode/src/worktree/index.ts | 2 +- packages/opencode/test/tool/bash.test.ts | 4 ++-- packages/opencode/test/worktree/index.test.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 1e21dca16..4656b10e5 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -459,7 +459,7 @@ export const BashTool = Tool.define( // machine hostname + wrong authorship into pushed commits. We inject // GIT_AUTHOR_*/COMMITTER_* env as a FLOOR (below repo/worktree local config, // which still wins). Resolved once per worktree and memoized. - const AGENT_NAME = "MiMo Code" + const AGENT_NAME = "MiMo" const AGENT_EMAIL = "mimo@xiaomi.com" const gitIdentityCache = new Map() const resolveGitIdentity = Effect.fn("BashTool.resolveGitIdentity")(function* () { diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index b564392a4..b82aacc14 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -288,7 +288,7 @@ export const layer: Layer.Layer< // non-zero / empty, which the `git()` runner returns as empty text. const parentName = (yield* git(["config", "user.name"], { cwd: ctx.worktree })).text.trim() const parentEmail = (yield* git(["config", "user.email"], { cwd: ctx.worktree })).text.trim() - const name = parentName || "MiMo Code" + const name = parentName || "MiMo" const email = parentEmail || "mimo@xiaomi.com" yield* git(["config", "user.name", name], { cwd: info.directory }) yield* git(["config", "user.email", email], { cwd: info.directory }) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 7434f30d9..3e7cc6f8a 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -258,9 +258,9 @@ describe("tool.bash git identity floor", () => { const result = await Effect.runPromise( bash.execute({ command: printGitEnv, description: "print git env" }, ctx), ) - expect(result.metadata.output).toContain("GIT_AUTHOR_NAME=MiMo Code") + expect(result.metadata.output).toContain("GIT_AUTHOR_NAME=MiMo") expect(result.metadata.output).toContain("GIT_AUTHOR_EMAIL=mimo@xiaomi.com") - expect(result.metadata.output).toContain("GIT_COMMITTER_NAME=MiMo Code") + expect(result.metadata.output).toContain("GIT_COMMITTER_NAME=MiMo") expect(result.metadata.output).toContain("GIT_COMMITTER_EMAIL=mimo@xiaomi.com") }, }) diff --git a/packages/opencode/test/worktree/index.test.ts b/packages/opencode/test/worktree/index.test.ts index baacaf25e..6dbb9986e 100644 --- a/packages/opencode/test/worktree/index.test.ts +++ b/packages/opencode/test/worktree/index.test.ts @@ -65,7 +65,7 @@ describe("Worktree.setup git identity", () => { const email = ( yield* Effect.promise(() => $`git config user.email`.cwd(info.directory).quiet().text()) ).trim() - expect(name).toBe("MiMo Code") + expect(name).toBe("MiMo") expect(email).toBe("mimo@xiaomi.com") // Sanity: identity is never left empty (the hostname-fallback trigger). expect(name.length).toBeGreaterThan(0) From 9b0e03016ef3310021eeb5c615b3ada993deef19 Mon Sep 17 00:00:00 2001 From: wqymi Date: Tue, 21 Jul 2026 19:24:03 +0800 Subject: [PATCH 022/135] fix(mcp): negotiate per-turn lifecycle notifications Propagate one stable turn context through MCP tool execution and notify negotiated servers exactly once when a turn completes, is cancelled, or fails. Keep the behavior capability-gated and provider-neutral, with serialization and recovery tests for overlapping notifications. --- packages/opencode/src/mcp/index.ts | 128 +++++- packages/opencode/src/session/prompt.ts | 55 ++- packages/opencode/src/tool/tool.ts | 2 + packages/opencode/test/mcp/lifecycle.test.ts | 371 +++++++++++++++++- .../test/session/prompt-effect.test.ts | 163 +++++++- 5 files changed, 696 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index ef7c571a4..66d318b0d 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -69,6 +69,118 @@ export const Failed = NamedError.create( type MCPClient = Client +export const TURN_LIFECYCLE_CAPABILITY = "com.xiaomi.mimo/turn-lifecycle" +export const TURN_LIFECYCLE_NOTIFICATION = `notifications/${TURN_LIFECYCLE_CAPABILITY}` +export const TURN_LIFECYCLE_NOTIFICATION_TIMEOUT = 1_000 + +interface PendingTurnLifecycleNotification { + readonly promise: Promise + readonly waiters: Set<() => void> +} + +const pendingTurnLifecycleNotifications = new WeakMap() + +export interface TurnContext { + [key: string]: unknown + sessionId: string + turnId: string + actorId?: string +} + +export type TurnStatus = "completed" | "cancelled" | "error" + +function supportsTurnLifecycle(client: MCPClient) { + const capability = client.getServerCapabilities()?.experimental?.[TURN_LIFECYCLE_CAPABILITY] + return typeof capability === "object" && capability !== null && "version" in capability && capability.version === 1 +} + +function startTurnLifecycleNotification(client: MCPClient, context: TurnContext, status: TurnStatus) { + if (pendingTurnLifecycleNotifications.has(client)) return undefined + const promise = Promise.resolve().then(() => + client.notification({ + method: TURN_LIFECYCLE_NOTIFICATION, + params: { ...context, status }, + } as Parameters[0]), + ) + const notification: PendingTurnLifecycleNotification = { promise, waiters: new Set() } + pendingTurnLifecycleNotifications.set(client, notification) + const clear = () => { + if (pendingTurnLifecycleNotifications.get(client) === notification) { + pendingTurnLifecycleNotifications.delete(client) + } + const waiters = [...notification.waiters] + notification.waiters.clear() + for (const waiter of waiters) waiter() + } + void promise.then(clear, clear) + return notification +} + +function waitForTurnLifecycleNotification(client: MCPClient, notification: PendingTurnLifecycleNotification) { + return Effect.tryPromise({ + try: (signal) => + new Promise((resolve, reject) => { + let done = false + const cleanup = () => { + notification.waiters.delete(onSettled) + signal.removeEventListener("abort", onAbort) + } + const finish = (complete: () => void) => { + if (done) return + done = true + cleanup() + complete() + } + const onSettled = () => finish(resolve) + const onAbort = () => + finish(() => reject(signal.reason instanceof Error ? signal.reason : new Error("Lifecycle wait aborted"))) + + notification.waiters.add(onSettled) + signal.addEventListener("abort", onAbort, { once: true }) + + if (signal.aborted) onAbort() + else if (pendingTurnLifecycleNotifications.get(client) !== notification) onSettled() + }), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }) +} + +function sendTurnLifecycleNotification(client: MCPClient, context: TurnContext, status: TurnStatus) { + return Effect.gen(function* () { + while (true) { + const pending = pendingTurnLifecycleNotifications.get(client) + if (pending) { + yield* waitForTurnLifecycleNotification(client, pending) + continue + } + + const notification = startTurnLifecycleNotification(client, context, status) + if (!notification) continue + return yield* Effect.tryPromise({ + try: () => notification.promise, + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }) + } + }) +} + +export function notifyTurnLifecycle(clients: Record, context: TurnContext, status: TurnStatus) { + return Effect.forEach( + Object.entries(clients), + ([clientName, client]) => { + if (!supportsTurnLifecycle(client)) return Effect.void + return sendTurnLifecycleNotification(client, context, status).pipe( + Effect.timeout(TURN_LIFECYCLE_NOTIFICATION_TIMEOUT), + Effect.tapError((error) => + Effect.sync(() => log.warn("failed to notify MCP turn lifecycle", { clientName, status, error })), + ), + Effect.ignore, + ) + }, + { concurrency: "unbounded", discard: true }, + ) +} + export const Status = z .discriminatedUnion("status", [ z @@ -137,7 +249,7 @@ function isMcpConfigured(entry: McpEntry): entry is ConfigMCP.Info { const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, "_") // Convert MCP tool definition to AI SDK Tool type -function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool { +function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number, context?: TurnContext): Tool { const inputSchema = mcpTool.inputSchema // Spread first, then override type to ensure it's always "object" @@ -152,10 +264,13 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number description: mcpTool.description ?? "", inputSchema: jsonSchema(schema), execute: async (args: unknown) => { + const metadata = + context && supportsTurnLifecycle(client) ? { _meta: { [TURN_LIFECYCLE_CAPABILITY]: context } } : {} return client.callTool( { name: mcpTool.name, arguments: (args || {}) as Record, + ...metadata, }, CallToolResultSchema, { @@ -228,7 +343,7 @@ interface State { export interface Interface { readonly status: () => Effect.Effect> readonly clients: () => Effect.Effect> - readonly tools: () => Effect.Effect> + readonly tools: (context?: TurnContext) => Effect.Effect> readonly prompts: () => Effect.Effect> readonly resources: () => Effect.Effect> readonly add: (name: string, mcp: ConfigMCP.Info) => Effect.Effect<{ status: Record | Status }> @@ -637,7 +752,7 @@ export const layer = Layer.effect( s.status[name] = { status: "disabled" } }) - const tools = Effect.fn("MCP.tools")(function* () { + const tools = Effect.fn("MCP.tools")(function* (context?: TurnContext) { const result: Record = {} const s = yield* InstanceState.get(state) @@ -664,7 +779,12 @@ export const layer = Layer.effect( const timeout = entry?.timeout ?? defaultTimeout for (const mcpTool of listed) { - result[sanitize(clientName) + "_" + sanitize(mcpTool.name)] = convertMcpTool(mcpTool, client, timeout) + result[sanitize(clientName) + "_" + sanitize(mcpTool.name)] = convertMcpTool( + mcpTool, + client, + timeout, + context, + ) } }), { concurrency: "unbounded" }, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3955d8039..a5038c499 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -937,6 +937,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the messages: MessageV2.WithParts[] agentID?: string task_id?: string + mcpContext: MCP.TurnContext }) { using _ = log.time("resolveTools") const tools: Record = {} @@ -1017,6 +1018,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the agent: input.agent.name, actorID: input.agentID, taskId: input.task_id, + turnID: input.mcpContext.turnId, + turnActorID: input.mcpContext.actorId, messages: input.messages, metadata: (val) => input.processor.updateToolCall(options.toolCallId, (match) => { @@ -1157,7 +1160,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the } const localToolNames = new Set(Object.keys(tools)) - const mcpTools = Object.entries(yield* mcp.tools()) + const mcpTools = Object.entries(yield* mcp.tools(input.mcpContext)) const agentToolAllowlist = input.agent.toolAllowlist ? new Set(input.agent.toolAllowlist) : undefined const disabledMcpTools = Permission.disabled( mcpTools.map(([key]) => key), @@ -2338,6 +2341,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the // into the same loop. let hardHalt = false const resolvedAgentID = agentID ?? "main" + const mcpContext: MCP.TurnContext = { + sessionId: sessionID, + turnId: ulid(), + actorId: resolvedAgentID, + } // Tracks plugin-driven cancellation (session.pre OR any session.userQuery.pre) // so session.post reports outcome="cancelled" instead of "error". let cancelled = false @@ -2377,20 +2385,36 @@ NOTE: At any point in time through this workflow you should feel free to ask the : finalAsst ? sessionErrorText(finalAsst.error) : undefined - yield* plugin.trigger( - "session.post", - { - sessionID, - agentID: resolvedAgentID, - task_id, - outcome, - error, - finalText: finalAsst ? assistantFinalText(finalAsst, finalParts) : undefined, - assistantMessageID: finalAsst?.id, - trajectory: serializeTrajectoryMessages(sliceMsgs), - systemPrompt: lastSystemPrompt, - }, - {}, + const interrupted = Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause) + const lifecycleStatus: MCP.TurnStatus = + cancelled || interrupted ? "cancelled" : failed || finalIsError ? "error" : "completed" + yield* Effect.all( + [ + plugin + .trigger( + "session.post", + { + sessionID, + agentID: resolvedAgentID, + task_id, + outcome, + error, + finalText: finalAsst ? assistantFinalText(finalAsst, finalParts) : undefined, + assistantMessageID: finalAsst?.id, + trajectory: serializeTrajectoryMessages(sliceMsgs), + systemPrompt: lastSystemPrompt, + }, + {}, + ) + .pipe(Effect.ignore), + mcp + .clients() + .pipe( + Effect.flatMap((clients) => MCP.notifyTurnLifecycle(clients, mcpContext, lifecycleStatus)), + Effect.ignore, + ), + ], + { concurrency: "unbounded", discard: true }, ) }).pipe(Effect.ignore) @@ -3422,6 +3446,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the messages: msgs, agentID: lastUser.agentID, task_id, + mcpContext, }) const tools = resolvedTools.tools const activeTools = resolvedTools.activeTools diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index c2a406e8d..087abed6f 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -20,6 +20,8 @@ export type Context = { agent: string actorID?: string taskId?: string + turnID?: string + turnActorID?: string abort: AbortSignal callID?: string extra?: { [key: string]: unknown } diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index a79298319..88e201744 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,5 +1,5 @@ import { test, expect, mock, beforeEach } from "bun:test" -import { Effect } from "effect" +import { Effect, Fiber } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" // --- Mock infrastructure --- @@ -16,6 +16,15 @@ interface MockClientState { resources: Array<{ name: string; uri: string; description?: string }> closed: boolean notificationHandlers: Map any> + serverCapabilities: Record + toolCalls: Array> + notifications: Array> + notificationCalls: number + notificationInFlight: number + notificationMaxInFlight: number + notificationResolvers: Array<() => void> + notificationError?: string + notificationHangs?: boolean } const clientStates = new Map() @@ -43,6 +52,13 @@ function getOrCreateClientState(name?: string): MockClientState { resources: [], closed: false, notificationHandlers: new Map(), + serverCapabilities: {}, + toolCalls: [], + notifications: [], + notificationCalls: 0, + notificationInFlight: 0, + notificationMaxInFlight: 0, + notificationResolvers: [], } clientStates.set(key, state) } @@ -130,6 +146,34 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ this._state?.notificationHandlers.set(schema, handler) } + getServerCapabilities() { + return this._state?.serverCapabilities + } + + async callTool(params: Record) { + this._state?.toolCalls.push(params) + return { content: [{ type: "text", text: "ok" }] } + } + + async notification(notification: Record) { + if (!this._state) return + this._state.notificationCalls++ + this._state.notificationInFlight++ + this._state.notificationMaxInFlight = Math.max( + this._state.notificationMaxInFlight, + this._state.notificationInFlight, + ) + try { + if (this._state.notificationError) throw new Error(this._state.notificationError) + if (this._state.notificationHangs) { + await new Promise((resolve) => this._state.notificationResolvers.push(resolve)) + } + this._state.notifications.push(notification) + } finally { + this._state.notificationInFlight-- + } + } + async listTools() { if (this._state) this._state.listToolsCalls++ if (this._state?.listToolsShouldFail) { @@ -235,6 +279,331 @@ test( ), ) +test( + "turn metadata is omitted unless the server advertises lifecycle v1", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "legacy-server" + const serverState = getOrCreateClientState("legacy-server") + yield* mcp.add("legacy-server", { + type: "local", + command: ["echo", "test"], + }) + + const tools = yield* mcp.tools({ sessionId: "ses_1", turnId: "turn_1", actorId: "main" }) + const execute = tools["legacy-server_test_tool"]?.execute + expect(execute).toBeDefined() + yield* Effect.promise(() => + Promise.resolve( + execute?.({}, { toolCallId: "call_1", messages: [], abortSignal: new AbortController().signal }), + ), + ) + + expect(serverState.toolCalls).toEqual([{ name: "test_tool", arguments: {} }]) + }), + ), +) + +test( + "turn metadata is stable across calls to a lifecycle-aware server", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "lifecycle-server" + const serverState = getOrCreateClientState("lifecycle-server") + serverState.serverCapabilities = { + experimental: { "com.xiaomi.mimo/turn-lifecycle": { version: 1 } }, + } + yield* mcp.add("lifecycle-server", { + type: "local", + command: ["echo", "test"], + }) + + const context = { sessionId: "ses_1", turnId: "turn_1", actorId: "main" } + const tools = yield* mcp.tools(context) + const execute = tools["lifecycle-server_test_tool"]?.execute + expect(execute).toBeDefined() + yield* Effect.promise(() => + Promise.all([ + Promise.resolve( + execute?.({ index: 1 }, { toolCallId: "call_1", messages: [], abortSignal: new AbortController().signal }), + ), + Promise.resolve( + execute?.({ index: 2 }, { toolCallId: "call_2", messages: [], abortSignal: new AbortController().signal }), + ), + ]), + ) + + expect(serverState.toolCalls).toEqual([ + { + name: "test_tool", + arguments: { index: 1 }, + _meta: { "com.xiaomi.mimo/turn-lifecycle": context }, + }, + { + name: "test_tool", + arguments: { index: 2 }, + _meta: { "com.xiaomi.mimo/turn-lifecycle": context }, + }, + ]) + }), + ), +) + +test( + "turn lifecycle notifications carry each terminal status only for v1 servers", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "lifecycle-server" + const serverState = getOrCreateClientState("lifecycle-server") + serverState.serverCapabilities = { + experimental: { "com.xiaomi.mimo/turn-lifecycle": { version: 1 } }, + } + yield* mcp.add("lifecycle-server", { + type: "local", + command: ["echo", "test"], + }) + + const context = { sessionId: "ses_1", turnId: "turn_1", actorId: "main" } + const clients = yield* mcp.clients() + yield* MCP.notifyTurnLifecycle(clients, context, "completed") + yield* MCP.notifyTurnLifecycle(clients, context, "cancelled") + yield* MCP.notifyTurnLifecycle(clients, context, "error") + + expect(serverState.notifications).toEqual( + ["completed", "cancelled", "error"].map((status) => ({ + method: "notifications/com.xiaomi.mimo/turn-lifecycle", + params: { ...context, status }, + })), + ) + }), + ), +) + +test( + "turn lifecycle ignores unsupported and non-numeric capability versions", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "legacy-server" + const serverState = getOrCreateClientState("legacy-server") + serverState.serverCapabilities = { + experimental: { "com.xiaomi.mimo/turn-lifecycle": { version: "1" } }, + } + yield* mcp.add("legacy-server", { + type: "local", + command: ["echo", "test"], + }) + + yield* MCP.notifyTurnLifecycle(yield* mcp.clients(), { sessionId: "ses_1", turnId: "turn_1" }, "completed") + + expect(serverState.notifications).toEqual([]) + }), + ), +) + +test( + "turn lifecycle notification failures are best effort and do not block other servers", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "failing-server" + const failingState = getOrCreateClientState("failing-server") + failingState.serverCapabilities = { + experimental: { "com.xiaomi.mimo/turn-lifecycle": { version: 1 } }, + } + failingState.notificationError = "closed" + yield* mcp.add("failing-server", { type: "local", command: ["echo", "test"] }) + + lastCreatedClientName = "healthy-server" + const healthyState = getOrCreateClientState("healthy-server") + healthyState.serverCapabilities = { + experimental: { "com.xiaomi.mimo/turn-lifecycle": { version: 1 } }, + } + yield* mcp.add("healthy-server", { type: "local", command: ["echo", "test"] }) + + yield* MCP.notifyTurnLifecycle(yield* mcp.clients(), { sessionId: "ses_1", turnId: "turn_1" }, "completed") + + expect(failingState.notifications).toEqual([]) + expect(healthyState.notifications).toHaveLength(1) + }), + ), +) + +test( + "turn lifecycle serializes overlapping healthy notifications without dropping turns", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "lifecycle-server" + const serverState = getOrCreateClientState("lifecycle-server") + serverState.serverCapabilities = { + experimental: { "com.xiaomi.mimo/turn-lifecycle": { version: 1 } }, + } + serverState.notificationHangs = true + yield* mcp.add("lifecycle-server", { type: "local", command: ["echo", "test"] }) + + const clients = yield* mcp.clients() + const first = yield* MCP.notifyTurnLifecycle(clients, { sessionId: "ses_1", turnId: "turn_1" }, "completed").pipe( + Effect.forkChild, + ) + yield* Effect.sleep(25) + expect(serverState.notificationCalls).toBe(1) + + const second = yield* MCP.notifyTurnLifecycle( + clients, + { sessionId: "ses_2", turnId: "turn_2" }, + "completed", + ).pipe(Effect.forkChild) + yield* Effect.sleep(25) + expect(serverState.notificationCalls).toBe(1) + expect(serverState.notificationInFlight).toBe(1) + + serverState.notificationHangs = false + serverState.notificationResolvers.shift()?.() + yield* Fiber.join(first) + yield* Fiber.join(second) + + expect(serverState.notificationCalls).toBe(2) + expect(serverState.notificationMaxInFlight).toBe(1) + expect(serverState.notifications.map((notification) => notification.params)).toEqual([ + { sessionId: "ses_1", turnId: "turn_1", status: "completed" }, + { sessionId: "ses_2", turnId: "turn_2", status: "completed" }, + ]) + }), + ), +) + +test( + "turn lifecycle times out hanging waiters without retaining or starting their sends", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "hanging-server" + const hangingState = getOrCreateClientState("hanging-server") + hangingState.serverCapabilities = { + experimental: { "com.xiaomi.mimo/turn-lifecycle": { version: 1 } }, + } + hangingState.notificationHangs = true + yield* mcp.add("hanging-server", { type: "local", command: ["echo", "test"] }) + + lastCreatedClientName = "healthy-server" + const healthyState = getOrCreateClientState("healthy-server") + healthyState.serverCapabilities = { + experimental: { "com.xiaomi.mimo/turn-lifecycle": { version: 1 } }, + } + yield* mcp.add("healthy-server", { type: "local", command: ["echo", "test"] }) + + const started = Date.now() + const clients = yield* mcp.clients() + const first = yield* MCP.notifyTurnLifecycle(clients, { sessionId: "ses_1", turnId: "turn_1" }, "completed").pipe( + Effect.forkChild, + ) + + yield* Effect.sleep(50) + expect(healthyState.notifications).toHaveLength(1) + const second = yield* MCP.notifyTurnLifecycle( + clients, + { sessionId: "ses_1", turnId: "turn_2" }, + "completed", + ).pipe(Effect.forkChild) + const third = yield* MCP.notifyTurnLifecycle(clients, { sessionId: "ses_1", turnId: "turn_3" }, "completed").pipe( + Effect.forkChild, + ) + yield* Fiber.join(first) + yield* Fiber.join(second) + yield* Fiber.join(third) + expect(Date.now() - started).toBeLessThan(2_000) + + expect(hangingState.notificationCalls).toBe(1) + expect(hangingState.notificationInFlight).toBe(1) + expect(hangingState.notificationMaxInFlight).toBe(1) + expect(healthyState.notifications).toHaveLength(3) + + hangingState.notificationHangs = false + hangingState.notificationResolvers.shift()?.() + yield* Effect.sleep(25) + expect(hangingState.notificationCalls).toBe(1) + expect(hangingState.notifications.map((notification) => notification.params)).toEqual([ + { sessionId: "ses_1", turnId: "turn_1", status: "completed" }, + ]) + + yield* MCP.notifyTurnLifecycle(clients, { sessionId: "ses_1", turnId: "turn_4" }, "completed") + + expect(hangingState.notificationCalls).toBe(2) + expect(hangingState.notificationInFlight).toBe(0) + expect(hangingState.notificationMaxInFlight).toBe(1) + expect(hangingState.notifications.map((notification) => notification.params)).toEqual([ + { sessionId: "ses_1", turnId: "turn_1", status: "completed" }, + { sessionId: "ses_1", turnId: "turn_4", status: "completed" }, + ]) + expect(healthyState.notifications).toHaveLength(4) + }), + ), +) + +test( + "turn lifecycle resumes after a notification rejects", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "lifecycle-server" + const serverState = getOrCreateClientState("lifecycle-server") + serverState.serverCapabilities = { + experimental: { "com.xiaomi.mimo/turn-lifecycle": { version: 1 } }, + } + serverState.notificationError = "closed" + yield* mcp.add("lifecycle-server", { type: "local", command: ["echo", "test"] }) + + const clients = yield* mcp.clients() + yield* MCP.notifyTurnLifecycle(clients, { sessionId: "ses_1", turnId: "turn_1" }, "completed") + serverState.notificationError = undefined + yield* MCP.notifyTurnLifecycle(clients, { sessionId: "ses_1", turnId: "turn_2" }, "completed") + + expect(serverState.notificationCalls).toBe(2) + expect(serverState.notificationMaxInFlight).toBe(1) + expect(serverState.notifications.map((notification) => notification.params)).toEqual([ + { sessionId: "ses_1", turnId: "turn_2", status: "completed" }, + ]) + }), + ), +) + +test( + "replacement clients are not blocked by an old pending notification", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "replacement-old" + const oldState = getOrCreateClientState("replacement-old") + oldState.serverCapabilities = { + experimental: { "com.xiaomi.mimo/turn-lifecycle": { version: 1 } }, + } + oldState.notificationHangs = true + yield* mcp.add("replace-server", { type: "local", command: ["echo", "test"] }) + + const oldNotification = yield* MCP.notifyTurnLifecycle( + yield* mcp.clients(), + { sessionId: "ses_1", turnId: "turn_1" }, + "completed", + ).pipe(Effect.forkChild) + yield* Effect.sleep(25) + expect(oldState.notificationCalls).toBe(1) + + lastCreatedClientName = "replacement-new" + const newState = getOrCreateClientState("replacement-new") + newState.serverCapabilities = { + experimental: { "com.xiaomi.mimo/turn-lifecycle": { version: 1 } }, + } + yield* mcp.add("replace-server", { type: "local", command: ["echo", "test"] }) + yield* MCP.notifyTurnLifecycle(yield* mcp.clients(), { sessionId: "ses_2", turnId: "turn_2" }, "completed") + + expect(oldState.notificationCalls).toBe(1) + expect(newState.notificationCalls).toBe(1) + expect(newState.notifications.map((notification) => notification.params)).toEqual([ + { sessionId: "ses_2", turnId: "turn_2", status: "completed" }, + ]) + + oldState.notificationHangs = false + oldState.notificationResolvers.shift()?.() + yield* Fiber.join(oldNotification) + }), + ), +) + // ======================================================================== // Test: tool change notifications refresh the cache // ======================================================================== diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index d40bb606d..b92eaca94 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -140,13 +140,16 @@ function wireTool(tools: Array>, name: string) { return tools.find((item) => wireToolName(item) === name) } -function mcpLayer(tools: () => Record = () => ({})) { +function mcpLayer( + tools: (context?: MCP.TurnContext) => Record = () => ({}), + clients: () => Record = () => ({}), +) { return Layer.succeed( MCP.Service, MCP.Service.of({ status: () => Effect.succeed({}), - clients: () => Effect.succeed({}), - tools: () => Effect.sync(tools), + clients: () => Effect.sync(clients), + tools: (context) => Effect.sync(() => tools(context)), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), add: () => Effect.succeed({ status: { status: "disabled" as const } }), @@ -346,6 +349,39 @@ const mcpIt = testEffect( })), ), ) +const lifecycleContexts: MCP.TurnContext[] = [] +const lifecycleNotifications: Array> = [] +let lifecycleNotificationHangs = false +const lifecycleClient = { + getServerCapabilities: () => ({ + experimental: { "com.xiaomi.mimo/turn-lifecycle": { version: 1 } }, + }), + notification: async (notification: Record) => { + if (lifecycleNotificationHangs) return new Promise(() => {}) + lifecycleNotifications.push(notification) + }, +} +const lifecycleMcpIt = testEffect( + makeHttp( + mcpLayer( + (context) => ({ + mcp_lifecycle: dynamicTool({ + description: "Record lifecycle context", + inputSchema: jsonSchema({ + type: "object", + properties: { index: { type: "number" } }, + required: ["index"], + }), + execute: async () => { + if (context) lifecycleContexts.push(context) + return { content: [{ type: "text", text: "ok" }] } + }, + }), + }), + () => ({ lifecycle: lifecycleClient }), + ), + ), +) const unix = process.platform !== "win32" ? it.live : it.live.skip // Config that registers a custom "test" provider with a "test-model" model @@ -1328,6 +1364,127 @@ it.live( 30_000, ) +lifecycleMcpIt.live("MCP calls in one outer run share one turn and emit one terminal notification", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + lifecycleContexts.length = 0 + lifecycleNotifications.length = 0 + lifecycleNotificationHangs = false + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "Lifecycle", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "call the lifecycle tool twice" }], + }) + yield* llm.tool("mcp_lifecycle", { index: 1 }) + yield* llm.tool("mcp_lifecycle", { index: 2 }) + yield* llm.text("done") + + yield* prompt.loop({ sessionID: session.id }) + + expect(lifecycleContexts).toHaveLength(2) + expect(lifecycleContexts[0]?.sessionId).toBe(session.id) + expect(lifecycleContexts[0]?.actorId).toBe("main") + expect(lifecycleContexts[0]?.turnId).toBeTruthy() + expect(lifecycleContexts[1]).toEqual(lifecycleContexts[0]) + expect(lifecycleNotifications).toEqual([ + { + method: "notifications/com.xiaomi.mimo/turn-lifecycle", + params: { ...lifecycleContexts[0], status: "completed" }, + }, + ]) + }), + { git: true, config: providerCfg }, + ), +) + +lifecycleMcpIt.live( + "MCP lifecycle emits one cancelled notification when the outer run is interrupted", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + lifecycleContexts.length = 0 + lifecycleNotifications.length = 0 + lifecycleNotificationHangs = false + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Lifecycle cancellation" }) + yield* user(session.id, "wait") + yield* llm.hang + + const fiber = yield* prompt.loop({ sessionID: session.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + yield* prompt.cancel(session.id) + yield* Fiber.await(fiber) + + expect(lifecycleNotifications).toHaveLength(1) + expect(lifecycleNotifications[0]).toMatchObject({ + method: "notifications/com.xiaomi.mimo/turn-lifecycle", + params: { sessionId: session.id, actorId: "main", status: "cancelled" }, + }) + expect(lifecycleNotifications[0]?.params?.turnId).toBeTruthy() + }), + { git: true, config: providerCfg }, + ), + 3_000, +) + +lifecycleMcpIt.live("MCP lifecycle emits one error notification when the outer run fails", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + lifecycleContexts.length = 0 + lifecycleNotifications.length = 0 + lifecycleNotificationHangs = false + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Lifecycle error" }) + yield* user(session.id, "fail") + yield* llm.error(400, { error: { message: "test failure" } }) + + yield* prompt.loop({ sessionID: session.id }).pipe(Effect.exit) + + expect(lifecycleNotifications).toHaveLength(1) + expect(lifecycleNotifications[0]).toMatchObject({ + method: "notifications/com.xiaomi.mimo/turn-lifecycle", + params: { sessionId: session.id, actorId: "main", status: "error" }, + }) + expect(lifecycleNotifications[0]?.params?.turnId).toBeTruthy() + }), + { git: true, config: providerCfg }, + ), +) + +lifecycleMcpIt.live( + "MCP lifecycle timeout lets the outer run finalizer complete when a notification hangs", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + lifecycleContexts.length = 0 + lifecycleNotifications.length = 0 + lifecycleNotificationHangs = true + yield* Effect.addFinalizer(() => Effect.sync(() => void (lifecycleNotificationHangs = false))) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Lifecycle timeout" }) + yield* user(session.id, "finish despite a hanging notification") + yield* llm.text("done") + + const result = yield* prompt.loop({ sessionID: session.id }) + + expect(result.info.role).toBe("assistant") + expect(lifecycleNotifications).toEqual([]) + }), + { git: true, config: providerCfg }, + ), + 5_000, +) + it.live("glob tool keeps instance context during prompt runs", () => provideTmpdirServer( ({ dir, llm }) => From cd16c2001263a8d5adde21a6543162a5eb979f94 Mon Sep 17 00:00:00 2001 From: wqymi Date: Tue, 21 Jul 2026 23:15:25 +0800 Subject: [PATCH 023/135] fix(mcp): make lifecycle negotiation cancellation-safe Advertise the exact lifecycle v1 client capability and propagate turn cancellation into in-flight MCP calls so terminal notifications cannot race active tool work. --- packages/opencode/src/mcp/index.ts | 25 ++++- packages/opencode/test/mcp/lifecycle.test.ts | 104 +++++++++++++++++- .../test/session/prompt-effect.test.ts | 54 +++++++++ 3 files changed, 177 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 66d318b0d..e3421c98d 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -71,8 +71,17 @@ type MCPClient = Client export const TURN_LIFECYCLE_CAPABILITY = "com.xiaomi.mimo/turn-lifecycle" export const TURN_LIFECYCLE_NOTIFICATION = `notifications/${TURN_LIFECYCLE_CAPABILITY}` +export const TURN_LIFECYCLE_VERSION = 1 export const TURN_LIFECYCLE_NOTIFICATION_TIMEOUT = 1_000 +const turnLifecycleClientOptions = { + capabilities: { + experimental: { + [TURN_LIFECYCLE_CAPABILITY]: { version: TURN_LIFECYCLE_VERSION }, + }, + }, +} + interface PendingTurnLifecycleNotification { readonly promise: Promise readonly waiters: Set<() => void> @@ -91,7 +100,12 @@ export type TurnStatus = "completed" | "cancelled" | "error" function supportsTurnLifecycle(client: MCPClient) { const capability = client.getServerCapabilities()?.experimental?.[TURN_LIFECYCLE_CAPABILITY] - return typeof capability === "object" && capability !== null && "version" in capability && capability.version === 1 + return ( + typeof capability === "object" && + capability !== null && + "version" in capability && + capability.version === TURN_LIFECYCLE_VERSION + ) } function startTurnLifecycleNotification(client: MCPClient, context: TurnContext, status: TurnStatus) { @@ -263,7 +277,7 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number return dynamicTool({ description: mcpTool.description ?? "", inputSchema: jsonSchema(schema), - execute: async (args: unknown) => { + execute: async (args: unknown, options) => { const metadata = context && supportsTurnLifecycle(client) ? { _meta: { [TURN_LIFECYCLE_CAPABILITY]: context } } : {} return client.callTool( @@ -275,6 +289,7 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number CallToolResultSchema, { resetTimeoutOnProgress: true, + signal: options.abortSignal, timeout, }, ) @@ -375,6 +390,8 @@ export const layer = Layer.effect( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const auth = yield* McpAuth.Service const bus = yield* Bus.Service + const createClient = () => + new Client({ name: "mimocode", version: InstallationVersion }, turnLifecycleClientOptions) type Transport = StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport @@ -388,7 +405,7 @@ export const layer = Layer.effect( (t) => Effect.tryPromise({ try: () => { - const client = new Client({ name: "mimocode", version: InstallationVersion }) + const client = createClient() return withTimeout(client.connect(t), timeout).then(() => client) }, catch: (e) => (e instanceof Error ? e : new Error(String(e))), @@ -897,7 +914,7 @@ export const layer = Layer.effect( return yield* Effect.tryPromise({ try: () => { - const client = new Client({ name: "mimocode", version: InstallationVersion }) + const client = createClient() return client .connect(transport) .then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 88e201744..7dc210939 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -18,6 +18,9 @@ interface MockClientState { notificationHandlers: Map any> serverCapabilities: Record toolCalls: Array> + toolCallSignals: Array + toolCallHangs: boolean + toolCallAbortCount: number notifications: Array> notificationCalls: number notificationInFlight: number @@ -34,6 +37,7 @@ let connectShouldHang = false let connectError = "Mock transport cannot connect" // Tracks how many Client instances were created (detects leaks) let clientCreateCount = 0 +const clientOptions: unknown[] = [] // Tracks how many times transport.close() is called across all mock transports let transportCloseCount = 0 @@ -54,6 +58,9 @@ function getOrCreateClientState(name?: string): MockClientState { notificationHandlers: new Map(), serverCapabilities: {}, toolCalls: [], + toolCallSignals: [], + toolCallHangs: false, + toolCallAbortCount: 0, notifications: [], notificationCalls: 0, notificationInFlight: 0, @@ -131,8 +138,9 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ _state!: MockClientState transport: any - constructor(_opts: any) { + constructor(_opts: any, options?: unknown) { clientCreateCount++ + clientOptions.push(options) } async connect(transport: { start: () => Promise }) { @@ -150,8 +158,27 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ return this._state?.serverCapabilities } - async callTool(params: Record) { + async callTool( + params: Record, + _schema?: unknown, + options?: { signal?: AbortSignal }, + ) { this._state?.toolCalls.push(params) + this._state?.toolCallSignals.push(options?.signal) + if (this._state?.toolCallHangs) { + await new Promise((_resolve, reject) => { + const signal = options?.signal + const onAbort = () => { + signal?.removeEventListener("abort", onAbort) + if (this._state) this._state.toolCallAbortCount++ + reject(signal?.reason instanceof Error ? signal.reason : new Error("tool call aborted")) + } + signal?.addEventListener("abort", onAbort, { once: true }) + if (signal?.aborted) onAbort() + // Deliberately no resolver: this request must settle only through + // the propagated cancellation signal. + }) + } return { content: [{ type: "text", text: "ok" }] } } @@ -209,6 +236,7 @@ beforeEach(() => { connectShouldHang = false connectError = "Mock transport cannot connect" clientCreateCount = 0 + clientOptions.length = 0 transportCloseCount = 0 }) @@ -279,6 +307,29 @@ test( ), ) +test( + "client advertises the exact lifecycle v1 capability during initialization", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "lifecycle-server" + yield* mcp.add("lifecycle-server", { + type: "local", + command: ["echo", "test"], + }) + + expect(clientOptions).toEqual([ + { + capabilities: { + experimental: { + "com.xiaomi.mimo/turn-lifecycle": { version: 1 }, + }, + }, + }, + ]) + }), + ), +) + test( "turn metadata is omitted unless the server advertises lifecycle v1", withInstance({}, (mcp) => @@ -349,6 +400,55 @@ test( ), ) +test( + "cancelling tool execution aborts the in-flight MCP request before terminal notification", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "lifecycle-server" + const serverState = getOrCreateClientState("lifecycle-server") + serverState.serverCapabilities = { + experimental: { "com.xiaomi.mimo/turn-lifecycle": { version: 1 } }, + } + serverState.toolCallHangs = true + yield* mcp.add("lifecycle-server", { + type: "local", + command: ["echo", "test"], + }) + + const context = { sessionId: "ses_1", turnId: "turn_1", actorId: "main" } + const tools = yield* mcp.tools(context) + const execute = tools["lifecycle-server_test_tool"]?.execute + expect(execute).toBeDefined() + const controller = new AbortController() + const execution = Promise.resolve( + execute?.({}, { toolCallId: "call_1", messages: [], abortSignal: controller.signal }), + ) + + expect(serverState.toolCallSignals).toEqual([controller.signal]) + expect(serverState.notifications).toEqual([]) + controller.abort(new Error("turn cancelled")) + yield* Effect.promise(() => + execution.then( + () => Promise.reject(new Error("cancelled MCP call unexpectedly resolved")), + (error) => { + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toBe("turn cancelled") + }, + ), + ) + expect(serverState.toolCallAbortCount).toBe(1) + + yield* MCP.notifyTurnLifecycle(yield* mcp.clients(), context, "cancelled") + expect(serverState.notifications).toEqual([ + { + method: "notifications/com.xiaomi.mimo/turn-lifecycle", + params: { ...context, status: "cancelled" }, + }, + ]) + }), + ), +) + test( "turn lifecycle notifications carry each terminal status only for v1 servers", withInstance({}, (mcp) => diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index b92eaca94..5986d4d83 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -352,6 +352,8 @@ const mcpIt = testEffect( const lifecycleContexts: MCP.TurnContext[] = [] const lifecycleNotifications: Array> = [] let lifecycleNotificationHangs = false +let lifecycleToolStarted: Deferred.Deferred | undefined +let lifecycleToolGate: Deferred.Deferred | undefined const lifecycleClient = { getServerCapabilities: () => ({ experimental: { "com.xiaomi.mimo/turn-lifecycle": { version: 1 } }, @@ -374,6 +376,8 @@ const lifecycleMcpIt = testEffect( }), execute: async () => { if (context) lifecycleContexts.push(context) + if (lifecycleToolStarted) Effect.runSync(Deferred.succeed(lifecycleToolStarted, undefined)) + if (lifecycleToolGate) await Effect.runPromise(Deferred.await(lifecycleToolGate)) return { content: [{ type: "text", text: "ok" }] } }, }), @@ -1404,6 +1408,56 @@ lifecycleMcpIt.live("MCP calls in one outer run share one turn and emit one term ), ) +lifecycleMcpIt.live("MCP lifecycle waits for an in-flight tool call before notifying", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + lifecycleContexts.length = 0 + lifecycleNotifications.length = 0 + lifecycleNotificationHangs = false + const started = yield* Deferred.make() + const gate = yield* Deferred.make() + lifecycleToolStarted = started + lifecycleToolGate = gate + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + yield* Deferred.succeed(gate, undefined) + lifecycleToolStarted = undefined + lifecycleToolGate = undefined + }), + ) + + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "Lifecycle settling", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "call the lifecycle tool" }], + }) + yield* llm.tool("mcp_lifecycle", { index: 1 }) + yield* llm.text("done") + + const run = yield* prompt.loop({ sessionID: session.id }).pipe(Effect.forkChild) + yield* Deferred.await(started) + expect(lifecycleNotifications).toEqual([]) + + yield* Deferred.succeed(gate, undefined) + yield* Fiber.join(run) + expect(lifecycleNotifications).toHaveLength(1) + expect(lifecycleNotifications[0]?.params).toMatchObject({ + sessionId: session.id, + turnId: lifecycleContexts[0]?.turnId, + status: "completed", + }) + }), + { git: true, config: providerCfg }, + ), +) + lifecycleMcpIt.live( "MCP lifecycle emits one cancelled notification when the outer run is interrupted", () => From 54e0c331c18c16d26e4c0f404a64591ebf08dfff Mon Sep 17 00:00:00 2001 From: wqymi Date: Mon, 27 Jul 2026 15:53:53 +0800 Subject: [PATCH 024/135] test(mcp): pin lifecycle MCP tests to a direct-MCP model main's request-scoped MCP discovery gates MCP tools behind mcp_tool_search for the default test model, so mcp_lifecycle never executed and no turn context was captured. Pin the two tool-calling lifecycle tests to the non-GPT model that still exposes MCP tools directly. --- packages/opencode/test/session/prompt-effect.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 5986d4d83..d1171cbe3 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -1383,6 +1383,7 @@ lifecycleMcpIt.live("MCP calls in one outer run share one turn and emit one term yield* prompt.prompt({ sessionID: session.id, agent: "build", + model: ref, noReply: true, parts: [{ type: "text", text: "call the lifecycle tool twice" }], }) @@ -1435,6 +1436,7 @@ lifecycleMcpIt.live("MCP lifecycle waits for an in-flight tool call before notif yield* prompt.prompt({ sessionID: session.id, agent: "build", + model: ref, noReply: true, parts: [{ type: "text", text: "call the lifecycle tool" }], }) From 7b487037b0ae4471bb2bdba0aef4bd1d2347b2cd Mon Sep 17 00:00:00 2001 From: wqymi Date: Mon, 27 Jul 2026 22:37:46 +0800 Subject: [PATCH 025/135] fix(mcp): drop dead turn context fields and unblock stuck lifecycle sends turnID/turnActorID on the tool Context were residue of the MCP-in-tool_script path that main removed in 8c29041a8; nothing reads them, so delete them. A lifecycle notification that never settles kept its pending-map entry forever, so every later turn for that client queued behind it, waited out the 1s timeout and was dropped -- a permanent, invisible per-turn stall. Record when a send started and release an entry that has outlived the budget so the next turn sends immediately, without ever awaiting the orphaned promise. --- packages/opencode/src/mcp/index.ts | 40 ++++++++++++-- packages/opencode/src/session/prompt.ts | 2 - packages/opencode/src/tool/tool.ts | 2 - packages/opencode/test/mcp/lifecycle.test.ts | 57 ++++++++++++++++++++ 4 files changed, 94 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index e3421c98d..0f0d0a4e3 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -73,6 +73,9 @@ export const TURN_LIFECYCLE_CAPABILITY = "com.xiaomi.mimo/turn-lifecycle" export const TURN_LIFECYCLE_NOTIFICATION = `notifications/${TURN_LIFECYCLE_CAPABILITY}` export const TURN_LIFECYCLE_VERSION = 1 export const TURN_LIFECYCLE_NOTIFICATION_TIMEOUT = 1_000 +// A send that has already outlived the per-turn budget can never be useful to wait +// on again, so later turns abandon it instead of queueing behind it forever. +export const TURN_LIFECYCLE_STUCK_TIMEOUT = TURN_LIFECYCLE_NOTIFICATION_TIMEOUT const turnLifecycleClientOptions = { capabilities: { @@ -85,6 +88,7 @@ const turnLifecycleClientOptions = { interface PendingTurnLifecycleNotification { readonly promise: Promise readonly waiters: Set<() => void> + readonly startedAt: number } const pendingTurnLifecycleNotifications = new WeakMap() @@ -116,7 +120,7 @@ function startTurnLifecycleNotification(client: MCPClient, context: TurnContext, params: { ...context, status }, } as Parameters[0]), ) - const notification: PendingTurnLifecycleNotification = { promise, waiters: new Set() } + const notification: PendingTurnLifecycleNotification = { promise, waiters: new Set(), startedAt: Date.now() } pendingTurnLifecycleNotifications.set(client, notification) const clear = () => { if (pendingTurnLifecycleNotifications.get(client) === notification) { @@ -126,10 +130,31 @@ function startTurnLifecycleNotification(client: MCPClient, context: TurnContext, notification.waiters.clear() for (const waiter of waiters) waiter() } + // Attached at creation so an orphaned send's eventual rejection is always swallowed. void promise.then(clear, clear) return notification } +// A send that outlives the per-turn budget is treated as stuck: drop it from the +// pending map so the next turn sends immediately instead of paying the timeout +// forever. The orphaned promise is never awaited again; its settlement still runs +// `clear`, which no-ops because the map entry has been replaced. +function releaseStuckTurnLifecycleNotification( + client: MCPClient, + notification: PendingTurnLifecycleNotification, + clientName: string, +) { + if (pendingTurnLifecycleNotifications.get(client) !== notification) return + pendingTurnLifecycleNotifications.delete(client) + log.warn("abandoning stuck MCP turn lifecycle notification", { + clientName, + elapsed: Date.now() - notification.startedAt, + }) + const waiters = [...notification.waiters] + notification.waiters.clear() + for (const waiter of waiters) waiter() +} + function waitForTurnLifecycleNotification(client: MCPClient, notification: PendingTurnLifecycleNotification) { return Effect.tryPromise({ try: (signal) => @@ -159,11 +184,20 @@ function waitForTurnLifecycleNotification(client: MCPClient, notification: Pendi }) } -function sendTurnLifecycleNotification(client: MCPClient, context: TurnContext, status: TurnStatus) { +function sendTurnLifecycleNotification( + client: MCPClient, + context: TurnContext, + status: TurnStatus, + clientName: string, +) { return Effect.gen(function* () { while (true) { const pending = pendingTurnLifecycleNotifications.get(client) if (pending) { + if (Date.now() - pending.startedAt >= TURN_LIFECYCLE_STUCK_TIMEOUT) { + releaseStuckTurnLifecycleNotification(client, pending, clientName) + continue + } yield* waitForTurnLifecycleNotification(client, pending) continue } @@ -183,7 +217,7 @@ export function notifyTurnLifecycle(clients: Record, context: Object.entries(clients), ([clientName, client]) => { if (!supportsTurnLifecycle(client)) return Effect.void - return sendTurnLifecycleNotification(client, context, status).pipe( + return sendTurnLifecycleNotification(client, context, status, clientName).pipe( Effect.timeout(TURN_LIFECYCLE_NOTIFICATION_TIMEOUT), Effect.tapError((error) => Effect.sync(() => log.warn("failed to notify MCP turn lifecycle", { clientName, status, error })), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a5038c499..f7fbfc3f6 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1018,8 +1018,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the agent: input.agent.name, actorID: input.agentID, taskId: input.task_id, - turnID: input.mcpContext.turnId, - turnActorID: input.mcpContext.actorId, messages: input.messages, metadata: (val) => input.processor.updateToolCall(options.toolCallId, (match) => { diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 087abed6f..c2a406e8d 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -20,8 +20,6 @@ export type Context = { agent: string actorID?: string taskId?: string - turnID?: string - turnActorID?: string abort: AbortSignal callID?: string extra?: { [key: string]: unknown } diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 7dc210939..8a2dd373c 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -705,6 +705,63 @@ test( ) // ======================================================================== +test( + "turn lifecycle abandons a permanently stuck send so a later turn still notifies promptly", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "stuck-server" + const stuckState = getOrCreateClientState("stuck-server") + stuckState.serverCapabilities = { + experimental: { "com.xiaomi.mimo/turn-lifecycle": { version: 1 } }, + } + stuckState.notificationHangs = true + yield* mcp.add("stuck-server", { type: "local", command: ["echo", "test"] }) + + const clients = yield* mcp.clients() + + // turn_1's send never settles — its resolver is deliberately never called, so it + // stays orphaned in the pending map for the rest of the test. + yield* MCP.notifyTurnLifecycle(clients, { sessionId: "ses_1", turnId: "turn_1" }, "completed") + expect(stuckState.notificationCalls).toBe(1) + expect(stuckState.notifications).toEqual([]) + + // The transport recovers, but the orphaned send is still parked in the pending map. + stuckState.notificationHangs = false + yield* Effect.sleep(50) + + const started = Date.now() + yield* MCP.notifyTurnLifecycle(clients, { sessionId: "ses_1", turnId: "turn_2" }, "completed") + const elapsed = Date.now() - started + + // Before the fix this turn queued behind the orphan, burned the whole 1s budget + // and was dropped (notificationCalls would still be 1) — and so would every turn + // after it. Now the stuck entry is released and the send happens immediately. + expect(stuckState.notificationCalls).toBe(2) + expect(elapsed).toBeLessThan(MCP.TURN_LIFECYCLE_NOTIFICATION_TIMEOUT / 2) + expect(stuckState.notifications.map((notification) => notification.params)).toEqual([ + { sessionId: "ses_1", turnId: "turn_2", status: "completed" }, + ]) + // Only the abandoned send is still counted in flight; overlapping it is the + // accepted cost of not blocking later turns forever. + expect(stuckState.notificationInFlight).toBe(1) + expect(stuckState.notificationMaxInFlight).toBe(2) + + // A further turn is also prompt, and healthy sends stay serialized behind + // each other rather than piling up. + const secondStarted = Date.now() + yield* MCP.notifyTurnLifecycle(clients, { sessionId: "ses_1", turnId: "turn_3" }, "completed") + expect(Date.now() - secondStarted).toBeLessThan(MCP.TURN_LIFECYCLE_NOTIFICATION_TIMEOUT / 2) + expect(stuckState.notificationCalls).toBe(3) + expect(stuckState.notificationInFlight).toBe(1) + expect(stuckState.notificationMaxInFlight).toBe(2) + expect(stuckState.notifications.map((notification) => notification.params)).toEqual([ + { sessionId: "ses_1", turnId: "turn_2", status: "completed" }, + { sessionId: "ses_1", turnId: "turn_3", status: "completed" }, + ]) + }), + ), +) + // Test: tool change notifications refresh the cache // ======================================================================== From ae18decb72a5d46df814e7d85ac7afc7311919ce Mon Sep 17 00:00:00 2001 From: wqymi Date: Mon, 27 Jul 2026 22:43:13 +0800 Subject: [PATCH 026/135] refactor(git-identity): single source of truth + document the env-floor contract Review follow-up on the two-layer git-identity fix. - Extract the fallback identity into Git.FALLBACK_IDENTITY (src/git/index.ts), the module both layers already import. The literals "MiMo"/"mimo@xiaomi.com" were duplicated in src/worktree/index.ts and src/tool/bash.ts; that constant had already been renamed once across several files, so the drift risk was demonstrated rather than hypothetical. Both tests now assert against the shared constant too, so a future rename cannot pass in one layer while silently failing in the other. - Document the floor's behavioral contract on gitIdentityCache: its only job is to stop `user@hostname` authorship; it is delivered as env, and git gives GIT_AUTHOR_*/GIT_COMMITTER_* precedence OVER user.name/user.email config; the per-worktree memoization means a mid-session `git config` change is not picked up until the process restarts; operator-set vars still win per-variable. - Correct the previous shellEnv comment, which claimed the env floor sat "below repo/worktree local config, which still wins". Verified empirically that git env vars override config, so the claim was backwards. - Note why resolveGitIdentity/gitIdentityCache sit in the tool's outer setup block rather than inside shellEnv (memoization across bash invocations). - Extend the operator-override test to assert per-variable precedence: with only GIT_AUTHOR_NAME operator-set, the other three vars must still receive the floor. It previously asserted names only, leaving the email path uncovered. --- packages/opencode/src/git/index.ts | 12 ++++++ packages/opencode/src/tool/bash.ts | 37 ++++++++++++++----- packages/opencode/src/worktree/index.ts | 9 +++-- packages/opencode/test/tool/bash.test.ts | 18 +++++---- packages/opencode/test/worktree/index.test.ts | 5 ++- 5 files changed, 59 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index 719b5607f..e7c33de35 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -16,6 +16,18 @@ const cfg = [ "core.quotepath=false", ] as const +// Single source of truth for the agent's fallback git identity, used only when +// neither the repo's nor the global config supplies one. Without it `git commit` +// autodetects `user@hostname` (e.g. `MI `), leaking the machine +// hostname and wrong authorship into pushed commits. Two independent layers +// consume this: the worktree-creation local-config pin (src/worktree/index.ts) +// and the bash env floor (src/tool/bash.ts). Keep it here so a rename can never +// land in one layer and silently drift in the other. +export const FALLBACK_IDENTITY = { + name: "MiMo", + email: "mimo@xiaomi.com", +} as const + const out = (result: { text(): string }) => result.text().trim() const nuls = (text: string) => text.split("\0").filter(Boolean) const fail = (err: unknown) => diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 4656b10e5..83fccdbda 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -456,11 +456,25 @@ export const BashTool = Tool.define( // commit in an ad-hoc dir via this bash tool, bypassing Worktree.setup()'s // per-worktree local-config fix. Without an identity, `git commit` // autodetects `user@hostname` (e.g. `MI `), leaking the - // machine hostname + wrong authorship into pushed commits. We inject - // GIT_AUTHOR_*/COMMITTER_* env as a FLOOR (below repo/worktree local config, - // which still wins). Resolved once per worktree and memoized. - const AGENT_NAME = "MiMo" - const AGENT_EMAIL = "mimo@xiaomi.com" + // machine hostname + wrong authorship into pushed commits. + // + // Behavioral contract of this floor and its cache: + // - Its ONLY job is to guarantee a commit never falls back to + // `user@hostname`. It is not a general identity-configuration feature. + // - It is delivered as GIT_AUTHOR_*/GIT_COMMITTER_* ENV, and git gives env + // vars precedence OVER `user.name`/`user.email` config. So the value + // seeded here outranks the repo's own config for commits made through + // this tool. We seed it FROM that config, so the two normally agree. + // - Because the resolved value is memoized per worktree path for the + // lifetime of the process, a `git config user.name ...` performed + // mid-session is NOT picked up until the process restarts. + // - Operator-set GIT_AUTHOR_*/GIT_COMMITTER_* still win: shellEnv only + // fills the vars that are absent from process.env (see below). + // + // resolveGitIdentity and gitIdentityCache live in this outer setup block, + // not inside shellEnv, precisely so the cache persists across every bash + // invocation instead of being rebuilt (and re-spawning two `git config` + // subprocesses) on each call. const gitIdentityCache = new Map() const resolveGitIdentity = Effect.fn("BashTool.resolveGitIdentity")(function* () { const worktree = Instance.worktree @@ -468,13 +482,16 @@ export const BashTool = Tool.define( if (cached) return cached // Non-git projects set worktree to "/"; never read git config at root. if (worktree === "/") { - const fallback = { name: AGENT_NAME, email: AGENT_EMAIL } + const fallback = { name: Git.FALLBACK_IDENTITY.name, email: Git.FALLBACK_IDENTITY.email } gitIdentityCache.set(worktree, fallback) return fallback } const name = (yield* gitSvc.run(["config", "user.name"], { cwd: worktree })).text().trim() const email = (yield* gitSvc.run(["config", "user.email"], { cwd: worktree })).text().trim() - const identity = { name: name || AGENT_NAME, email: email || AGENT_EMAIL } + const identity = { + name: name || Git.FALLBACK_IDENTITY.name, + email: email || Git.FALLBACK_IDENTITY.email, + } gitIdentityCache.set(worktree, identity) return identity }) @@ -562,8 +579,10 @@ export const BashTool = Tool.define( // back to the ANSI code page (GBK on zh-CN), producing mojibake. Force // UTF-8 for child Python processes on Windows. ...(process.platform === "win32" ? { PYTHONIOENCODING: "utf-8" } : {}), - // Git authorship floor: below process.env (operator override wins) but - // above plugin extra.env (a plugin can still override). + // Git authorship floor. Placed after process.env so the spread order + // reads naturally, but it can never clobber an operator value: gitFloor + // only ever holds keys that were absent from process.env. A plugin's + // extra.env comes last and so can still override the floor. ...gitFloor, ...extra.env, } diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index b82aacc14..bc48d2b04 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -283,13 +283,14 @@ export const layer: Layer.Layer< // `user@hostname` (e.g. `MI `), leaking the machine // hostname + wrong authorship into pushed commits. Resolve the parent's // identity (walks local->global->system) and pin it into the new - // worktree's own local config; fall back to a stable mimocode identity - // so the worktree is NEVER left without one. Reading an unset key exits + // worktree's own local config; fall back to Git.FALLBACK_IDENTITY (the + // one shared source of truth, also used by the bash env floor) so the + // worktree is NEVER left without one. Reading an unset key exits // non-zero / empty, which the `git()` runner returns as empty text. const parentName = (yield* git(["config", "user.name"], { cwd: ctx.worktree })).text.trim() const parentEmail = (yield* git(["config", "user.email"], { cwd: ctx.worktree })).text.trim() - const name = parentName || "MiMo" - const email = parentEmail || "mimo@xiaomi.com" + const name = parentName || Git.FALLBACK_IDENTITY.name + const email = parentEmail || Git.FALLBACK_IDENTITY.email yield* git(["config", "user.name", name], { cwd: info.directory }) yield* git(["config", "user.email", email], { cwd: info.directory }) }), diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 3e7cc6f8a..3cdd43e69 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -258,10 +258,10 @@ describe("tool.bash git identity floor", () => { const result = await Effect.runPromise( bash.execute({ command: printGitEnv, description: "print git env" }, ctx), ) - expect(result.metadata.output).toContain("GIT_AUTHOR_NAME=MiMo") - expect(result.metadata.output).toContain("GIT_AUTHOR_EMAIL=mimo@xiaomi.com") - expect(result.metadata.output).toContain("GIT_COMMITTER_NAME=MiMo") - expect(result.metadata.output).toContain("GIT_COMMITTER_EMAIL=mimo@xiaomi.com") + expect(result.metadata.output).toContain(`GIT_AUTHOR_NAME=${Git.FALLBACK_IDENTITY.name}`) + expect(result.metadata.output).toContain(`GIT_AUTHOR_EMAIL=${Git.FALLBACK_IDENTITY.email}`) + expect(result.metadata.output).toContain(`GIT_COMMITTER_NAME=${Git.FALLBACK_IDENTITY.name}`) + expect(result.metadata.output).toContain(`GIT_COMMITTER_EMAIL=${Git.FALLBACK_IDENTITY.email}`) }, }) } finally { @@ -269,7 +269,7 @@ describe("tool.bash git identity floor", () => { } }) - each("does not override an operator-set GIT_AUTHOR_NAME", async () => { + each("applies the floor per-variable, not all-or-nothing, when only GIT_AUTHOR_NAME is operator-set", async () => { const saved = savedEnv() restoreEnv({ GIT_AUTHOR_NAME: "Operator", @@ -286,10 +286,14 @@ describe("tool.bash git identity floor", () => { const result = await Effect.runPromise( bash.execute({ command: printGitEnv, description: "print git env" }, ctx), ) - // process.env value wins over the floor. + // The one operator-set var wins over the floor. expect(result.metadata.output).toContain("GIT_AUTHOR_NAME=Operator") - // Unset ones still get the floor. + // The other three are absent from process.env, so each still receives + // the floor independently. The tmpdir git fixture sets + // user.name=Test / user.email=test@mimocode.test. + expect(result.metadata.output).toContain("GIT_AUTHOR_EMAIL=test@mimocode.test") expect(result.metadata.output).toContain("GIT_COMMITTER_NAME=Test") + expect(result.metadata.output).toContain("GIT_COMMITTER_EMAIL=test@mimocode.test") }, }) } finally { diff --git a/packages/opencode/test/worktree/index.test.ts b/packages/opencode/test/worktree/index.test.ts index 6dbb9986e..f235eb346 100644 --- a/packages/opencode/test/worktree/index.test.ts +++ b/packages/opencode/test/worktree/index.test.ts @@ -2,6 +2,7 @@ import { describe, expect } from "bun:test" import { $ } from "bun" import { Effect, Layer } from "effect" import { Worktree } from "../../src/worktree" +import { Git } from "../../src/git" import { testEffect } from "../lib/effect" import { provideTmpdirInstance } from "../fixture/fixture" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" @@ -65,8 +66,8 @@ describe("Worktree.setup git identity", () => { const email = ( yield* Effect.promise(() => $`git config user.email`.cwd(info.directory).quiet().text()) ).trim() - expect(name).toBe("MiMo") - expect(email).toBe("mimo@xiaomi.com") + expect(name).toBe(Git.FALLBACK_IDENTITY.name) + expect(email).toBe(Git.FALLBACK_IDENTITY.email) // Sanity: identity is never left empty (the hostname-fallback trigger). expect(name.length).toBeGreaterThan(0) expect(email.length).toBeGreaterThan(0) From 829d00ffd735100e4ba6ab3af9b8cbce53d3063e Mon Sep 17 00:00:00 2001 From: wqymi Date: Tue, 28 Jul 2026 01:10:11 +0800 Subject: [PATCH 027/135] fix(provider): never send an empty-content message (Bedrock 400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live Bedrock 400 `messages.: user messages must have non-empty content` was traced to the AI SDK, not to our message array. `ai@6`'s `convertToLanguageModelMessage` strips empty text parts from user messages with no backfill: .filter((part) => part.type !== "text" || part.text !== "") That runs AFTER every ProviderTransform step, so a user message whose only text part is "" leaves our transform as a healthy length-1 array and reaches the provider as `content: []`. Emptiness therefore cannot be judged by `content.length` at this layer — it must be judged by what survives the SDK's own filter. The SDK's assistant branch has an escape for empty parts carrying providerOptions; the user branch does not, so even an empty text part holding a cache_control marker is stripped. Our only prior defense stripped empty parts in `normalizeMessages`, but gated on `@ai-sdk/anthropic`/`@ai-sdk/amazon-bedrock` — a Bedrock-backed gateway on any other npm got no protection at all. `normalizeContentArray` guarded only content shape and itself emitted `content: []`, and `ensureTrailingUserMessage` inspected only the trailing assistant, with a comment claiming an empty trailing message was "safe to send as-is". Add a provider-agnostic pre-send invariant, `ensureNonEmptyContent`, and make the two guards cooperate instead of fighting: - user -> BACKFILL a minimal non-empty text turn. Dropping it would end the request on an assistant, trading this 400 for the assistant-prefill 400 that #1703 fixed. - assistant -> DROP; it is residue, and the trailing-user guard that runs next re-establishes the prefill invariant. - tool -> leave untouched; `tool_result` blocks cannot be synthesized and injecting text would break tool_use/tool_result pairing. `normalizeContentArray` now backfills user/tool instead of blanking, and ordering is made explicit and documented: resolve empty content FIRST, then the trailing-assistant/prefill invariant. Tests assert both invariants together — no empty content AND the request still ends with user/tool — across anthropic, bedrock, openai-compatible and openai, plus an end-to-end case run through the real AI SDK prompt conversion that reproduces the captured wire payload. --- packages/opencode/src/provider/transform.ts | 119 +++++++- .../opencode/test/provider/transform.test.ts | 266 +++++++++++++++++- 2 files changed, 370 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 19b0ec80e..354ea406b 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -270,6 +270,92 @@ function supportsCacheMarkers(model: Provider.Model): boolean { // not an assistant prefill. const CONTINUATION_PROMPT = "Continue." +// Backfill text for a message whose content is structurally present but carries +// nothing a provider will accept. Same string as the continuation prompt: both +// mean "there is no new instruction here, keep going". +const EMPTY_CONTENT_PLACEHOLDER = CONTINUATION_PROMPT + +// Mirrors the AI SDK's OWN user-content filter. `ai@6` builds the wire payload in +// `convertToLanguageModelMessage`, whose user branch is: +// +// content: message.content.map((part) => convertPartToLanguageModelPart(part, ...)) +// .filter((part) => part.type !== "text" || part.text !== "") +// +// It runs AFTER every transform here and does NOT backfill, so a user message +// whose only text part is "" reaches the provider as `content: []` — exactly the +// shape observed in the live Bedrock 400 payload. Note the asymmetry: the SDK's +// assistant branch keeps an empty text part when it carries providerOptions +// (`|| part.providerOptions != null`); the user branch has no such escape, so +// even an empty text part holding a cache_control marker is stripped. +// +// Consequence: emptiness of a user message CANNOT be judged by `content.length` +// — at this layer the offending message is a length-1 array that looks fine. It +// must be judged by what SURVIVES this filter. +function sdkVisibleUserParts(content: readonly any[]): readonly any[] { + return content.filter((part) => !part || part.type !== "text" || part.text !== "") +} + +// The SDK's ASSISTANT branch uses a slightly looser predicate — an empty text +// part survives when it carries providerOptions: +// .filter((part) => part.type !== "text" || part.text !== "" || part.providerOptions != null) +// so assistant emptiness has to be judged against that rule, not the user one. +function sdkVisibleAssistantParts(content: readonly any[]): readonly any[] { + return content.filter( + (part) => !part || part.type !== "text" || part.text !== "" || part.providerOptions != null, + ) +} + +// True when a message will reach the provider with no usable content. +function hasNoSendableContent(msg: ModelMessage): boolean { + const content = msg.content as unknown + if (typeof content === "string") return content === "" + if (!Array.isArray(content)) return true + // Judge each role by the SDK's own post-filter view (see the notes above). + if (msg.role === "user") return sdkVisibleUserParts(content).length === 0 + if (msg.role === "assistant") return sdkVisibleAssistantParts(content).length === 0 + return content.length === 0 +} + +// THE global pre-send content invariant: no message may reach the provider with +// empty content. This layer never existed before — `normalizeContentArray` only +// guards content SHAPE, `normalizeMessages` only strips empty parts and only for +// `@ai-sdk/anthropic`/`@ai-sdk/amazon-bedrock` (so a Bedrock-backed gateway on any +// other npm got no protection at all), and `ensureTrailingUserMessage` inspects +// only the trailing assistant. An empty user message fell through all three seams +// and produced `messages.: user messages must have non-empty content`. +// +// Policy is per-role and deliberately asymmetric: +// - user → BACKFILL a minimal non-empty text turn. Dropping it would make +// the request end with an assistant message, which Bedrock rejects +// as a prefill — trading this 400 for the prefill 400. +// - assistant → DROP. It is residue with nothing to preserve, and the trailing +// user guard that runs next re-establishes the prefill invariant. +// - tool → LEAVE UNTOUCHED. A tool message's content must be `tool-result` +// blocks keyed to a preceding `tool-call`; we cannot synthesize a +// valid one, and injecting text would break tool_use/tool_result +// pairing (a different 400). The SDK's empty-text filter does not +// apply to the tool branch, and no empty tool message exists in +// any observed transcript, so there is nothing to repair here. +// +// Provider-agnostic on purpose: the AI SDK applies its stripping filter for every +// provider, so gating this on an npm package name is what created the hole. +export function ensureNonEmptyContent(msgs: ModelMessage[]): ModelMessage[] { + const result: ModelMessage[] = [] + for (const msg of msgs) { + if (!hasNoSendableContent(msg)) { + result.push(msg) + continue + } + if (msg.role === "assistant") continue + if (msg.role === "tool") { + result.push(msg) + continue + } + result.push({ ...msg, content: [{ type: "text", text: EMPTY_CONTENT_PLACEHOLDER }] } as ModelMessage) + } + return result +} + // True when an assistant ModelMessage carries no renderable content (no text and // no tool-call) — pure residue we can drop without losing anything. function isEmptyAssistant(msg: ModelMessage): boolean { @@ -292,13 +378,23 @@ function isEmptyAssistant(msg: ModelMessage): boolean { // user turn is appended so the list ends with a user message. Runs at the // pre-send choke point in `message()`, so it also self-heals history that // already ends in an assistant turn. +// +// ORDERING CONTRACT: `ensureNonEmptyContent` MUST run before this function. +// This guard only establishes "the list ends with user/tool"; it says nothing +// about whether that trailing message has usable content. Running it first and +// resolving emptiness second would let this function return a list ending in an +// empty user message (which is what shipped, and what produced the live 400), +// and resolving emptiness afterwards could drop that message again and re-open +// the prefill 400. Emptiness first, prefill second — the two cannot fight. export function ensureTrailingUserMessage(msgs: ModelMessage[]): ModelMessage[] { // Drop only trailing EMPTY assistant residue (nothing to preserve). let end = msgs.length while (end > 0 && isEmptyAssistant(msgs[end - 1])) end-- const trimmed = end === msgs.length ? msgs : msgs.slice(0, end) const last = trimmed[trimmed.length - 1] - // Already ends with user or tool (or empty) — safe to send as-is. + // Already ends with a user or tool message, so this is not a prefill. Their + // content is guaranteed non-empty by `ensureNonEmptyContent` (see the ordering + // contract above) — an empty trailing message is NOT safe to send as-is. if (!last || last.role !== "assistant") return trimmed // A content-bearing assistant is legitimately last: keep it and append a // minimal user turn so the request ends with a user message. @@ -457,14 +553,20 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage // Minimal crash guard: ensure msg.content is never a non-string non-array value // (object, undefined, null) that would blow up downstream `.map()` calls. // Strings are valid ModelMessage content (the AI SDK accepts content: string | -// Array) and are left untouched. Only genuinely-invalid types are normalized -// to a safe empty array so every downstream path can safely call `.map()`. +// Array) and are left untouched. Only genuinely-invalid types are normalized. +// +// Invalid content is BACKFILLED, not blanked, for roles the provider requires to +// be non-empty. Emitting `content: []` here would trade a crash for a 400 +// ("user messages must have non-empty content"), and blanking a user turn also +// re-opens the trailing-assistant prefill 400 once the empty message is dropped +// downstream. An assistant gets `[]` because it carries no obligation: the +// non-empty invariant drops empty assistant residue and the trailing-user guard +// then re-establishes the prefill invariant. function normalizeContentArray(msgs: ModelMessage[]): ModelMessage[] { return msgs.map((msg) => { if (typeof msg.content === "string" || Array.isArray(msg.content)) return msg - // object / undefined / null — not a valid ModelMessage content shape; - // wrap in an empty array so .map() downstream never throws. - return { ...msg, content: [] } as ModelMessage + if (msg.role === "assistant") return { ...msg, content: [] } as ModelMessage + return { ...msg, content: [{ type: "text", text: EMPTY_CONTENT_PLACEHOLDER }] } as ModelMessage }) } @@ -810,6 +912,11 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re msgs = limitImages(msgs, model) msgs = normalizeMessages(msgs, model, options) msgs = forceAnthropicReasoningContent(msgs, model) + // Ordering is load-bearing (see ensureTrailingUserMessage's ordering contract): + // resolve EMPTY content first, then the trailing-assistant/prefill invariant. + // Emptiness is provider-agnostic because the AI SDK strips empty user text + // parts for every provider, downstream of everything here. + msgs = ensureNonEmptyContent(msgs) // SAFE prefill guard: never let the request end with an assistant (prefill) // message a provider (e.g. Bedrock) would reject, without deleting a completed // reply. Drops only empty residue; appends a continuation user turn otherwise. diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 67df9eee8..c8fc1daa0 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1859,9 +1859,15 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => const result = ProviderTransform.message(msgs, openaiModel, {}) - expect(result).toHaveLength(3) - expect(result[0].content).toBe("") - expect(result[1].content).toHaveLength(1) + // The anthropic-only empty-PART stripping still does not run for other + // providers (that is what this test guards), but the provider-agnostic + // non-empty-content invariant DOES: npm-gating it is exactly what let an + // empty-content message reach a Bedrock-backed gateway. Both empty assistant + // messages here are residue with nothing to preserve, so they are dropped and + // the request correctly ends with the real user turn. + expect(result).toHaveLength(1) + expect(result[0].role).toBe("user") + expect(result[0].content).toBe("next") }) test("splits anthropic assistant messages when text trails tool calls", () => { @@ -4531,28 +4537,46 @@ describe("ProviderTransform.message - non-array content guard (j.map is not a fu expect(result[0].content).toBe("next") }) - test("undefined content is normalized to empty array (crash guard)", () => { + test("undefined content is normalized to a NON-EMPTY array (crash guard + content invariant)", () => { const msgs = [{ role: "user", content: undefined }] as any[] const result = ProviderTransform.message(msgs, genericModel, {}) expect(result).toHaveLength(1) + // The crash guard still holds: content is always an array so `.map()` is safe. expect(Array.isArray(result[0].content)).toBe(true) - expect(result[0].content).toEqual([]) + // ...but it must NOT be blanked to `[]`. A user message with empty content is + // rejected by Bedrock/Anthropic ("user messages must have non-empty content"), + // and dropping it instead would end the request on an assistant (prefill 400). + // Invalid user content is therefore BACKFILLED with a minimal text turn. + expect((result[0].content as any[]).length).toBeGreaterThan(0) + expect((result[0].content as any[])[0]).toMatchObject({ type: "text", text: "Continue." }) }) - test("null content is normalized to empty array (crash guard)", () => { + test("null content is normalized to a NON-EMPTY array (crash guard + content invariant)", () => { const msgs = [{ role: "user", content: null }] as any[] const result = ProviderTransform.message(msgs, genericModel, {}) expect(result).toHaveLength(1) + // The crash guard still holds: content is always an array so `.map()` is safe. expect(Array.isArray(result[0].content)).toBe(true) - expect(result[0].content).toEqual([]) + // ...but it must NOT be blanked to `[]`. A user message with empty content is + // rejected by Bedrock/Anthropic ("user messages must have non-empty content"), + // and dropping it instead would end the request on an assistant (prefill 400). + // Invalid user content is therefore BACKFILLED with a minimal text turn. + expect((result[0].content as any[]).length).toBeGreaterThan(0) + expect((result[0].content as any[])[0]).toMatchObject({ type: "text", text: "Continue." }) }) - test("object content is normalized to empty array (crash guard)", () => { + test("object content is normalized to a NON-EMPTY array (crash guard + content invariant)", () => { const msgs = [{ role: "user", content: { type: "text", text: "oops" } }] as any[] const result = ProviderTransform.message(msgs, genericModel, {}) expect(result).toHaveLength(1) + // The crash guard still holds: content is always an array so `.map()` is safe. expect(Array.isArray(result[0].content)).toBe(true) - expect(result[0].content).toEqual([]) + // ...but it must NOT be blanked to `[]`. A user message with empty content is + // rejected by Bedrock/Anthropic ("user messages must have non-empty content"), + // and dropping it instead would end the request on an assistant (prefill 400). + // Invalid user content is therefore BACKFILLED with a minimal text turn. + expect((result[0].content as any[]).length).toBeGreaterThan(0) + expect((result[0].content as any[])[0]).toMatchObject({ type: "text", text: "Continue." }) }) test("already-array content passes through unchanged", () => { @@ -4682,3 +4706,227 @@ describe("ProviderTransform.message - interleaved field: empty reasoning still s expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBe("") }) }) + +// Regression suite for the live Bedrock 400 +// `messages.: user messages must have non-empty content`. +// +// Root mechanism (verified verbatim against ai@6.0.168, convertToLanguageModelMessage): +// the SDK's USER branch strips empty text parts with no backfill — +// .filter((part) => part.type !== "text" || part.text !== "") +// — and it runs AFTER every ProviderTransform step. So a user message whose only +// text part is "" leaves our transform looking like a healthy length-1 array and +// arrives at the provider as `content: []`. +// +// Every test asserts BOTH invariants together, because fixing either one alone +// re-opens the other's 400: +// (1) no message reaches the provider with empty content, and +// (2) the request still ends with a user/tool message (no assistant prefill). +describe("ProviderTransform.message - non-empty content invariant (paired with the prefill invariant)", () => { + const modelFor = (npm: string, providerID = "anthropic", apiID = "claude-opus-5") => + ({ + id: `${providerID}/${apiID}`, + providerID, + api: { id: apiID, url: "https://example.invalid", npm }, + name: apiID, + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.003, output: 0.015, cache: { read: 0.0003, write: 0.00375 } }, + limit: { context: 200000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + }) as any + + // Emptiness as the PROVIDER sees it: replicate the AI SDK's user-content filter + // so these assertions catch the real failure shape, not just `content.length`. + const sdkVisible = (msg: any) => { + if (typeof msg.content === "string") return msg.content === "" ? [] : [{ type: "text", text: msg.content }] + if (!Array.isArray(msg.content)) return [] + if (msg.role !== "user") return msg.content + return msg.content.filter((p: any) => !p || p.type !== "text" || p.text !== "") + } + + const expectBothInvariants = (result: any[]) => { + const empty = result + .map((m, i) => ({ i, role: m.role, visible: sdkVisible(m).length })) + .filter((r) => r.visible === 0) + expect(empty).toEqual([]) + // Prefill invariant: must not end with an assistant message. + expect(result.length).toBeGreaterThan(0) + expect(result[result.length - 1].role).not.toBe("assistant") + } + + // The exact shape captured off the wire in the live incident: a content-bearing + // assistant followed by a user turn that the SDK would empty to `content: []`. + const liveIncidentShape = () => [ + { role: "user", content: [{ type: "text", text: "how many open PRs?" }] }, + { + role: "assistant", + content: [{ type: "text", text: "## 8 个 OPEN PR ..." }], + }, + { role: "user", content: [{ type: "text", text: "" }] }, + ] as any[] + + for (const npm of ["@ai-sdk/anthropic", "@ai-sdk/amazon-bedrock", "@ai-sdk/openai-compatible", "@ai-sdk/openai"]) { + test(`history ending in a content-bearing assistant + SDK-emptied user turn is repaired (${npm})`, () => { + const result = ProviderTransform.message(liveIncidentShape(), modelFor(npm), {}) + expectBothInvariants(result) + // The user turn is BACKFILLED, never dropped — dropping it would end the + // request on the assistant and trade this 400 for the prefill 400. + const last = result[result.length - 1] + expect(last.role).toBe("user") + // The assistant's completed reply is still present. + expect(JSON.stringify(result)).toContain("## 8 个 OPEN PR") + }) + } + + test("history ending in a content-bearing assistant (no trailing user) keeps the reply and appends a user turn", () => { + const msgs = [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { role: "assistant", content: [{ type: "text", text: "## 8 个 OPEN PR ..." }] }, + ] as any[] + + const result = ProviderTransform.message(msgs, modelFor("@ai-sdk/anthropic"), {}) + expectBothInvariants(result) + expect(JSON.stringify(result)).toContain("## 8 个 OPEN PR") + }) + + test("a message whose parts are all non-convertible/ignored (empty array content) is repaired, not dropped into a prefill", () => { + const msgs = [ + { role: "user", content: [{ type: "text", text: "start" }] }, + { role: "assistant", content: [{ type: "text", text: "done" }] }, + // Every part was ignored/non-convertible upstream — arrives already empty. + { role: "user", content: [] }, + ] as any[] + + const result = ProviderTransform.message(msgs, modelFor("@ai-sdk/openai-compatible"), {}) + expectBothInvariants(result) + expect(result[result.length - 1].role).toBe("user") + }) + + test("an empty text part carrying a cache_control marker is still repaired (SDK strips it despite providerOptions)", () => { + const msgs = [ + { role: "assistant", content: [{ type: "text", text: "reply" }] }, + { + role: "user", + content: [{ type: "text", text: "", providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } } }], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, modelFor("@ai-sdk/anthropic"), {}) + expectBothInvariants(result) + }) + + for (const bad of [undefined, null, { some: "object" }] as any[]) { + test(`ModelMessage arriving with content=${JSON.stringify(bad) ?? "undefined"} is backfilled for user, not blanked`, () => { + const msgs = [ + { role: "user", content: [{ type: "text", text: "start" }] }, + { role: "assistant", content: [{ type: "text", text: "reply" }] }, + { role: "user", content: bad }, + ] as any[] + + const result = ProviderTransform.message(msgs, modelFor("@ai-sdk/anthropic"), {}) + expectBothInvariants(result) + expect(result[result.length - 1].role).toBe("user") + }) + } + + test("empty-string user content is backfilled rather than removed", () => { + const msgs = [ + { role: "assistant", content: [{ type: "text", text: "reply" }] }, + { role: "user", content: "" }, + ] as any[] + + const result = ProviderTransform.message(msgs, modelFor("@ai-sdk/openai"), {}) + expectBothInvariants(result) + }) + + test("empty assistant residue is dropped and the prefill invariant still holds", () => { + const msgs = [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { role: "assistant", content: [] }, + ] as any[] + + const result = ProviderTransform.message(msgs, modelFor("@ai-sdk/anthropic"), {}) + expectBothInvariants(result) + expect(result).toHaveLength(1) + expect(result[0].role).toBe("user") + }) + + test("a trailing tool message is left alone and satisfies both invariants", () => { + const msgs = [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "call_1", toolName: "read", input: {} }], + }, + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "call_1", toolName: "read", output: { type: "text", value: "ok" } }], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, modelFor("@ai-sdk/anthropic"), {}) + expectBothInvariants(result) + expect(result[result.length - 1].role).toBe("tool") + }) +}) + +describe("ProviderTransform.message - end-to-end through the AI SDK's own wire conversion", () => { + // The strongest form of the regression: run our transform output through the + // real ai@6 prompt conversion and assert the WIRE payload has no empty content. + // This is the layer that produced the incident and that unit-level assertions on + // `content.length` cannot see. + test("no wire message has empty content, and the wire still ends with a user turn", async () => { + const { convertToLanguageModelPrompt } = await import("ai/internal") + const model = { + id: "anthropic/claude-opus-5", + providerID: "anthropic", + // Deliberately NOT @ai-sdk/anthropic: the anthropic-only empty-part filter in + // normalizeMessages would mask the defect. The live incident hit a + // Bedrock-backed gateway on a non-anthropic npm, which had no protection. + api: { id: "claude-opus-5", url: "https://example.invalid", npm: "@ai-sdk/openai-compatible" }, + name: "claude-opus-5", + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.003, output: 0.015, cache: { read: 0.0003, write: 0.00375 } }, + limit: { context: 200000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + } as any + + const msgs = [ + { role: "user", content: [{ type: "text", text: "how many open PRs?" }] }, + { role: "assistant", content: [{ type: "text", text: "## 8 个 OPEN PR ..." }] }, + { role: "user", content: [{ type: "text", text: "" }] }, + ] as any[] + + const out = ProviderTransform.message(msgs, model, {}) + const wire = (await convertToLanguageModelPrompt({ + prompt: { messages: out, system: undefined }, + supportedUrls: {}, + download: undefined, + })) as any[] + + const empty = wire + .map((m, i) => ({ i, role: m.role, len: Array.isArray(m.content) ? m.content.length : -1 })) + .filter((r) => r.len === 0) + expect(empty).toEqual([]) + expect(wire[wire.length - 1].role).not.toBe("assistant") + }) +}) From fe1ab5b7d9a9bc0fead2caefa70eb435a0698168 Mon Sep 17 00:00:00 2001 From: Jinyu Xiang Date: Tue, 28 Jul 2026 12:39:50 +0800 Subject: [PATCH 028/135] feat(exec): restore MCP dispatch with request-scoped gating, structured results, and live sub-call trace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore the toolScriptMcp late-bound ref removed by 8c29041a, now populated per-request by SessionPrompt with only the active MCP view — under mcp_tool_search gating exec sees exactly the search-loaded tools, so it cannot bypass the discovery gate. Dispatch reuses SessionPrompt's wrapped executes (permission ask, plugin hooks, metrics, truncation). - MCP structuredContent crosses into the guest pre-parsed as `structured` so scripts can aggregate data without re-parsing text output. - publishProgress ships a bounded trace tail (last 20 calls) and the final metadata carries it over; the TUI renders the last 5 sub-calls live and keeps them visible after completion. - New MIMOCODE_ENABLE_EXEC_TOOL flag exposes exec to all models (was GPT-toolset only). --- .../src/cli/cmd/tui/routes/session/index.tsx | 19 +++ packages/opencode/src/flag/flag.ts | 3 + packages/opencode/src/session/prompt.ts | 14 ++ packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/src/tool/tool-script-ref.ts | 13 ++ packages/opencode/src/tool/tool-script.ts | 102 ++++++++++++-- packages/opencode/src/tool/tool-script.txt | 1 + .../opencode/test/tool/tool-script.test.ts | 133 +++++++++++++++++- 8 files changed, 271 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index c21e6b136..5cd8ff0bb 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -2316,6 +2316,22 @@ function ToolScript(props: ToolProps) { if (isRunning()) return base return failed() ? `${status()} · ${base}` : base }) + // Per-call trace tail published live via ctx.metadata (see publishProgress + // in tool-script.ts). While running, show the last few sub-calls under the + // summary line so long batches aren't a black box. + type RecentCall = { name: string; status: string; durationMs: number; error?: string } + const recent = createMemo(() => { + const r = meta().recent as RecentCall[] | undefined + return Array.isArray(r) ? r : [] + }) + const recentLines = createMemo(() => + recent() + .slice(-5) + .map( + (t) => + ` ${t.status === "error" ? "✗" : "✓"} ${t.name} [${t.durationMs}ms]${t.error ? ` ${t.error.slice(0, 80)}` : ""}`, + ), + ) const code = createMemo(() => ((props.input.code as string | undefined) ?? "").trim()) // exec embeds nested tool output (a `bash` call's stdout) into @@ -2343,6 +2359,9 @@ function ToolScript(props: ToolProps) { > {clip(code())} + 0}> + {recentLines().join("\n")} + {clip(output())} diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index f78d0ac0f..f1bf53217 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -220,6 +220,9 @@ export const Flag = { MIMOCODE_EXPERIMENTAL_OXFMT: MIMOCODE_EXPERIMENTAL || truthy("MIMOCODE_EXPERIMENTAL_OXFMT"), MIMOCODE_EXPERIMENTAL_LSP_TY: truthy("MIMOCODE_EXPERIMENTAL_LSP_TY"), MIMOCODE_EXPERIMENTAL_LSP_TOOL: MIMOCODE_EXPERIMENTAL || truthy("MIMOCODE_EXPERIMENTAL_LSP_TOOL"), + // Defaults to OFF: exec (tool_script orchestration) is registered only for + // GPT-toolset models. Opt in here to expose it to every model. + MIMOCODE_ENABLE_EXEC_TOOL: truthy("MIMOCODE_ENABLE_EXEC_TOOL"), // Defaults to OFF for non-GPT models. GPT models enable MCP Tool Search in // SessionPrompt regardless of this flag. Opt in here to enable it for every // function-calling model. diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3955d8039..1900db63e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -125,6 +125,7 @@ import { type McpToolSearchMetadata, } from "@/tool/mcp-tool-search" import { isMcpToolSearchEnabled } from "@/tool/gpt" +import { toolScriptMcp } from "@/tool/tool-script-ref" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -1388,6 +1389,19 @@ NOTE: At any point in time through this workflow you should feel free to ask the } loadedMcpTools.forEach((name) => activeTools.add(name)) + // Populate the exec sandbox's MCP view (late-bound ref, see + // tool-script-ref.ts) with the REQUEST-SCOPED set: exactly the MCP tools + // active for this request. Under mcp_tool_search gating that means only + // search-loaded tools — exec must not bypass the discovery gate. The map + // is rebuilt on every resolveTools pass, so the view tracks each turn. + const execMcpView: Record = {} + for (const [key] of mcpTools) { + if (!tools[key] || !activeTools.has(key)) continue + if (key === MCP_TOOL_SEARCH_ID) continue + execMcpView[key] = tools[key] + } + toolScriptMcp.current = () => Effect.succeed(execMcpView) + return { tools, activeTools: [...activeTools].filter((name) => tools[name]), diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 40f824594..f5bee6f61 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -378,7 +378,7 @@ export const layer = Layer.effect( }) { const useGPTTools = usesGPTToolset(input.modelID) let filtered = (yield* all()).filter((tool) => { - if (tool.id === ToolScriptTool.id) return useGPTTools + if (tool.id === ToolScriptTool.id) return useGPTTools || Flag.MIMOCODE_ENABLE_EXEC_TOOL if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) { if (tool.id === WebSearchTool.id) { return ( diff --git a/packages/opencode/src/tool/tool-script-ref.ts b/packages/opencode/src/tool/tool-script-ref.ts index 48cc95522..eae259d1b 100644 --- a/packages/opencode/src/tool/tool-script-ref.ts +++ b/packages/opencode/src/tool/tool-script-ref.ts @@ -6,6 +6,7 @@ // the registry layer populates this module-local reference on initialisation and // the tool reads it at call time. import type { Effect } from "effect" +import type { Tool as AiTool } from "ai" import type { Agent } from "../agent/agent" import type { ModelID, ProviderID } from "../provider/schema" import type * as Tool from "./tool" @@ -16,6 +17,18 @@ export const toolScriptRegistry: { | undefined } = { current: undefined } +// MCP tools live outside ToolRegistry (SessionPrompt assembles them straight +// from MCP.Service), so exec reaches them through this second ref, populated +// per-request by the SessionPrompt layer. The populated map is the +// REQUEST-SCOPED view: when mcp_tool_search gating is active, only tools the +// model has already loaded via search are present — exec must not become a +// backdoor around the discovery gate. Reusing the ref pattern keeps MCP's +// layer out of the registry graph (providing MCP.defaultLayer to the registry +// would spin up a SECOND set of MCP client connections). +export const toolScriptMcp: { + current: (() => Effect.Effect>) | undefined +} = { current: undefined } + // Agent control-flow tools make no sense inside a script (they steer the // conversation, not data) — excluded from both the declared API and dispatch. export const TOOL_SCRIPT_EXCLUDED = new Set([ diff --git a/packages/opencode/src/tool/tool-script.ts b/packages/opencode/src/tool/tool-script.ts index 81fbe5803..eddf5b556 100644 --- a/packages/opencode/src/tool/tool-script.ts +++ b/packages/opencode/src/tool/tool-script.ts @@ -3,12 +3,13 @@ import os from "os" import fs from "fs" import path from "path" import { Effect } from "effect" +import type { Tool as AiTool } from "ai" import { EffectBridge, InstanceState } from "@/effect" import { Log, Filesystem } from "@/util" import { Agent } from "@/agent/agent" import type { ModelID, ProviderID } from "../provider/schema" import { evalScript, type HostFn } from "../workflow/sandbox" -import { toolScriptRegistry, TOOL_SCRIPT_ALIASES, TOOL_SCRIPT_EXCLUDED } from "./tool-script-ref" +import { toolScriptRegistry, toolScriptMcp, TOOL_SCRIPT_ALIASES, TOOL_SCRIPT_EXCLUDED } from "./tool-script-ref" import DESCRIPTION from "./tool-script.txt" import * as Tool from "./tool" import * as Truncate from "./truncate" @@ -25,6 +26,7 @@ const MAX_RESULT_BYTES = 256 * 1024 const MAX_LOG_BYTES = 64 * 1024 const MAX_CODE_BYTES = 128 * 1024 const MAX_FILE_BYTES = 10 * 1024 * 1024 +const TRACE_TAIL_ENTRIES = 20 /** JSON Schema (zod v4 toJSONSchema output) → compact TS type text. Best-effort: * anything unrecognized renders as `unknown`, which is safe for declarations. */ @@ -82,10 +84,12 @@ export function renderToolScriptDeclarations(defs: Tool.Def[]): string { }) return [ "```ts", - "type ToolResult = { title: string; output: string; metadata: Record }", + "type ToolResult = { title: string; output: string; metadata: Record; structured?: unknown }", "declare const tools: {", ...lines, ...aliasLines, + " /** Active MCP tools (if any) are also callable as tools.(input). MCP results carry parsed structuredContent in `structured` when the server provides it. */", + " [mcpToolName: string]: (input: Record) => Promise", "}", "// Raw file IO for machine-to-machine data (pipelines across executions).", "declare const files: {", @@ -340,10 +344,21 @@ export const ToolScriptTool = Tool.define( } return counts } + // Bounded per-call trace tail for the TUI (last N calls, error text + // truncated) — kept small so metadata deltas stay cheap on 500-call + // runs. Re-published on terminal returns for the same reason as + // tally(): completeToolCall replaces part metadata. + const recentTail = () => + trace.slice(-TRACE_TAIL_ENTRIES).map((t) => ({ + name: t.name, + status: t.status, + durationMs: t.durationMs, + ...(t.error && { error: t.error.slice(0, 200) }), + })) if (Buffer.byteLength(params.code, "utf8") > MAX_CODE_BYTES) { return { title: "code too large", - metadata: { status: "code_error", toolCalls: 0, counts: tally() }, + metadata: { status: "code_error", toolCalls: 0, counts: tally(), recent: recentTail() }, output: `\n\ncode exceeds ${MAX_CODE_BYTES} bytes\n\n`, } } @@ -363,6 +378,14 @@ export const ToolScriptTool = Tool.define( ) ).filter((def) => !TOOL_SCRIPT_EXCLUDED.has(def.id) && (!whitelist || whitelist.has(def.id))) const byId = new Map(defs.map((def) => [def.id, def])) + // MCP tools (late-bound ref, populated per-request by SessionPrompt + // with the request-scoped view — search-gated tools only appear after + // the model loaded them via mcp_tool_search). Builtin ids win on + // collision — an MCP server must not shadow `read`/`grep`. + const mcpTools = toolScriptMcp.current ? yield* toolScriptMcp.current() : {} + const mcpById = new Map( + Object.entries(mcpTools).filter(([id]) => !byId.has(id) && (!whitelist || whitelist.has(id))), + ) // Non-git projects report worktree === "/" (see Instance.containsPath) — // "/" as a jail root would allow EVERYTHING. Fall back to the project // directory in that case. Relative guest paths resolve against roots[0]. @@ -406,7 +429,7 @@ export const ToolScriptTool = Tool.define( if (typeof transpiled === "object") { return { title: "transpile error", - metadata: { status: "code_error", toolCalls: 0, counts: tally() }, + metadata: { status: "code_error", toolCalls: 0, counts: tally(), recent: recentTail() }, output: `\n\n${transpiled.error}\n\n`, } } @@ -417,12 +440,18 @@ export const ToolScriptTool = Tool.define( const withSlot = makeSemaphore(MAX_CONCURRENT) // Live progress for the TUI: after each settled call, publish the - // aggregated per-tool counts through the OUTER part's metadata (each - // ctx.metadata fires a part delta the ToolScript view renders - // reactively). Fire-and-forget — progress must never fail a call. + // aggregated per-tool counts plus a bounded tail of per-call trace + // entries through the OUTER part's metadata (each ctx.metadata fires + // a part delta the ToolScript view renders reactively). The tail is + // capped so metadata deltas stay small on 500-call runs. + // Fire-and-forget — progress must never fail a call. const publishProgress = () => { bridge - .promise(ctx.metadata({ metadata: { running: true, toolCalls: trace.length, counts: tally() } })) + .promise( + ctx.metadata({ + metadata: { running: true, toolCalls: trace.length, counts: tally(), recent: recentTail() }, + }), + ) .catch(() => {}) } @@ -430,7 +459,8 @@ export const ToolScriptTool = Tool.define( const id = String(name) const alias = TOOL_SCRIPT_ALIASES[id as keyof typeof TOOL_SCRIPT_ALIASES] const def = byId.get(alias ?? id) - if (!def) return Promise.reject(new Error(`unknown tool: ${id}`)) + const mcpDef = def ? undefined : mcpById.get(id) + if (!def && !mcpDef) return Promise.reject(new Error(`unknown tool: ${id}`)) calls++ if (calls > maxToolCalls) return Promise.reject(new Error(`tool call budget exceeded (${maxToolCalls} per execution)`)) @@ -443,14 +473,58 @@ export const ToolScriptTool = Tool.define( // title in the UI — swallow it; the trace covers observability. metadata: () => Effect.void, } + // MCP path: the map holds SessionPrompt's WRAPPED executes, so the + // full direct-call pipeline applies unchanged — permission ask, + // plugin before/after hooks, metrics, normalizeToolResult folding, + // truncation. Here we only adapt the wrapped result shape for the + // guest: structuredContent (when the server sent it) crosses as a + // parsed value under `structured` so scripts can filter/aggregate + // without re-parsing text; media attachments cannot cross the + // sandbox string boundary and are dropped with a note. + const executeMcp = (tool: AiTool) => + Effect.tryPromise({ + try: () => + Promise.resolve( + tool.execute!(args ?? {}, { + toolCallId: subCtx.callID, + messages: [], + abortSignal: ctx.abort, + }), + ), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }).pipe( + Effect.map((result) => { + const r = result as { + output?: unknown + metadata?: { mcp?: { structuredContent?: unknown } } + attachments?: unknown[] + } + const structured = r?.metadata?.mcp?.structuredContent + const dropped = Array.isArray(r?.attachments) && r.attachments.length + ? `\n[note: ${r.attachments.length} non-text attachment(s) dropped — binary content cannot cross the exec sandbox]` + : "" + return { + title: id, + output: String(r?.output ?? "") + dropped, + metadata: (r?.metadata ?? {}) as Record, + ...(structured !== undefined && { structured }), + } + }), + ) return withSlot(() => bridge - .promise(def.execute(args, subCtx)) + .promise(def ? def.execute(args, subCtx) : executeMcp(mcpDef!)) .then( (result) => { trace.push({ name: id, status: "success", durationMs: Date.now() - start }) publishProgress() - return { title: result.title, output: result.output, metadata: result.metadata } + const structured = (result as { structured?: unknown }).structured + return { + title: result.title, + output: result.output, + metadata: result.metadata, + ...(structured !== undefined && { structured }), + } }, (err) => { const message = err instanceof Error ? err.message : String(err) @@ -554,7 +628,7 @@ return { __undef: __out.value === undefined, json: __out.value === undefined ? " log.warn("exec failed", { status, message: explained.slice(0, 500) }) return { title: status, - metadata: { status, toolCalls: trace.length, counts: tally() }, + metadata: { status, toolCalls: trace.length, counts: tally(), recent: recentTail() }, output: `\n\n${explained}\n\n${logBlock}${traceBlock}`, } } @@ -573,14 +647,14 @@ return { __undef: __out.value === undefined, json: __out.value === undefined ? " if (returnedBytes > MAX_RESULT_BYTES) { return { title: "result too large", - metadata: { status: "budget_exceeded", toolCalls: trace.length, counts: tally() }, + metadata: { status: "budget_exceeded", toolCalls: trace.length, counts: tally(), recent: recentTail() }, output: `\n\nreturned value is ${returnedBytes} bytes (max ${MAX_RESULT_BYTES}). Aggregate or slice the data before returning.\n\n${warningsBlock}${logBlock}${traceBlock}`, } } return { title: `${trace.length} tool calls`, - metadata: { status: "completed", toolCalls: trace.length, counts: tally() }, + metadata: { status: "completed", toolCalls: trace.length, counts: tally(), recent: recentTail() }, output: `\n\n${returnedText}\n\n${warningsBlock}${logBlock}${traceBlock}`, } }).pipe(Effect.orDie), diff --git a/packages/opencode/src/tool/tool-script.txt b/packages/opencode/src/tool/tool-script.txt index 343920d58..b940a03b5 100644 --- a/packages/opencode/src/tool/tool-script.txt +++ b/packages/opencode/src/tool/tool-script.txt @@ -12,6 +12,7 @@ Use `exec` when JavaScript can batch multiple independent tool calls, programmat The script environment provides JavaScript built-ins plus `tools`, `files`, and `console`. It does not provide Node.js modules, `import`, `require`, `process`, `fetch`, or timers. Use the available tools for external operations. - Call tools with `await tools.name(input)`. +- Active MCP tools are also callable as `tools.(input)` with the same permission checks as direct calls. When the MCP server returns structured data, the result carries it pre-parsed in the `structured` field. - Use `Promise.all` or `Promise.allSettled` only for independent calls; at most 8 run concurrently. - Return a small JSON-serializable aggregate. Circular values, BigInt, and throwing getters fail execution. - Use `console.log` only for debugging; logs are included in the result. diff --git a/packages/opencode/test/tool/tool-script.test.ts b/packages/opencode/test/tool/tool-script.test.ts index f8445bab8..c24d66841 100644 --- a/packages/opencode/test/tool/tool-script.test.ts +++ b/packages/opencode/test/tool/tool-script.test.ts @@ -8,7 +8,7 @@ import { evalScript } from "../../src/workflow/sandbox" import { Agent } from "../../src/agent/agent" import { Truncate, Tool } from "../../src/tool" import { ToolScriptTool, renderToolScriptDeclarations } from "../../src/tool/tool-script" -import { toolScriptRegistry, TOOL_SCRIPT_EXCLUDED } from "../../src/tool/tool-script-ref" +import { toolScriptRegistry, toolScriptMcp, TOOL_SCRIPT_EXCLUDED } from "../../src/tool/tool-script-ref" import { Instance } from "../../src/project/instance" describe("sandbox non-deterministic mode", () => { @@ -94,10 +94,13 @@ async function runToolScript( maxToolCalls?: number timeoutSeconds?: number toolWhitelist?: string[] + mcp?: Record }, ) { const prev = toolScriptRegistry.current + const prevMcp = toolScriptMcp.current toolScriptRegistry.current = () => Effect.succeed(defs) + toolScriptMcp.current = opts?.mcp ? () => Effect.succeed(opts.mcp!) : undefined try { return await Instance.provide({ directory: tmp, @@ -128,6 +131,7 @@ async function runToolScript( }) } finally { toolScriptRegistry.current = prev + toolScriptMcp.current = prevMcp } } @@ -564,3 +568,130 @@ describe("renderToolScriptDeclarations", () => { }) }) + +describe("exec MCP dispatch", () => { + // Mimics the SessionPrompt-wrapped MCP execute: resolves with the normalized + // {output, metadata, attachments} shape (permission/hooks/truncation already + // applied by the wrapper), rejects on tool failure. + function fakeMcpTool(execute: (args: any) => Promise) { + return { + description: "fake mcp tool", + inputSchema: z.object({}), + execute, + } + } + + test("MCP tool is callable and returns output text", async () => { + const mcp = { + srv_search: fakeMcpTool(async (args) => ({ + output: `found: ${args.query}`, + metadata: { mcp: { isError: false } }, + attachments: [], + })), + } + const result = await runToolScript( + `const r = await tools.srv_search({ query: "hello" }); return r.output`, + [], + undefined, + { mcp }, + ) + expect(result.metadata.status).toBe("completed") + expect(result.output).toContain("found: hello") + }) + + test("structuredContent crosses into the guest as parsed `structured`", async () => { + const mcp = { + srv_data: fakeMcpTool(async () => ({ + output: "3 items", + metadata: { mcp: { isError: false, structuredContent: { items: [1, 2, 3], total: 3 } } }, + attachments: [], + })), + } + const result = await runToolScript( + `const r = await tools.srv_data({}); + return { total: r.structured.total, doubled: r.structured.items.map((x) => x * 2) }`, + [], + undefined, + { mcp }, + ) + expect(result.metadata.status).toBe("completed") + expect(result.output).toContain('"total": 3') + expect(result.output).toContain("4") + expect(result.output).toContain("6") + }) + + test("MCP failure rejects catchably inside the guest", async () => { + const mcp = { + srv_fail: fakeMcpTool(async () => { + throw new Error("server exploded") + }), + } + const result = await runToolScript( + `try { await tools.srv_fail({}) } catch (e) { return "caught: " + e.message }`, + [], + undefined, + { mcp }, + ) + expect(result.metadata.status).toBe("completed") + expect(result.output).toContain("caught: srv_fail: server exploded") + }) + + test("builtin id wins on collision with an MCP tool", async () => { + const mcp = { + echo: fakeMcpTool(async () => ({ output: "mcp version", metadata: {}, attachments: [] })), + } + const result = await runToolScript( + `const r = await tools.echo({ value: "x" }); return r.output`, + [fakeDef("echo", async () => "builtin version")], + undefined, + { mcp }, + ) + expect(result.output).toContain("builtin version") + }) + + test("attachments are dropped with a note", async () => { + const mcp = { + srv_img: fakeMcpTool(async () => ({ + output: "here is your chart", + metadata: { mcp: { isError: false } }, + attachments: [{ mime: "image/png", url: "data:image/png;base64,xxxx" }], + })), + } + const result = await runToolScript( + `const r = await tools.srv_img({}); return r.output`, + [], + undefined, + { mcp }, + ) + expect(result.output).toContain("here is your chart") + expect(result.output).toContain("non-text attachment(s) dropped") + }) + + test("MCP calls count against the tool call budget", async () => { + const mcp = { + srv_a: fakeMcpTool(async () => ({ output: "a", metadata: {}, attachments: [] })), + } + const result = await runToolScript( + `for (let i = 0; i < 3; i++) await tools.srv_a({}); return "done"`, + [], + undefined, + { mcp, maxToolCalls: 2 }, + ) + expect(result.metadata.status).not.toBe("completed") + expect(result.output).toContain("budget exceeded") + }) + + test("whitelist filters MCP tools too", async () => { + const mcp = { + srv_blocked: fakeMcpTool(async () => ({ output: "should not run", metadata: {}, attachments: [] })), + } + const result = await runToolScript( + `try { await tools.srv_blocked({}) } catch (e) { return "denied: " + e.message }`, + [], + undefined, + { mcp, toolWhitelist: ["exec"] }, + ) + expect(result.output).toContain("denied:") + expect(result.output).toContain("unknown tool") + }) +}) From 3613792505c83d85688aacc4b790a78ef355189d Mon Sep 17 00:00:00 2001 From: Jinyu Xiang Date: Tue, 28 Jul 2026 13:59:52 +0800 Subject: [PATCH 029/135] feat(tui): render exec collapsed view as one clickable block with sub-call trace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert exec to the compact collapsed-by-default view (#1941 made the script source a always-visible BlockTool, which floods long transcripts) but keep its stripAnsi fix. Collapsed state is now a single BlockTool holding the summary title and the recent sub-call trace — one bordered, hover-highlighted click target, kept after completion. Falls back to a one-line InlineTool until the first sub-call lands. --- .../src/cli/cmd/tui/routes/session/index.tsx | 101 ++++++++++-------- 1 file changed, 54 insertions(+), 47 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 5cd8ff0bb..56b30e3ba 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -2286,14 +2286,14 @@ function WorkItemTask(props: ToolProps) { ) } -// Renderer for the `exec` batch-orchestration tool, shaped like : once the -// script source has streamed in it lives in a BlockTool, and collapsing only caps -// how much of the script and its output are shown (head + "…") instead of hiding -// both behind a one-line summary. The title carries the live aggregated call -// counts published through ctx.metadata. +// Renderer for the `exec` batch-orchestration tool. Collapsed view is a compact +// BlockTool: summary title (spinner + live aggregated call counts published +// through ctx.metadata) plus the last few sub-calls — one bordered clickable +// unit, visible while running and kept after completion. Clicking swaps to the +// full BlockTool with code, result, logs and trace. Before any sub-call lands +// it stays a one-line InlineTool. function ToolScript(props: ToolProps) { const { theme } = useTheme() - const ctx = use() const [expanded, setExpanded] = createSignal(false) const isRunning = createMemo(() => props.part.state.status === "running") const meta = createMemo(() => @@ -2317,8 +2317,9 @@ function ToolScript(props: ToolProps) { return failed() ? `${status()} · ${base}` : base }) // Per-call trace tail published live via ctx.metadata (see publishProgress - // in tool-script.ts). While running, show the last few sub-calls under the - // summary line so long batches aren't a black box. + // in tool-script.ts). Shown under the summary line while running AND after + // completion — the terminal returns re-publish it (completeToolCall replaces + // part metadata) so the trace doesn't vanish the moment a run finishes. type RecentCall = { name: string; status: string; durationMs: number; error?: string } const recent = createMemo(() => { const r = meta().recent as RecentCall[] | undefined @@ -2332,51 +2333,57 @@ function ToolScript(props: ToolProps) { ` ${t.status === "error" ? "✗" : "✓"} ${t.name} [${t.durationMs}ms]${t.error ? ` ${t.error.slice(0, 80)}` : ""}`, ), ) - - const code = createMemo(() => ((props.input.code as string | undefined) ?? "").trim()) // exec embeds nested tool output (a `bash` call's stdout) into // and , so escape sequences reach this renderer raw. const output = createMemo(() => stripAnsi(props.output?.trim() ?? "")) - const columns = createMemo(() => Collapse.columns(ctx.width)) - const overflow = createMemo( - () => - Collapse.rows(code(), columns()) > TOOL_BLOCK_COLLAPSE_MAX_ROWS || - Collapse.rows(output(), columns()) > TOOL_BLOCK_COLLAPSE_MAX_ROWS, - ) - const clip = (content: string) => { - if (expanded()) return content - return Collapse.clip(content, columns(), TOOL_BLOCK_COLLAPSE_MAX_ROWS) - } return ( - - - setExpanded((prev) => !prev) : undefined} + 0} + fallback={ + setExpanded(true)} + > + exec {summary()} + + } > - - {clip(code())} - 0}> - {recentLines().join("\n")} - - - {clip(output())} - - - {expanded() ? "Click to collapse" : "Click to expand"} - - - - - - - exec - - - + setExpanded(true)} + > + {recentLines().join("\n")} + + + } + > + setExpanded(false)}> + + {((props.input.code as string | undefined) ?? "").trim()} + 0}> + {recentLines().join("\n")} + + + {output()} + + Click to collapse + + + ) } From e0edc52d41ee020d517e7732128bbbd445a936ed Mon Sep 17 00:00:00 2001 From: Jinyu Xiang Date: Tue, 28 Jul 2026 14:18:23 +0800 Subject: [PATCH 030/135] feat(tui): add Click to expand hint to exec collapsed block --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 56b30e3ba..577bdfd9c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -2367,6 +2367,7 @@ function ToolScript(props: ToolProps) { onClick={() => setExpanded(true)} > {recentLines().join("\n")} + Click to expand } From e9e42b624b4bc77bbbadea66b951295b63d41bd7 Mon Sep 17 00:00:00 2001 From: wqymi Date: Tue, 28 Jul 2026 14:42:05 +0800 Subject: [PATCH 031/135] fix(tui): keep the transcript alive after a directory switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entering a different agent that switches the working directory (Tab into Orchestrator from another project, or the worktree dialog) left the whole transcript blank and unrecoverable without restarting the TUI. Mechanism, measured on a live TUI with temporary instrumentation: `dispose + switchDirectory + bootstrap` produces TWO concurrent bootstraps. The `server.instance.disposed` handler starts one whose HTTP requests were built from the pre-switch client, so it describes the OLD directory, and it resolves LAST (the just-disposed old instance has to be re-created, which is slower than the new one). Its `project.sync()` write then rolls `instance.path` back to the launch directory. `useEvent` filters every event on `project.instance.directory()`, so from that moment on every `message.updated` / `message.part.updated` for the live directory is dropped (220 dropped events observed for a single prompt whose reply was already in the DB). The only remaining path to content, the full HTTP sync at `routes/session/index.tsx:274`, is short-circuited forever by `fullSyncedSessions`, which was only cleared on a workspace change — hence "unrecoverable in-session". - project.sync(): drop a path/project response whose request was issued for a directory the client has since left. - bootstrap(): drop stale provider/agent/config/session writes for the same reason, and invalidate fullSyncedSessions on a directory change as well as a workspace change. An unchanged workspace+directory still short-circuits, so re-entering an already-synced session does not refetch its transcript. Test asserts both halves and fails without the fix (`"/tmp/blanktx-a"` instead of `"/tmp/blanktx-b"`, and 1 transcript fetch instead of 2). --- .../src/cli/cmd/tui/context/project.tsx | 10 + .../opencode/src/cli/cmd/tui/context/sync.tsx | 20 +- .../test/cli/tui/directory-switch.test.tsx | 201 ++++++++++++++++++ 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/cli/tui/directory-switch.test.tsx diff --git a/packages/opencode/src/cli/cmd/tui/context/project.tsx b/packages/opencode/src/cli/cmd/tui/context/project.tsx index 9e98eabad..8cd072bd3 100644 --- a/packages/opencode/src/cli/cmd/tui/context/project.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/project.tsx @@ -35,11 +35,21 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex async function sync() { const workspace = store.workspace.current + const directory = sdk.directory const [path, project] = await Promise.all([ sdk.client.path.get({ workspace }), sdk.client.project.current({ workspace }), ]) + // A directory switch (worktree dialog, orchestrator entry) disposes the old + // instance and bootstraps the new one, and the resulting + // server.instance.disposed event fires a SECOND bootstrap whose requests + // were built from the pre-switch client. That stale run can resolve last + // and describe a directory the client no longer talks to; writing it makes + // instance.path disagree with sdk.directory, which silently drops every + // live event in useEvent (it filters on instance.directory()). + if (sdk.directory !== directory) return + batch(() => { setStore("instance", "path", reconcile(path.data || defaultPath)) setStore("project", "id", project.data?.id) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 5c4561557..d5c98aac8 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -267,6 +267,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const fullSyncedSessions = new Set() let syncedWorkspace = project.workspace.current() + let syncedDirectory = sdk.directory event.subscribe((event) => { switch (event.type) { @@ -696,10 +697,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ async function bootstrap(input: { fatal?: boolean } = {}) { const fatal = input.fatal ?? true const workspace = project.workspace.current() - if (workspace !== syncedWorkspace) { + const directory = sdk.directory + // fullSyncedSessions exists to keep a re-entered session from refetching its + // whole transcript on every navigation. That cache is scoped to the data + // source, so it must be dropped whenever the source changes — a workspace + // switch OR a directory switch (sdk.switchDirectory). Without the directory + // half, a session synced before the switch can never be re-synced, so any + // update missed during the switch window stays invisible for the rest of the + // session. An unchanged workspace+directory still short-circuits. + if (workspace !== syncedWorkspace || directory !== syncedDirectory) { fullSyncedSessions.clear() syncedWorkspace = workspace + syncedDirectory = directory } + // A bootstrap triggered before a directory switch (e.g. the + // server.instance.disposed handler above, which fires while the switch is + // mid-flight) issues its requests against the OLD directory. Its responses + // must not be written once the client has moved on, or the store ends up + // describing a directory sdk no longer talks to. + const stale = () => sdk.directory !== directory const start = Date.now() - 30 * 24 * 60 * 60 * 1000 // roots: true so child sessions (subagents, workers) don't crowd root // sessions out of the server-side limit @@ -743,6 +759,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ configResponse, ...(sessionListResponse ? [sessionListResponse] : []), ]).then((responses) => { + if (stale()) return const providers = responses[0] const providerList = responses[1] const consoleState = responses[2] @@ -762,6 +779,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) }) .then(() => { + if (stale()) return if (store.status !== "complete") setStore("status", "partial") // non-blocking void Promise.all([ diff --git a/packages/opencode/test/cli/tui/directory-switch.test.tsx b/packages/opencode/test/cli/tui/directory-switch.test.tsx new file mode 100644 index 000000000..b28c35983 --- /dev/null +++ b/packages/opencode/test/cli/tui/directory-switch.test.tsx @@ -0,0 +1,201 @@ +/** @jsxImportSource @opentui/solid */ +import { describe, expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import type { GlobalEvent } from "@mimo-ai/sdk/v2" +import { onMount } from "solid-js" +import { ArgsProvider } from "../../../src/cli/cmd/tui/context/args" +import { ExitProvider } from "../../../src/cli/cmd/tui/context/exit" +import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project" +import { SDKProvider, useSDK } from "../../../src/cli/cmd/tui/context/sdk" +import { SyncProvider, useSync } from "../../../src/cli/cmd/tui/context/sync" + +const DIR_A = "/tmp/blanktx-a" +const DIR_B = "/tmp/blanktx-b" + +async function wait(fn: () => boolean, timeout = 5000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(5) + } +} + +function sessionRow() { + return { + id: "ses_a", + projectID: "p", + directory: DIR_A, + title: "t", + version: "test", + time: { created: 1, updated: 1 }, + } +} + +/** + * HTTP double for the endpoints the project/sync contexts touch. Records the + * directory the client sent with each request, and can hold one `/path` response + * open so a test can make an in-flight bootstrap resolve AFTER a directory switch. + */ +function createFetch() { + const seen: { path: string; directory?: string }[] = [] + let held: { directory: string; release: () => void } | undefined + + function body(path: string, directory?: string): unknown { + if (path === "/path") + return { home: "/home", state: "/state", config: "/config", worktree: "", directory: directory ?? "" } + if (path === "/project/current") return { id: "p" } + if (path === "/config/providers") return { providers: [], default: {} } + if (path === "/provider") return { all: [], default: {}, connected: [], authenticated: [] } + if (path === "/session") return [sessionRow()] + if (path === "/vcs") return { branch: "main" } + if (path === "/experimental/console") return {} + if (path.startsWith("/session/ses_a")) return path === "/session/ses_a" ? sessionRow() : [] + if (path === "/agent" || path === "/command" || path === "/experimental/workspace") return [] + if (path === "/experimental/workspace/status" || path === "/lsp" || path === "/formatter") return [] + return {} + } + + const fetcher = (async (request: Request) => { + const url = new URL(request.url) + const raw = url.searchParams.get("directory") + const directory = raw ? decodeURIComponent(raw) : undefined + seen.push({ path: url.pathname, directory }) + + if (held && url.pathname === "/path" && directory === held.directory) { + const gate = held + held = undefined + await new Promise((resolve) => { + gate.release = resolve + }) + } + + return new Response(JSON.stringify(body(url.pathname, directory)), { + status: 200, + headers: { "content-type": "application/json" }, + }) + }) as unknown as typeof fetch + + return { + fetch: fetcher, + count(path: string) { + return seen.filter((x) => x.path === path).length + }, + hold(directory: string) { + const gate = { directory, release: () => {} } + held = gate + return () => gate.release() + }, + } +} + +function createEvents() { + let fn: ((event: GlobalEvent) => void) | undefined + return { + subscribe: async (handler: (event: GlobalEvent) => void) => { + fn = handler + return () => { + if (fn === handler) fn = undefined + } + }, + } +} + +async function mount() { + const http = createFetch() + let ctx!: { + project: ReturnType + sdk: ReturnType + sync: ReturnType + } + let done!: () => void + const ready = new Promise((resolve) => { + done = resolve + }) + + function Probe() { + const project = useProject() + const sdk = useSDK() + const sync = useSync() + onMount(() => { + ctx = { project, sdk, sync } + done() + }) + return + } + + const app = await testRender(() => ( + + + + + + + + + + + + )) + + await ready + return { app, http, ...ctx } +} + +describe("tui directory switch", () => { + test("a bootstrap started before the switch cannot rewrite instance.path to the old directory", async () => { + const { app, http, project, sdk, sync } = await mount() + + try { + await wait(() => project.instance.directory() === DIR_A) + + // Reproduces the app's switch sequence: dispose fires + // server.instance.disposed, whose handler bootstraps against the OLD + // directory; the client then switches and bootstraps the new one. The + // stale run resolves LAST. + const release = http.hold(DIR_A) + const staleRun = sync.bootstrap({ fatal: false }) + sdk.switchDirectory(DIR_B) + await sync.bootstrap({ fatal: false }) + expect(project.instance.directory()).toBe(DIR_B) + + release() + await staleRun + + // instance.path must keep describing the directory the client actually + // talks to: useEvent filters every live event on instance.directory(), so + // a stale value silently drops the whole transcript. + expect(project.instance.directory()).toBe(DIR_B) + } finally { + app.renderer.destroy() + } + }) + + test("a session is re-syncable after a directory switch and still short-circuits without one", async () => { + const { app, http, sdk, sync } = await mount() + + try { + await sync.session.sync("ses_a") + expect(http.count("/session/ses_a/message")).toBe(1) + + // Invariant that fullSyncedSessions exists for: navigating back to an + // already-synced session must not refetch its transcript... + await sync.session.sync("ses_a") + expect(http.count("/session/ses_a/message")).toBe(1) + + // ...and a bootstrap that stays in the same directory keeps that cache. + await sync.bootstrap({ fatal: false }) + await sync.session.sync("ses_a") + expect(http.count("/session/ses_a/message")).toBe(1) + + // A directory switch changes the data source, so the cache must be + // dropped — otherwise a session synced before the switch can never be + // re-synced and anything missed stays invisible for the whole session. + sdk.switchDirectory(DIR_B) + await sync.bootstrap({ fatal: false }) + await sync.session.sync("ses_a") + expect(http.count("/session/ses_a/message")).toBe(2) + } finally { + app.renderer.destroy() + } + }) +}) From 9e7f022a2226cad111f63549fe1a9a754de4a013 Mon Sep 17 00:00:00 2001 From: wqymi Date: Tue, 28 Jul 2026 17:40:13 +0800 Subject: [PATCH 032/135] test(actor): verify the spawn-first claim end-to-end with a real model The existing coverage for the spawn-first rewrite only asserted the wording of actor.txt, which cannot show that an agent actually prefers spawn. Add the two things that can: - a deterministic assertion that the JSON schema handed to the provider offers spawn ahead of run (the wire-format consequence of moving spawnSchema ahead of runSchema; previously only the union's LENGTH was pinned, never its order) - an opt-in RUN_ACTOR_SPAWN_AB=1 test that drives the real headless CLI against mimo/mimo-v2.5 and reads the emitted operation out of the structured tool part A/B over 6 trials per prompt per arm: a clear-delegation prompt went 0/6 spawn on the run-first description to 6/6 spawn on the spawn-first one, and a three-way fan-out went from three sequential runs to three parallel spawns in 6/6 trials. The blocking-lookup prompt still chose run on both arms, so the change did not over-correct. --- .../test/tool/actor-spawn-preference.test.ts | 124 ++++++++++++++++++ packages/opencode/test/tool/actor.test.ts | 26 ++++ 2 files changed, 150 insertions(+) create mode 100644 packages/opencode/test/tool/actor-spawn-preference.test.ts diff --git a/packages/opencode/test/tool/actor-spawn-preference.test.ts b/packages/opencode/test/tool/actor-spawn-preference.test.ts new file mode 100644 index 000000000..b11cdb257 --- /dev/null +++ b/packages/opencode/test/tool/actor-spawn-preference.test.ts @@ -0,0 +1,124 @@ +// Real-model verification that the actor tool's spawn-first affordance actually +// changes what the model EMITS. The sibling wording tests (actor-prompt-spawn-first) +// only assert the text of actor.txt; a text assertion can never show that an agent +// prefers `spawn`. This drives the real headless CLI against the live mimo router +// and reads the emitted operation out of the structured tool part. +// +// Measured 2026-07-28, mimo/mimo-v2.5, 6 trials per prompt per arm, actor denied by +// permission so the call is recorded but no subagent is actually launched. The arms +// differ only in the four files #1942 touches; "neither" means the agent did the work +// inline instead of delegating at all: +// spawn-first (this branch) run-first (pre-#1942 main) +// clear delegation 6/6 spawn, 0 run 0/6 spawn, 3/6 run, 3/6 neither +// parallel fan-out 6/6 emitted 3 parallel spawns 6/6 emitted 3 sequential runs +// blocking lookup 2/6 run (correct), 4 neither 1/6 run, 5 neither +// So the affordance moved the choice, and it did NOT over-correct into "never run". +// Hence the assertions below: zero `run` on a clear-delegation prompt, and spawn on +// at least half the trials. The looser second bound is deliberate — a later spot check +// saw one clear-delegation trial answered inline with no actor call at all, so +// "delegates every single time" is not a safe assertion; "never reaches for the +// blocking path" is. +// +// Gated behind RUN_ACTOR_SPAWN_AB=1 so it never runs in the normal suite (it needs +// the live router + a real key in ~/.config/mimocode/mimocode.json). Run with: +// RUN_ACTOR_SPAWN_AB=1 bun test test/tool/actor-spawn-preference.test.ts +import { describe, expect, test } from "bun:test" +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "fs" +import os from "os" +import path from "path" + +const ENABLED = process.env["RUN_ACTOR_SPAWN_AB"] === "1" +const TRIALS = Number(process.env["RUN_ACTOR_SPAWN_AB_TRIALS"] ?? "4") +const PKG = path.resolve(import.meta.dirname, "..", "..") + +const DELEGATION_PROMPT = + "Investigate how session compaction is triggered in this repo and report back " + + "a written summary of the trigger conditions." + +// A scratch MIMOCODE_HOME: the real mimo provider (test/preload.ts strips provider +// keys from the environment, so the key has to be read from the user's config the +// way verify-wow.test.ts does it) plus a deny rule that keeps the actor tool +// advertised to the model while refusing to actually launch the subagent. +// `**` matters: a bare `*` deny makes Permission.disabled() strip the tool entirely. +function scratchHome() { + const home = mkdtempSync(path.join(os.tmpdir(), "actor-ab-home-")) + mkdirSync(path.join(home, "config"), { recursive: true }) + const user = JSON.parse(readFileSync(path.join(os.homedir(), ".config", "mimocode", "mimocode.json"), "utf8")) + if (!user.provider?.mimo?.options?.apiKey) throw new Error("no mimo provider/key in ~/.config/mimocode/mimocode.json") + writeFileSync( + path.join(home, "config", "config.json"), + JSON.stringify({ + model: "mimo/mimo-v2.5", + permission: { actor: { "**": "deny" } }, + provider: { mimo: user.provider.mimo }, + }), + ) + return home +} + +// The emitted operation, taken from the tool part's structured input only. mimo +// sometimes serializes `operation` as a JSON string, and that string can carry raw +// newlines inside the nested prompt (so JSON.parse rejects it) — hence the regex +// fallback. Still structure, never prose. +function emittedOperation(input: unknown): string | undefined { + if (!input || typeof input !== "object") return undefined + const raw = (input as Record)["operation"] + if (typeof raw === "string") return raw.match(/"action"\s*:\s*"(\w+)"/)?.[1] ?? raw.trim() + const action = (raw as Record | undefined)?.["action"] + return typeof action === "string" ? action : undefined +} + +async function trial(home: string, prompt: string) { + const proc = Bun.spawn( + ["bun", "run", "--conditions=browser", "./src/index.ts", "run", "--model", "mimo/mimo-v2.5", "--format", "json", prompt], + { cwd: PKG, env: { ...process.env, MIMOCODE_HOME: home }, stdout: "pipe", stderr: "ignore" }, + ) + const ops: string[] = [] + const reader = proc.stdout.getReader() + let buffered = "" + while (true) { + const { done, value } = await reader.read() + if (done) break + buffered += new TextDecoder().decode(value) + const lines = buffered.split("\n") + buffered = lines.pop() ?? "" + for (const line of lines.filter(Boolean)) { + const event = JSON.parse(line) as { type?: string; part?: { tool?: string; state?: { input?: unknown } } } + if (event.type === "tool_use" && event.part?.tool === "actor") { + ops.push(emittedOperation(event.part.state?.input) ?? "UNPARSEABLE") + } + // stop as soon as the assistant step carrying the actor batch closes, so the + // trial does not pay for the rest of the turn + if (event.type === "step_finish" && ops.length > 0) { + proc.kill() + return ops + } + } + } + return ops +} + +describe("actor tool: the model's actual spawn-vs-run choice (live router)", () => { + if (!ENABLED) { + test("skipped (set RUN_ACTOR_SPAWN_AB=1 to run against the live router)", () => { + expect(true).toBe(true) + }) + } + + const maybe = ENABLED ? test : test.skip + + maybe( + "a clear-delegation prompt is delegated with spawn, never with the blocking run", + async () => { + const home = scratchHome() + const results: string[][] = [] + for (let i = 0; i < TRIALS; i++) results.push(await trial(home, DELEGATION_PROMPT)) + console.log("emitted actor operations per trial:", JSON.stringify(results)) + expect(results.flat()).not.toContain("run") + expect(results.filter((ops) => ops.length > 0 && ops.every((op) => op === "spawn")).length).toBeGreaterThanOrEqual( + Math.ceil(TRIALS / 2), + ) + }, + 900_000, + ) +}) diff --git a/packages/opencode/test/tool/actor.test.ts b/packages/opencode/test/tool/actor.test.ts index e5308d1cd..ad7143851 100644 --- a/packages/opencode/test/tool/actor.test.ts +++ b/packages/opencode/test/tool/actor.test.ts @@ -569,6 +569,32 @@ describe("Actor tool subagent_type enum (F36)", () => { ), ) + // The union order is behaviour-neutral for PARSING but it is the one part of + // "spawn is the default" that reaches the model as structure rather than + // prose: the branch order in the JSON schema handed to the provider. The + // actor.txt wording tests assert a file on disk; this asserts the wire format. + it.live("flattened schema offers spawn ahead of run in the operation union", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const tool = yield* ActorTool + const def = yield* tool.init() + const fakeModel = { + providerID: "mimo", + api: { id: "mimo-v2.5", npm: "@ai-sdk/openai-compatible" }, + id: "mimo-v2.5", + capabilities: { input: {} }, + } as any + const flat = transformSchema(fakeModel, z.toJSONSchema(def.parameters)) as any + const branches = (flat.properties.operation.oneOf ?? flat.properties.operation.anyOf) as any[] + const actions = branches.map((b) => b.properties?.action?.const ?? b.properties?.action?.enum?.[0]) + expect(actions).toContain("spawn") + expect(actions).toContain("run") + expect(actions[0]).toBe("spawn") + expect(actions.indexOf("spawn")).toBeLessThan(actions.indexOf("run")) + }), + ), + ) + it.live("schema accepts an arbitrary task_id string (validation moved to execute)", () => provideTmpdirInstance(() => Effect.gen(function* () { From 67bf626d18eee353a00f75f55cda4ce69f51e19b Mon Sep 17 00:00:00 2001 From: Jinyu Xiang Date: Tue, 28 Jul 2026 18:12:38 +0800 Subject: [PATCH 033/135] fix(exec): pass the MCP view through ctx.extra instead of a module-level ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toolScriptMcp ref held REQUEST state in a module global, so concurrent sessions in one process (multi-session TUI, mimo serve, peer actors) overwrote each other's view: a session's exec could reach MCP tools it never search-loaded, and — worse — the map holds SessionPrompt's wrapped executes, whose closures capture the OTHER session's id and processor, so tool parts would land in the wrong conversation. Deliver it via ctx.extra.execMcp like toolWhitelist/mcpToolSearch, which is request-scoped by construction. toolScriptRegistry stays a ref: it holds a stateless factory, not per-request data. --- packages/opencode/src/session/prompt.ts | 20 +++++++++++-------- packages/opencode/src/tool/tool-script-ref.ts | 13 ------------ packages/opencode/src/tool/tool-script.ts | 14 +++++++------ .../opencode/test/tool/tool-script.test.ts | 10 +++++----- 4 files changed, 25 insertions(+), 32 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 1900db63e..411cecf08 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -125,7 +125,6 @@ import { type McpToolSearchMetadata, } from "@/tool/mcp-tool-search" import { isMcpToolSearchEnabled } from "@/tool/gpt" -import { toolScriptMcp } from "@/tool/tool-script-ref" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -945,6 +944,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the const loadedMcpTools = new Set() const mcpSearchEntries: McpToolSearchEntry[] = [] const mcpCatalog = { current: createMcpToolSearchCatalog([]) } + // exec's request-scoped MCP view. Holder object (same pattern as + // mcpCatalog above): referenced by the context() closure below, filled + // at the end of this pass once activeTools is settled. Travels through + // ctx.extra — NOT a module-level ref, which concurrent sessions in the + // same process would overwrite (request state must never live in a + // global; see toolWhitelist/mcpToolSearch precedent). + const execMcp: { current: Record } = { current: {} } const useMcpToolSearch = isMcpToolSearchEnabled( Flag.MIMOCODE_EXPERIMENTAL_MCP_TOOL_SEARCH, input.model.id, @@ -1014,6 +1020,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the promptOps, ...(whitelist ? { toolWhitelist: [...whitelist] } : {}), mcpToolSearch: mcpCatalog.current, + execMcp, }, agent: input.agent.name, actorID: input.agentID, @@ -1389,18 +1396,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the } loadedMcpTools.forEach((name) => activeTools.add(name)) - // Populate the exec sandbox's MCP view (late-bound ref, see - // tool-script-ref.ts) with the REQUEST-SCOPED set: exactly the MCP tools + // Fill exec's request-scoped MCP view (holder declared at the top of + // this pass, delivered via ctx.extra.execMcp): exactly the MCP tools // active for this request. Under mcp_tool_search gating that means only - // search-loaded tools — exec must not bypass the discovery gate. The map - // is rebuilt on every resolveTools pass, so the view tracks each turn. - const execMcpView: Record = {} + // search-loaded tools — exec must not bypass the discovery gate. for (const [key] of mcpTools) { if (!tools[key] || !activeTools.has(key)) continue if (key === MCP_TOOL_SEARCH_ID) continue - execMcpView[key] = tools[key] + execMcp.current[key] = tools[key] } - toolScriptMcp.current = () => Effect.succeed(execMcpView) return { tools, diff --git a/packages/opencode/src/tool/tool-script-ref.ts b/packages/opencode/src/tool/tool-script-ref.ts index eae259d1b..48cc95522 100644 --- a/packages/opencode/src/tool/tool-script-ref.ts +++ b/packages/opencode/src/tool/tool-script-ref.ts @@ -6,7 +6,6 @@ // the registry layer populates this module-local reference on initialisation and // the tool reads it at call time. import type { Effect } from "effect" -import type { Tool as AiTool } from "ai" import type { Agent } from "../agent/agent" import type { ModelID, ProviderID } from "../provider/schema" import type * as Tool from "./tool" @@ -17,18 +16,6 @@ export const toolScriptRegistry: { | undefined } = { current: undefined } -// MCP tools live outside ToolRegistry (SessionPrompt assembles them straight -// from MCP.Service), so exec reaches them through this second ref, populated -// per-request by the SessionPrompt layer. The populated map is the -// REQUEST-SCOPED view: when mcp_tool_search gating is active, only tools the -// model has already loaded via search are present — exec must not become a -// backdoor around the discovery gate. Reusing the ref pattern keeps MCP's -// layer out of the registry graph (providing MCP.defaultLayer to the registry -// would spin up a SECOND set of MCP client connections). -export const toolScriptMcp: { - current: (() => Effect.Effect>) | undefined -} = { current: undefined } - // Agent control-flow tools make no sense inside a script (they steer the // conversation, not data) — excluded from both the declared API and dispatch. export const TOOL_SCRIPT_EXCLUDED = new Set([ diff --git a/packages/opencode/src/tool/tool-script.ts b/packages/opencode/src/tool/tool-script.ts index eddf5b556..d765a50d1 100644 --- a/packages/opencode/src/tool/tool-script.ts +++ b/packages/opencode/src/tool/tool-script.ts @@ -9,7 +9,7 @@ import { Log, Filesystem } from "@/util" import { Agent } from "@/agent/agent" import type { ModelID, ProviderID } from "../provider/schema" import { evalScript, type HostFn } from "../workflow/sandbox" -import { toolScriptRegistry, toolScriptMcp, TOOL_SCRIPT_ALIASES, TOOL_SCRIPT_EXCLUDED } from "./tool-script-ref" +import { toolScriptRegistry, TOOL_SCRIPT_ALIASES, TOOL_SCRIPT_EXCLUDED } from "./tool-script-ref" import DESCRIPTION from "./tool-script.txt" import * as Tool from "./tool" import * as Truncate from "./truncate" @@ -378,11 +378,13 @@ export const ToolScriptTool = Tool.define( ) ).filter((def) => !TOOL_SCRIPT_EXCLUDED.has(def.id) && (!whitelist || whitelist.has(def.id))) const byId = new Map(defs.map((def) => [def.id, def])) - // MCP tools (late-bound ref, populated per-request by SessionPrompt - // with the request-scoped view — search-gated tools only appear after - // the model loaded them via mcp_tool_search). Builtin ids win on - // collision — an MCP server must not shadow `read`/`grep`. - const mcpTools = toolScriptMcp.current ? yield* toolScriptMcp.current() : {} + // MCP tools (request-scoped view delivered via ctx.extra.execMcp, + // filled by SessionPrompt's resolveTools for THIS request — under + // mcp_tool_search gating only search-loaded tools appear, so exec + // cannot bypass the discovery gate; a module-level ref would be + // overwritten by concurrent sessions). Builtin ids win on collision + // — an MCP server must not shadow `read`/`grep`. + const mcpTools = (ctx.extra?.execMcp as { current?: Record } | undefined)?.current ?? {} const mcpById = new Map( Object.entries(mcpTools).filter(([id]) => !byId.has(id) && (!whitelist || whitelist.has(id))), ) diff --git a/packages/opencode/test/tool/tool-script.test.ts b/packages/opencode/test/tool/tool-script.test.ts index c24d66841..8e9202b30 100644 --- a/packages/opencode/test/tool/tool-script.test.ts +++ b/packages/opencode/test/tool/tool-script.test.ts @@ -8,7 +8,7 @@ import { evalScript } from "../../src/workflow/sandbox" import { Agent } from "../../src/agent/agent" import { Truncate, Tool } from "../../src/tool" import { ToolScriptTool, renderToolScriptDeclarations } from "../../src/tool/tool-script" -import { toolScriptRegistry, toolScriptMcp, TOOL_SCRIPT_EXCLUDED } from "../../src/tool/tool-script-ref" +import { toolScriptRegistry, TOOL_SCRIPT_EXCLUDED } from "../../src/tool/tool-script-ref" import { Instance } from "../../src/project/instance" describe("sandbox non-deterministic mode", () => { @@ -98,9 +98,7 @@ async function runToolScript( }, ) { const prev = toolScriptRegistry.current - const prevMcp = toolScriptMcp.current toolScriptRegistry.current = () => Effect.succeed(defs) - toolScriptMcp.current = opts?.mcp ? () => Effect.succeed(opts.mcp!) : undefined try { return await Instance.provide({ directory: tmp, @@ -120,7 +118,10 @@ async function runToolScript( agent: "build", abort: abort ?? new AbortController().signal, callID: "call_test", - extra: opts?.toolWhitelist ? { toolWhitelist: opts.toolWhitelist } : undefined, + extra: { + ...(opts?.toolWhitelist ? { toolWhitelist: opts.toolWhitelist } : {}), + ...(opts?.mcp ? { execMcp: { current: opts.mcp } } : {}), + }, messages: [], metadata: () => Effect.void, ask: opts?.ask ?? (() => Effect.void), @@ -131,7 +132,6 @@ async function runToolScript( }) } finally { toolScriptRegistry.current = prev - toolScriptMcp.current = prevMcp } } From eb4183825dfc8579e5892423434a942bb6d2b2c9 Mon Sep 17 00:00:00 2001 From: wqymi Date: Tue, 28 Jul 2026 18:40:13 +0800 Subject: [PATCH 034/135] fix(tui): stop a finished status message from latching into the next turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solid's store setter merges plain objects into the existing node, so `setStore("session_status", id, status)` kept every field the incoming status omitted. The runner opens each turn with a bare `{type:"busy"}` (session/run-state.ts:74), which therefore inherited the `message` of the previous status — the `/rebuild` outcome sentence emitted by `settle()` (session/prompt.ts:4173) stayed on the spinner for the whole following turn, describing work that was already over. Wrap the incoming status in `reconcile()` so each status event is authoritative for the whole object; the durable confirmation stays the transcript boundary marker. Harden the footer while there: that sentence wrapped to three lines and squeezed the context counter into `52.4K/96` instead of `52.4K/960K`. Clamp the status message to a fixed cell budget (and flatten newlines) and give the counter `flexShrink={0}` so it is never the thing that gives way. --- .../cli/cmd/tui/component/prompt/footer.ts | 22 ++++++ .../cli/cmd/tui/component/prompt/index.tsx | 12 +++- .../opencode/src/cli/cmd/tui/context/sync.tsx | 17 ++++- .../cli/cmd/tui/prompt-footer-status.test.ts | 35 +++++++++ .../test/cli/tui/session-status-store.test.ts | 71 +++++++++++++++++++ .../test/session/rebuild-on-the-spot.test.ts | 9 ++- 6 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/prompt/footer.ts create mode 100644 packages/opencode/test/cli/cmd/tui/prompt-footer-status.test.ts create mode 100644 packages/opencode/test/cli/tui/session-status-store.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/footer.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/footer.ts new file mode 100644 index 000000000..352d8c160 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/footer.ts @@ -0,0 +1,22 @@ +import { Locale } from "@/util" + +/** + * Cell budget for the ephemeral status message in the prompt footer. Sized so + * the message plus the spinner still leaves room for `esc interrupt` and the + * context counter on an 80-column terminal. + */ +export const STATUS_MESSAGE_MAX = 48 + +/** + * The footer packs the spinner + status message onto the same row as the context + * counter (`52.4K/960K (5%)`). A long server-supplied status string wrapped over + * several lines and squeezed that row until the counter rendered clipped + * (`52.4K/96`). Clamp the message — and flatten any newlines — so a status + * string can never cost the counter its cells. + */ +export function clampStatusMessage(message: string | undefined) { + if (!message) return undefined + const flat = message.replace(/\s+/g, " ").trim() + if (!flat) return undefined + return Locale.truncate(flat, STATUS_MESSAGE_MAX) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 2b5e0e789..24a8010e1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -18,6 +18,7 @@ import { useKeybind } from "@tui/context/keybind" import { usePromptHistory, type PromptInfo } from "./history" import { assign, expandPlaceholders } from "./part" import { usePromptStash } from "./stash" +import { clampStatusMessage } from "./footer" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useCommandDialog } from "../dialog-command" @@ -1897,11 +1898,13 @@ export function Prompt(props: PromptProps) { {(() => { const busyMessage = createMemo(() => { const s = status() - return s.type === "busy" ? s.message : undefined + return s.type === "busy" ? clampStatusMessage(s.message) : undefined }) return ( - {busyMessage()} + + {busyMessage()} + ) })()} @@ -1992,7 +1995,10 @@ export function Prompt(props: PromptProps) { {(item) => ( - + // flexShrink=0: the context counter is the one number the + // footer must never clip (`52.4K/96` instead of + // `52.4K/960K`); the hints beside it can give way first. + {[item().context, item().cost].filter(Boolean).join(" · ")} )} diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 5c4561557..462bcda7b 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -155,6 +155,21 @@ export function bucketMessages( return out } +/** + * A `session.status` event is authoritative for the WHOLE status object. + * + * Solid's store setter merges plain objects into the existing node + * (`mergeStoreNode` only writes `Object.keys(next)`), so writing a bare + * `{ type: "busy" }` — which is what the runner emits at the start of every turn + * (session/run-state.ts:74) — inherits the `message` of whatever status was + * written before it. That latched `/rebuild` outcome text + * (session/prompt.ts:4173) into the following turn's spinner. `reconcile()` + * drops the fields the new status omits, so each status stands alone. + */ +export function nextSessionStatus(status: SessionStatus) { + return reconcile(status) +} + export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", init: () => { @@ -452,7 +467,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } case "session.status": { - setStore("session_status", event.properties.sessionID, event.properties.status) + setStore("session_status", event.properties.sessionID, nextSessionStatus(event.properties.status)) break } diff --git a/packages/opencode/test/cli/cmd/tui/prompt-footer-status.test.ts b/packages/opencode/test/cli/cmd/tui/prompt-footer-status.test.ts new file mode 100644 index 000000000..985df4731 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/prompt-footer-status.test.ts @@ -0,0 +1,35 @@ +import { describe, test, expect } from "bun:test" +import { clampStatusMessage, STATUS_MESSAGE_MAX } from "../../../../src/cli/cmd/tui/component/prompt/footer" + +describe("clampStatusMessage", () => { + test("an over-long status cannot outgrow the footer budget", () => { + const long = + "Context rebuilt from the latest checkpoint. Recent messages are preserved; earlier context is now summarized." + expect(long.length).toBeGreaterThan(STATUS_MESSAGE_MAX) + const out = clampStatusMessage(long)! + expect(out.length).toBe(STATUS_MESSAGE_MAX) + expect(out.endsWith("\u2026")).toBe(true) + expect(long.startsWith(out.slice(0, -1))).toBe(true) + }) + + test("short statuses pass through untouched", () => { + expect(clampStatusMessage("Rebuilding context\u2026")).toBe("Rebuilding context\u2026") + expect(clampStatusMessage("Writing checkpoint\u2026")).toBe("Writing checkpoint\u2026") + }) + + test("newlines are flattened so the status can never claim extra rows", () => { + expect(clampStatusMessage("Rebuilding\ncontext\u2026")).toBe("Rebuilding context\u2026") + expect(clampStatusMessage(" Rebuilding context\u2026 ")).toBe("Rebuilding context\u2026") + }) + + test("empty and missing statuses render nothing", () => { + expect(clampStatusMessage(undefined)).toBeUndefined() + expect(clampStatusMessage("")).toBeUndefined() + expect(clampStatusMessage(" \n ")).toBeUndefined() + }) + + test("the budget leaves room for the spinner, interrupt hint and context counter on 80 columns", () => { + // "⠋ " + message + "esc interrupt" + "52.4K/960K (5%)" + expect(STATUS_MESSAGE_MAX + 2 + "esc interrupt".length + "52.4K/960K (5%)".length).toBeLessThanOrEqual(80) + }) +}) diff --git a/packages/opencode/test/cli/tui/session-status-store.test.ts b/packages/opencode/test/cli/tui/session-status-store.test.ts new file mode 100644 index 000000000..1f78b6e65 --- /dev/null +++ b/packages/opencode/test/cli/tui/session-status-store.test.ts @@ -0,0 +1,71 @@ +import { describe, test, expect } from "bun:test" +import { createRoot } from "solid-js" +import { createStore } from "solid-js/store" +import { nextSessionStatus } from "../../../src/cli/cmd/tui/context/sync" + +// The TUI stores every `session.status` event in a solid store keyed by session. +// Solid MERGES plain objects into the existing node, so a status that omits a +// field used to inherit it from the previous status — that is how the /rebuild +// outcome sentence latched into the following turn's spinner. +function harness() { + return createRoot((dispose) => { + const [store, setStore] = createStore<{ session_status: Record }>({ session_status: {} }) + return { + store, + apply: (sessionID: string, status: Parameters[0]) => + setStore("session_status", sessionID, nextSessionStatus(status)), + dispose, + } + }) +} + +describe("nextSessionStatus", () => { + const REBUILT = + "Context rebuilt from the latest checkpoint. Recent messages are preserved; earlier context is now summarized." + + test("a following turn's bare busy status does not inherit the rebuild message", () => { + const h = harness() + h.apply("ses_1", { type: "busy", message: REBUILT }) + expect(h.store.session_status["ses_1"]).toEqual({ type: "busy", message: REBUILT }) + + // /rebuild settles to idle immediately after emitting its outcome. + h.apply("ses_1", { type: "idle" }) + expect(h.store.session_status["ses_1"]).toEqual({ type: "idle" }) + + // The next turn: the runner emits a bare busy (session/run-state.ts:74). + h.apply("ses_1", { type: "busy" }) + expect((h.store.session_status["ses_1"] as { message?: string }).message).toBeUndefined() + h.dispose() + }) + + test("idle clears a busy message", () => { + const h = harness() + h.apply("ses_2", { type: "busy", message: "Writing checkpoint\u2026" }) + h.apply("ses_2", { type: "idle" }) + expect(h.store.session_status["ses_2"]).toEqual({ type: "idle" }) + h.dispose() + }) + + test("busy replaces an earlier busy message instead of keeping it", () => { + const h = harness() + h.apply("ses_3", { type: "busy", message: "Rebuilding context\u2026" }) + h.apply("ses_3", { type: "busy", message: "Writing checkpoint\u2026" }) + expect(h.store.session_status["ses_3"]).toEqual({ type: "busy", message: "Writing checkpoint\u2026" }) + h.dispose() + }) + + test("retry fields do not survive into the next status", () => { + const h = harness() + h.apply("ses_4", { type: "retry", attempt: 2, message: "boom", next: 1000 }) + h.apply("ses_4", { type: "busy" }) + expect(h.store.session_status["ses_4"]).toEqual({ type: "busy" }) + h.dispose() + }) + + test("first status for an unseen session is stored as-is", () => { + const h = harness() + h.apply("ses_5", { type: "busy", message: "Rebuilding context\u2026" }) + expect(h.store.session_status["ses_5"]).toEqual({ type: "busy", message: "Rebuilding context\u2026" }) + h.dispose() + }) +}) diff --git a/packages/opencode/test/session/rebuild-on-the-spot.test.ts b/packages/opencode/test/session/rebuild-on-the-spot.test.ts index e9b7c2a74..5acc58039 100644 --- a/packages/opencode/test/session/rebuild-on-the-spot.test.ts +++ b/packages/opencode/test/session/rebuild-on-the-spot.test.ts @@ -216,10 +216,13 @@ describe("Manual /rebuild: on-the-spot rebuild driven through SessionPrompt.comm async () => { const llm = startLLM("rebuilt-reply-from-model") const seen: Array = [] + const lifecycle: Array = [] const onEvent = (e: { payload?: { type?: string; properties?: { status?: { type?: string; message?: string } } } }) => { - if (e?.payload?.type === "session.status" && e.payload.properties?.status?.type === "busy") { + if (e?.payload?.type !== "session.status") return + lifecycle.push(e.payload.properties?.status?.type) + if (e.payload.properties?.status?.type === "busy") { seen.push(e.payload.properties.status.message) } } @@ -327,6 +330,10 @@ describe("Manual /rebuild: on-the-spot rebuild driven through SessionPrompt.comm expect( seen.some((m) => m?.includes("Context rebuilt from the latest checkpoint")), ).toBe(true) + + // …and the status is CLEARED again: /rebuild must settle to idle + // so the outcome text cannot leak into the following turn. + expect(lifecycle.at(-1)).toBe("idle") }), ), }) From 6f981f1385adfce06f15bffc9d650845de0592f8 Mon Sep 17 00:00:00 2001 From: wqymi Date: Tue, 28 Jul 2026 19:27:51 +0800 Subject: [PATCH 035/135] fix: repair orphaned running tool parts and stop a directory 403 from killing the TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent robustness holes. Orphaned `running` tool parts: a tool part is persisted as `running` when the tool starts so the TUI can stream progress, and only the abort finalizer in SessionProcessor.cleanup rewrites it. Any exit path that skips that finalizer (process kill, crash, dev restart, a registration that raced teardown, a call that arrived after ctx.toolcalls was cleared) leaves the row `running` forever, so the transcript permanently shows tool calls that will never finish. Nothing repaired them: the model-message converter synthesizes an output-error for pending/running parts so the provider never sees a dangling tool_use, but it never touches the persisted row. - cleanup() now takes a second, DB-driven pass over the assistant message's own tool parts instead of trusting only the in-memory map. - New SessionPrompt.sweepOrphanToolParts repairs leftovers from a previous process at the next prompt. A currently executing tool part is also `running`, so the sweep is gated on session status being idle (no active runner) and on the main slice only (SessionProcessor publishes status for the main slice only, so a subagent slice can be live while the session reads idle). - Both finalizers share MessageV2.abortedToolState so interrupted parts get one consistent shape. Directory 403 killing the TUI: the instance middleware correctly rejects a directory outside the server cwd, but the SDK throws the parsed body with no status attached, so bootstrap treated it as fatal and exit()ed — a user who picked a non-whitelisted worktree lost their whole session. The rejection now carries a stable `code`, bootstrap classifies it as recoverable and rethrows instead of exiting, and the worktree switch restores the previous directory, re-syncs and toasts the rejected path. Genuinely fatal bootstrap failures still exit. --- .../cli/cmd/tui/component/dialog-worktree.tsx | 25 +- .../opencode/src/cli/cmd/tui/context/sync.tsx | 21 +- .../src/server/routes/instance/access.ts | 17 ++ .../src/server/routes/instance/middleware.ts | 13 +- packages/opencode/src/session/message-v2.ts | 19 ++ packages/opencode/src/session/processor.ts | 29 ++- packages/opencode/src/session/prompt.ts | 49 ++++ .../tui/bootstrap-directory-denied.test.tsx | 129 ++++++++++ .../opencode/test/cron/end-to-end.test.ts | 1 + .../session/cron-bridge.integration.test.ts | 1 + .../session/keepalive.integration.test.ts | 1 + .../session/prompt-orphan-tool-parts.test.ts | 222 ++++++++++++++++++ 12 files changed, 508 insertions(+), 19 deletions(-) create mode 100644 packages/opencode/src/server/routes/instance/access.ts create mode 100644 packages/opencode/test/cli/tui/bootstrap-directory-denied.test.tsx create mode 100644 packages/opencode/test/session/prompt-orphan-tool-parts.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-worktree.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-worktree.tsx index 8ae41e5d0..c0d27ce9e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-worktree.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-worktree.tsx @@ -5,6 +5,7 @@ import { useSDK } from "../context/sdk" import { useSync } from "@tui/context/sync" import { useRoute } from "@tui/context/route" import { useToast } from "../ui/toast" +import { isDirectoryDeniedError } from "@/server/routes/instance/access" import path from "path" const CREATE_SENTINEL = "__create_worktree__" @@ -53,9 +54,31 @@ export function DialogWorktree() { async function switchTo(directory: string) { setBusy("Switching to worktree...") + const previous = sdk.directory await sdk.client.instance.dispose().catch(() => {}) sdk.switchDirectory(directory) - await sync.bootstrap() + // The server rejects any directory outside its cwd (instance middleware 403). + // That used to propagate out of bootstrap into the TUI's fatal-exit path and + // kill the whole session; treat it as a recoverable error: point the SDK back + // at the directory that was working, re-sync, and tell the user which path was + // refused and why. + const failure = await sync.bootstrap().then( + () => undefined, + (e) => e, + ) + if (failure) { + if (previous) sdk.switchDirectory(previous) + await sync.bootstrap({ fatal: false }).catch(() => {}) + setBusy(undefined) + dialog.clear() + toast.show({ + message: isDirectoryDeniedError(failure) + ? `Cannot switch to ${directory}: outside this server's working directory` + : `Failed to switch to ${path.basename(directory)}`, + variant: "error", + }) + return + } route.navigate({ type: "home" }) dialog.clear() toast.show({ message: `Switched to ${path.basename(directory)}`, variant: "success" }) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 5c4561557..5daf7bca9 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -30,6 +30,7 @@ import { useExit } from "./exit" import { useArgs } from "./args" import { batch, onMount } from "solid-js" import { Log } from "@/util" +import { isDirectoryDeniedError } from "@/server/routes/instance/access" import { emptyConsoleState, type ConsoleState } from "@/config/console-state" /** @@ -271,7 +272,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ event.subscribe((event) => { switch (event.type) { case "server.instance.disposed": - void bootstrap() + void bootstrap().catch(() => {}) break case "permission.replied": { const requests = store.permission[event.properties.sessionID] @@ -786,20 +787,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) .catch(async (e) => { Log.Default.error("tui bootstrap failed", { - error: e instanceof Error ? e.message : String(e), + error: isDirectoryDeniedError(e) ? e.error : e instanceof Error ? e.message : String(e), name: e instanceof Error ? e.name : undefined, stack: e instanceof Error ? e.stack : undefined, }) - if (fatal) { + // The server's directory whitelist rejecting the requested directory is a + // recoverable policy decision, not a broken TUI: exiting here would take + // the user's whole session down over a mistyped/untrusted path. Always + // rethrow so the switch caller can restore the previous directory and show + // the error. Genuinely fatal bootstrap failures still exit. + if (fatal && !isDirectoryDeniedError(e)) { await exit(e) - } else { - throw e + return } + throw e }) } onMount(() => { - void bootstrap() + // Errors are already logged (and exited on, when fatal) inside bootstrap; the + // rethrown recoverable case has no caller here, so swallow it rather than + // emitting an unhandled rejection. + void bootstrap().catch(() => {}) }) const result = { diff --git a/packages/opencode/src/server/routes/instance/access.ts b/packages/opencode/src/server/routes/instance/access.ts new file mode 100644 index 000000000..3c65af08d --- /dev/null +++ b/packages/opencode/src/server/routes/instance/access.ts @@ -0,0 +1,17 @@ +/** + * Shared contract for the instance middleware's directory whitelist rejection. + * + * The rejection itself is correct policy (a client may not point the server at a + * directory outside its cwd), but a client has to be able to RECOGNISE it: the + * generated SDK throws the parsed response body, with no status code attached, so + * a 403 is otherwise indistinguishable from a transport failure and gets treated + * as fatal. `code` is the stable discriminator — never match on `error` prose. + * + * Leaf module on purpose: the TUI imports the guard, so this file must not pull + * the server's instance/bootstrap graph into the TUI bundle. + */ +export const DIRECTORY_DENIED_CODE = "directory_not_allowed" + +export function isDirectoryDeniedError(e: unknown): e is { code: string; error: string; directory?: string } { + return typeof e === "object" && e !== null && "code" in e && e.code === DIRECTORY_DENIED_CODE +} diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts index d5f6683a9..c635de844 100644 --- a/packages/opencode/src/server/routes/instance/middleware.ts +++ b/packages/opencode/src/server/routes/instance/middleware.ts @@ -9,6 +9,7 @@ import { Flag } from "@/flag/flag" import { Filesystem } from "@/util" import { Global } from "@/global" import path from "node:path" +import { DIRECTORY_DENIED_CODE } from "./access" export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler { return async (c, next) => { @@ -34,7 +35,17 @@ export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler ? Filesystem.resolve(path.join(Global.Path.data, "orchestrator")) : undefined if (!Filesystem.contains(cwd, directory) && directory !== orchestrator) { - return c.json({ error: "Access denied: directory must be within the server's working directory" }, 403) + // Keep the 403 and the prose message; add a stable `code` so a client can + // tell this policy rejection apart from a transport failure and surface it + // instead of dying (see ./access.ts). + return c.json( + { + code: DIRECTORY_DENIED_CODE, + error: "Access denied: directory must be within the server's working directory", + directory, + }, + 403, + ) } } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 3137ec398..c0e006a03 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -358,6 +358,25 @@ export const ToolStateError = z }) export type ToolStateError = z.infer +/** + * The terminal state for a tool part that was left unfinished by an + * interruption. A `pending`/`running` part is persisted the moment the tool + * starts (so the TUI can stream progress) and is only rewritten by whoever + * finalizes the turn — so every finalizer must produce the SAME shape, or the + * transcript renders interrupted calls inconsistently. Callers: the abort + * finalizer in `SessionProcessor.cleanup` and `SessionPrompt.sweepOrphanToolParts`. + */ +export function abortedToolState(state: ToolPart["state"], error = "Tool execution aborted"): ToolStateError { + const end = Date.now() + return { + status: "error", + input: state.input, + error, + metadata: { ...("metadata" in state && state.metadata ? state.metadata : {}), interrupted: true }, + time: { start: "time" in state ? state.time.start : end, end }, + } +} + export const ToolState = z .discriminatedUnion("status", [ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]) .meta({ diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 2b5ac2061..8d57a8929 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -743,21 +743,28 @@ export const layer: Layer.Layer< for (const toolCallID of Object.keys(ctx.toolcalls)) { const match = yield* readToolCall(toolCallID) if (!match) continue - const part = match.part - const end = Date.now() - const metadata = "metadata" in part.state && isRecord(part.state.metadata) ? part.state.metadata : {} yield* session.updatePart({ - ...part, - state: { - ...part.state, - status: "error", - error: "Tool execution aborted", - metadata: { ...metadata, interrupted: true }, - time: { start: "time" in part.state ? part.state.time.start : end, end }, - }, + ...match.part, + state: MessageV2.abortedToolState(match.part.state), }) } ctx.toolcalls = {} + // Second pass, DB-driven. The loop above can only see calls this process + // still holds in `ctx.toolcalls`, so a call whose registration lost the race + // with teardown, or that arrived after the map was cleared, or whose + // `readToolCall` lookup missed, keeps its persisted `running` status forever + // — the transcript then shows a tool call that will never finish. Every tool + // part of THIS assistant message belongs to the turn being torn down here, + // so any part still `pending`/`running` is unfinalized by definition. + // Idempotent: the pass above already rewrote the tracked ones. + for (const part of yield* Effect.sync(() => MessageV2.parts(ctx.assistantMessage.id))) { + if (part.type !== "tool") continue + if (part.state.status !== "pending" && part.state.status !== "running") continue + yield* session.updatePart({ + ...part, + state: MessageV2.abortedToolState(part.state), + }) + } ctx.assistantMessage.time.completed = Date.now() yield* session.updateMessage(ctx.assistantMessage) }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 411cecf08..76893a116 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -277,6 +277,7 @@ export interface Interface { readonly command: (input: CommandInput) => Effect.Effect readonly resolvePromptParts: (template: string) => Effect.Effect readonly sweepOrphanAssistants: (sessionID: SessionID, immediate?: boolean) => Effect.Effect + readonly sweepOrphanToolParts: (sessionID: SessionID) => Effect.Effect readonly predict: (input: { sessionID: SessionID }) => Effect.Effect } @@ -2261,6 +2262,50 @@ NOTE: At any point in time through this workflow you should feel free to ask the } }) + // A tool part is persisted as `running` the moment the tool STARTS (so the TUI + // can stream progress) and is only rewritten by the abort finalizer in + // `SessionProcessor.cleanup`. Every exit path that skips that finalizer — process + // kill, crash, dev restart — leaves the row `running` forever, so the transcript + // permanently shows tool calls that will never finish. Nothing else repairs them: + // the model-message converter (`MessageV2.toModelMessages`) synthesizes an + // `output-error` for `pending`/`running` parts so the provider never sees a + // dangling `tool_use`, but it never touches the persisted row. + // + // SAFETY — a CURRENTLY EXECUTING tool part is also `running`, so an unscoped + // "rewrite every running row" sweep would corrupt live turns. Two guards, both + // required, both narrow: + // 1. session status must be `idle`. `busy`/`retry` mean an active runner owns + // this session, and a tool can only execute inside a runner's turn. This is + // the same gate `sweepOrphanAssistants`' caller relies on, kept INSIDE the + // function here because that is where the danger lives. + // 2. the MAIN slice only (`sessions.messages` default). `SessionProcessor` only + // publishes status for the main slice (`if (isMain) status.set(...)`), so a + // subagent slice can be executing tools while the session status reads + // `idle` — its parts are out of scope. + const sweepOrphanToolParts = Effect.fn("SessionPrompt.sweepOrphanToolParts")(function* (sessionID: SessionID) { + if ((yield* status.get(sessionID)).type !== "idle") return + for (const m of yield* sessions.messages({ sessionID })) { + if (m.info.role !== "assistant") continue + for (const part of m.parts) { + if (part.type !== "tool") continue + if (part.state.status !== "pending" && part.state.status !== "running") continue + yield* sessions + .updatePart({ ...part, state: MessageV2.abortedToolState(part.state) }) + .pipe( + Effect.catchCause((cause) => + elog.warn("orphan-tool-part-update-failed", { sessionID, partID: part.id, cause }), + ), + ) + yield* elog.info("orphan-tool-part-cleared", { + sessionID, + messageID: m.info.id, + partID: part.id, + tool: part.tool, + }) + } + } + }) + const prompt: (input: PromptInput) => Effect.Effect = Effect.fn("SessionPrompt.prompt")( function* (input: PromptInput) { const session = yield* sessions.get(input.sessionID) @@ -2271,6 +2316,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the // so a fresh message is not rendered as stuck QUEUED behind it. const idle = (yield* status.get(input.sessionID)).type === "idle" yield* sweepOrphanAssistants(input.sessionID, idle) + // Same recovery point, same idleness argument: repair tool parts a killed + // process left stuck at `running`. Self-gated on idle (see the function). + yield* sweepOrphanToolParts(input.sessionID) } const message = yield* createUserMessage(input) yield* sessions.touch(input.sessionID) @@ -4321,6 +4369,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the command, resolvePromptParts, sweepOrphanAssistants, + sweepOrphanToolParts, predict, }) sessionPromptRef.current = { loop: impl.loop } diff --git a/packages/opencode/test/cli/tui/bootstrap-directory-denied.test.tsx b/packages/opencode/test/cli/tui/bootstrap-directory-denied.test.tsx new file mode 100644 index 000000000..0c517355d --- /dev/null +++ b/packages/opencode/test/cli/tui/bootstrap-directory-denied.test.tsx @@ -0,0 +1,129 @@ +/** @jsxImportSource @opentui/solid */ +import { afterEach, describe, expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import type { GlobalEvent } from "@mimo-ai/sdk/v2" +import { onMount } from "solid-js" +import { ArgsProvider } from "../../../src/cli/cmd/tui/context/args" +import { ExitProvider } from "../../../src/cli/cmd/tui/context/exit" +import { ProjectProvider } from "../../../src/cli/cmd/tui/context/project" +import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk" +import { SyncProvider, useSync } from "../../../src/cli/cmd/tui/context/sync" +import { DIRECTORY_DENIED_CODE } from "../../../src/server/routes/instance/access" + +const DENIED = "/somewhere/outside/the/server/cwd" + +afterEach(() => { + delete process.env.MIMOCODE_FAST_BOOT +}) + +async function wait(fn: () => boolean, timeout = 5000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +/** + * Stands in for a server whose instance middleware refuses the requested + * directory. Shape matches `InstanceMiddleware`: HTTP 403 + a JSON body carrying + * the stable `code`. + */ +function denyingFetch(status: number, body: unknown): typeof fetch { + return (async () => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch +} + +const events = { + subscribe: async () => () => {}, +} + +async function mount(fetchDouble: typeof fetch) { + // SyncProvider gates its children behind `ready` (status !== "loading"), which a + // failing bootstrap never reaches — reuse the fast-boot escape hatch so the probe + // mounts and can drive bootstrap directly. + process.env.MIMOCODE_FAST_BOOT = "1" + const exits: unknown[] = [] + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + const app = await testRender(() => ( + { + exits.push("exit") + }} + > + + + + + { + sync = ctx + ready() + }} + /> + + + + + + )) + + await mounted + return { app, exits, sync } +} + +function Probe(props: { onReady: (sync: ReturnType) => void }) { + const sync = useSync() + onMount(() => props.onReady(sync)) + return +} + +describe("tui bootstrap directory rejection", () => { + test("a 403 from the instance middleware never reaches the fatal-exit path", async () => { + const { app, exits, sync } = await mount( + denyingFetch(403, { + code: DIRECTORY_DENIED_CODE, + error: "Access denied: directory must be within the server's working directory", + directory: DENIED, + }), + ) + + try { + const failure = await sync.bootstrap().then( + () => undefined, + (e) => e, + ) + + // Surfaced to the caller so it can restore the previous directory + toast... + expect(failure).toBeDefined() + expect((failure as { code?: string }).code).toBe(DIRECTORY_DENIED_CODE) + expect((failure as { directory?: string }).directory).toBe(DENIED) + // ...and the TUI is still alive: exit() was never invoked. + await Bun.sleep(50) + expect(exits).toEqual([]) + } finally { + app.renderer.destroy() + } + }) + + test("a genuinely fatal bootstrap failure still exits", async () => { + const { app, exits } = await mount(denyingFetch(500, { error: "boom" })) + + try { + // The mount-time bootstrap is enough: a 500 is not a recoverable policy + // rejection, so the fatal path must still fire. + await wait(() => exits.length > 0) + expect(exits).toEqual(["exit"]) + } finally { + app.renderer.destroy() + } + }) +}) diff --git a/packages/opencode/test/cron/end-to-end.test.ts b/packages/opencode/test/cron/end-to-end.test.ts index cfa7180d8..741a3096c 100644 --- a/packages/opencode/test/cron/end-to-end.test.ts +++ b/packages/opencode/test/cron/end-to-end.test.ts @@ -81,6 +81,7 @@ const stubPrompt = Layer.succeed( command: () => Effect.die("command not expected in end-to-end test"), resolvePromptParts: () => Effect.succeed([]), sweepOrphanAssistants: () => Effect.void, + sweepOrphanToolParts: () => Effect.void, predict: () => Effect.succeed(""), }), ) diff --git a/packages/opencode/test/session/cron-bridge.integration.test.ts b/packages/opencode/test/session/cron-bridge.integration.test.ts index 7a59a1871..1adc1f87f 100644 --- a/packages/opencode/test/session/cron-bridge.integration.test.ts +++ b/packages/opencode/test/session/cron-bridge.integration.test.ts @@ -67,6 +67,7 @@ const makeCaptureLayer = (captured: { value: CapturedPrompt[] }) => command: () => Effect.die("command not expected in cron-bridge test"), resolvePromptParts: () => Effect.succeed([]), sweepOrphanAssistants: () => Effect.void, + sweepOrphanToolParts: () => Effect.void, predict: () => Effect.succeed(""), }), ) diff --git a/packages/opencode/test/session/keepalive.integration.test.ts b/packages/opencode/test/session/keepalive.integration.test.ts index 4f3dc77db..9122ef615 100644 --- a/packages/opencode/test/session/keepalive.integration.test.ts +++ b/packages/opencode/test/session/keepalive.integration.test.ts @@ -73,6 +73,7 @@ const stubPrompt = Layer.succeed( command: () => Effect.die("command not expected in keepalive test"), resolvePromptParts: () => Effect.succeed([]), sweepOrphanAssistants: () => Effect.void, + sweepOrphanToolParts: () => Effect.void, predict: () => Effect.succeed(""), }), ) diff --git a/packages/opencode/test/session/prompt-orphan-tool-parts.test.ts b/packages/opencode/test/session/prompt-orphan-tool-parts.test.ts new file mode 100644 index 000000000..c6ae932a8 --- /dev/null +++ b/packages/opencode/test/session/prompt-orphan-tool-parts.test.ts @@ -0,0 +1,222 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import path from "path" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { Instance } from "../../src/project/instance" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { SessionStatus } from "../../src/session/status" +import { MessageV2 } from "../../src/session/message-v2" +import { MessageID, PartID, type SessionID } from "../../src/session/schema" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +afterEach(async () => { + await Instance.disposeAll() +}) + +const it = testEffect( + Layer.mergeAll( + SessionPrompt.defaultLayer, + Session.defaultLayer, + SessionStatus.defaultLayer, + CrossSpawnSpawner.defaultLayer, + ), +) + +const seedRunningToolPart = (dir: string, sessionID: SessionID) => + Effect.gen(function* () { + const sessions = yield* Session.Service + const user = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "user" as const, + sessionID, + agent: "default", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test-model") }, + time: { created: Date.now() }, + }) + const assistant = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "assistant" as const, + sessionID, + mode: "default", + agent: "default", + path: { cwd: path.resolve(dir), root: path.resolve(dir) }, + cost: 0, + tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ModelID.make("test-model"), + providerID: ProviderID.make("test"), + parentID: user.id, + time: { created: Date.now() }, + }) + return yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID, + type: "tool" as const, + tool: "bash", + callID: `call-${assistant.id}`, + state: { + status: "running" as const, + input: { command: "sleep 100" }, + title: "sleep 100", + time: { start: Date.now() }, + }, + }) + }) + +const readPart = (sessionID: SessionID, partID: string) => + Effect.gen(function* () { + const sessions = yield* Session.Service + for (const m of yield* sessions.messages({ sessionID })) { + const found = m.parts.find((p) => p.id === partID) + if (found) return found + } + return undefined + }) + +describe("sweepOrphanToolParts", () => { + it.live("repairs a tool part orphaned at running when the session is idle", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const sessions = yield* Session.Service + const svc = yield* SessionPrompt.Service + const session = yield* sessions.create({}) + const part = yield* seedRunningToolPart(dir, session.id) + + yield* svc.sweepOrphanToolParts(session.id) + + const after = yield* readPart(session.id, part.id) + expect(after?.type).toBe("tool") + if (after?.type !== "tool") throw new Error("expected a tool part") + expect(after.state.status).toBe("error") + if (after.state.status !== "error") throw new Error("expected an error state") + expect(after.state.error).toBe("Tool execution aborted") + expect(after.state.metadata?.interrupted).toBe(true) + // The original start time survives so the transcript keeps its duration. + expect(after.state.time.start).toBe(part.state.status === "running" ? part.state.time.start : 0) + }), + ), + ) + + it.live("leaves an in-flight tool part alone while the session is busy", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const sessions = yield* Session.Service + const status = yield* SessionStatus.Service + const svc = yield* SessionPrompt.Service + const session = yield* sessions.create({}) + const part = yield* seedRunningToolPart(dir, session.id) + + // A CURRENTLY EXECUTING tool is persisted as `running` too — this is the + // half that matters: a sweep that fires here would corrupt a live turn. + yield* status.set(session.id, { type: "busy" }) + yield* svc.sweepOrphanToolParts(session.id) + + const after = yield* readPart(session.id, part.id) + if (after?.type !== "tool") throw new Error("expected a tool part") + expect(after.state.status).toBe("running") + }), + ), + ) + + it.live("leaves a retrying session's tool part alone", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const sessions = yield* Session.Service + const status = yield* SessionStatus.Service + const svc = yield* SessionPrompt.Service + const session = yield* sessions.create({}) + const part = yield* seedRunningToolPart(dir, session.id) + + yield* status.set(session.id, { type: "retry", attempt: 1, message: "retrying", next: Date.now() + 1000 }) + yield* svc.sweepOrphanToolParts(session.id) + + const after = yield* readPart(session.id, part.id) + if (after?.type !== "tool") throw new Error("expected a tool part") + expect(after.state.status).toBe("running") + }), + ), + ) + + it.live("leaves completed tool parts untouched", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const sessions = yield* Session.Service + const svc = yield* SessionPrompt.Service + const session = yield* sessions.create({}) + const user = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "user" as const, + sessionID: session.id, + agent: "default", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test-model") }, + time: { created: Date.now() }, + }) + const assistant = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "assistant" as const, + sessionID: session.id, + mode: "default", + agent: "default", + path: { cwd: path.resolve(dir), root: path.resolve(dir) }, + cost: 0, + tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ModelID.make("test-model"), + providerID: ProviderID.make("test"), + parentID: user.id, + time: { created: Date.now() }, + }) + const part = yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID: session.id, + type: "tool" as const, + tool: "read", + callID: `call-${assistant.id}`, + state: { + status: "completed" as const, + input: {}, + output: "ok", + title: "read", + metadata: {}, + time: { start: 1, end: 2 }, + }, + }) + + yield* svc.sweepOrphanToolParts(session.id) + + const after = yield* readPart(session.id, part.id) + if (after?.type !== "tool") throw new Error("expected a tool part") + expect(after.state.status).toBe("completed") + }), + ), + ) +}) + +describe("MessageV2.abortedToolState", () => { + it.live("keeps the original start time and stamps interrupted", () => + Effect.sync(() => { + const state = MessageV2.abortedToolState({ + status: "running", + input: { a: 1 }, + metadata: { foo: "bar" }, + time: { start: 42 }, + }) + expect(state.status).toBe("error") + expect(state.input).toEqual({ a: 1 }) + expect(state.time.start).toBe(42) + expect(state.metadata).toMatchObject({ foo: "bar", interrupted: true }) + }), + ) + + it.live("synthesizes a start time for a pending part", () => + Effect.sync(() => { + const state = MessageV2.abortedToolState({ status: "pending", input: {}, raw: "" }) + expect(state.status).toBe("error") + expect(state.time.start).toBe(state.time.end) + expect(state.metadata?.interrupted).toBe(true) + }), + ) +}) From e080a8394dbec4decf34369e068321d79002d4a4 Mon Sep 17 00:00:00 2001 From: wqymi Date: Tue, 28 Jul 2026 19:46:44 +0800 Subject: [PATCH 036/135] fix(inbox): stop rendering a body-less notification into an empty user text part MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inbox.drain persists renderInboxRow(row) verbatim as the only text part of a synthetic role:"user" message, bypassing createUserMessage/hasSubstantiveContent. renderInboxRow used `content.text ?? "(no notification body)"`, and `??` does not catch "", so a body-less actor_notification rendered to exactly "". With one queued row that is `parts: [{type:"text",text:""}]` — length 1, invisible to every parts.length===0 guard — which ai@6.0.168's convertToLanguageModelMessage filters to `content: []`, the shape a provider rejects with "messages.: user messages must have non-empty content". Also restores the shell path's parity with the JSON path's content min(1): shell-wrap routes a shell-parsed op straight to def.execute without re-validating against `parameters`, so `actor send main ""` could queue the body-less row in the first place. --- packages/opencode/src/inbox/render.ts | 11 +- packages/opencode/src/tool/actor.ts | 11 + .../inbox/empty-notification-part.test.ts | 201 ++++++++++++++++++ .../opencode/test/session/message-v2.test.ts | 41 ++++ .../opencode/test/tool/actor.shell.test.ts | 14 ++ 5 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/inbox/empty-notification-part.test.ts diff --git a/packages/opencode/src/inbox/render.ts b/packages/opencode/src/inbox/render.ts index aa4c2df2c..e283e9c09 100644 --- a/packages/opencode/src/inbox/render.ts +++ b/packages/opencode/src/inbox/render.ts @@ -5,7 +5,14 @@ export function renderInboxRow(row: InboxRow): string { // Pre-rendered notification text — sender produced the full // ... wrapper. const content = row.content as { text?: string } - return content.text ?? "(no notification body)" + // `||` not `??`: an EMPTY body is exactly as unusable as a missing one, and + // `??` let `""` through. Inbox.drain persists this return value verbatim as + // the ONLY text part of a synthetic `role:"user"` message, so a `""` here + // produced `parts: [{type:"text",text:""}]` — length 1, so every + // `parts.length === 0` guard misses it — which `ai`'s + // convertToLanguageModelMessage then filters down to `content: []`, + // yielding a provider 400 ("user messages must have non-empty content"). + return content.text || "(no notification body)" } // Default: type === "text" or unknown — wrap as element so // the LLM can route by sender; the wrapper format mirrors the @@ -15,7 +22,7 @@ export function renderInboxRow(row: InboxRow): string { ? `${row.sender_session_id}:${row.sender_actor_id ?? "?"}` : "system" const sentAt = new Date(row.created_at).toISOString() - return `\n${content.text ?? "(empty)"}\n` + return `\n${content.text || "(empty)"}\n` } export function renderActorNotification(event: { diff --git a/packages/opencode/src/tool/actor.ts b/packages/opencode/src/tool/actor.ts index bd9b4bf63..c4136e5ab 100644 --- a/packages/opencode/src/tool/actor.ts +++ b/packages/opencode/src/tool/actor.ts @@ -178,6 +178,17 @@ const mapActorVerb = Effect.fn("mapActorVerb")(function* (verb: string | undefin const { flags, rest } = yield* extractNamedFlags(args, ["session", "type"], line) if (rest.length !== 2) return yield* actorArityError("send", ' "" [--session ] [--type ]', rest, line) + // Parity with the JSON path's `content: z.string().min(1)`. shell-wrap + // calls def.execute(parsed) directly, so a shell-parsed op is NEVER + // re-validated against `parameters` — without this, `actor send main ""` + // queued a body-less inbox row that drain() then rendered into an + // unusable synthetic user text part. + if (rest[1] === "") + return yield* Effect.fail({ + kind: "flag" as const, + line, + detail: `actor: send: content must not be empty`, + }) return { operation: { action: "send" as const, diff --git a/packages/opencode/test/inbox/empty-notification-part.test.ts b/packages/opencode/test/inbox/empty-notification-part.test.ts new file mode 100644 index 000000000..082564bc7 --- /dev/null +++ b/packages/opencode/test/inbox/empty-notification-part.test.ts @@ -0,0 +1,201 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Layer, ManagedRuntime } from "effect" +import { Inbox } from "../../src/inbox" +import { renderInboxRow } from "../../src/inbox/render" +import { defaultModelRef } from "../../src/inbox/inbox-ref" +import type { InboxRow } from "../../src/inbox/inbox.sql" +import { ActorRegistry } from "../../src/actor/registry" +import { Session } from "../../src/session" +import { Bus } from "../../src/bus" +import { Instance } from "../../src/project/instance" +import { MessageID, SessionID } from "../../src/session/schema" +import { ProviderID, ModelID } from "../../src/provider/schema" +import { tmpdir } from "../fixture/fixture" + +// Producer of the empty-user-content provider 400. +// +// Inbox.drain writes ONE synthetic `role:"user"` message and then one text part +// per queued row, with `text: renderInboxRow(row)` persisted verbatim — +// bypassing createUserMessage/hasSubstantiveContent entirely. renderInboxRow +// used `content.text ?? "(no notification body)"`, and `??` does not catch `""`, +// so a body-less `actor_notification` row rendered to exactly `""`. With a +// single queued row that yields `parts: [{type:"text",text:""}]` — length 1, so +// every `parts.length === 0` guard misses it — which `ai`'s +// convertToLanguageModelMessage then filters to `content: []`, the shape a +// provider rejects with "user messages must have non-empty content". +// +// See test/session/message-v2.test.ts for the SDK-boundary half of the proof. + +const base = Layer.mergeAll(Session.defaultLayer, ActorRegistry.defaultLayer, Bus.defaultLayer) +const testLayer = Inbox.layer.pipe(Layer.provide(base), Layer.provideMerge(base)) + +afterEach(async () => { + defaultModelRef.current = undefined + await Instance.disposeAll() +}) + +type RT = ManagedRuntime.ManagedRuntime + +async function withInbox(directory: string, fn: (rt: RT) => Promise) { + return Instance.provide({ + directory, + fn: async () => { + const rt = ManagedRuntime.make(testLayer) + try { + await fn(rt) + } finally { + await rt.dispose() + } + }, + }) +} + +async function seedRealMessage(rt: RT, sessionID: SessionID, actorID: string) { + return rt.runPromise( + Session.Service.use((sessions) => + sessions.updateMessage({ + id: MessageID.ascending(), + role: "user" as const, + sessionID, + agentID: actorID, + time: { created: Date.now() }, + agent: "general", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test-model") }, + }), + ), + ) +} + +function row(type: string, text: string | undefined): InboxRow { + return { + id: "01AAA", + receiver_session_id: "ses_x", + receiver_actor_id: "main", + sender_session_id: "ses_y", + sender_actor_id: "general-1", + type, + content: text === undefined ? {} : { text }, + created_at: 0, + } as unknown as InboxRow +} + +describe("inbox render never yields an empty part text", () => { + test("a body-less actor_notification renders the placeholder, not an empty string", () => { + expect(renderInboxRow(row("actor_notification", ""))).toBe("(no notification body)") + expect(renderInboxRow(row("actor_notification", undefined))).toBe("(no notification body)") + }) + + test("a body-less text row renders a non-empty wrapper", () => { + expect(renderInboxRow(row("text", ""))).toContain("(empty)") + expect(renderInboxRow(row("text", undefined))).toContain("(empty)") + }) + + test("every row type/body combination renders non-empty", () => { + for (const type of ["actor_notification", "text", "unknown-future-type"]) { + for (const text of ["", undefined, " ", "real body"]) { + expect(renderInboxRow(row(type, text)).length).toBeGreaterThan(0) + } + } + }) +}) + +describe("Inbox.drain never persists an empty user text part", () => { + test("draining a body-less actor_notification writes a non-empty synthetic part", async () => { + await using tmp = await tmpdir({ git: true }) + await withInbox(tmp.path, async (rt) => { + const session = await rt.runPromise(Session.Service.use((s) => s.create())) + await rt.runPromise( + ActorRegistry.Service.use((reg) => + reg.register({ + sessionID: session.id, + actorID: "actor-empty", + mode: "subagent", + parentActorID: undefined, + agent: "general", + description: "empty-body notification", + contextMode: "none", + contextWatermark: undefined, + background: false, + lifecycle: "ephemeral", + }), + ), + ) + await seedRealMessage(rt, session.id, "actor-empty") + + // The reachable trigger: `actor send "" --type actor_notification`. + // The JSON path's `content: z.string().min(1)` is bypassed in shell mode + // (shell-wrap calls def.execute(parsed) without re-validating), so an + // empty body did reach Inbox.send in production. + await rt.runPromise( + Inbox.Service.use((inbox) => + inbox.send({ + receiverSessionID: session.id, + receiverActorID: "actor-empty", + content: "", + type: "actor_notification", + }), + ), + ) + + expect(await rt.runPromise(Inbox.Service.use((inbox) => inbox.drain(session.id, "actor-empty")))).toBe(1) + + const msgs = await rt.runPromise( + Session.Service.use((sessions) => sessions.messages({ sessionID: session.id, agentID: "actor-empty" })), + ) + const drained = msgs.findLast((m) => m.info.role === "user" && m.parts.some((p) => p.type === "text" && p.synthetic)) + expect(drained).toBeDefined() + const textParts = drained!.parts.filter((p) => p.type === "text") + expect(textParts.length).toBe(1) + // The whole point: this part must not be "" — a length-1 parts array whose + // only text is empty is the shape that reaches a provider as `content: []`. + expect(textParts[0].type === "text" && textParts[0].text).toBe("(no notification body)") + expect(drained!.parts.every((p) => p.type !== "text" || p.text !== "")).toBe(true) + }) + }) + + test("a mixed drain (empty + real bodies) leaves no empty text part behind", async () => { + await using tmp = await tmpdir({ git: true }) + await withInbox(tmp.path, async (rt) => { + const session = await rt.runPromise(Session.Service.use((s) => s.create())) + await rt.runPromise( + ActorRegistry.Service.use((reg) => + reg.register({ + sessionID: session.id, + actorID: "actor-mixed", + mode: "subagent", + parentActorID: undefined, + agent: "general", + description: "mixed bodies", + contextMode: "none", + contextWatermark: undefined, + background: false, + lifecycle: "ephemeral", + }), + ), + ) + await seedRealMessage(rt, session.id, "actor-mixed") + + for (const body of ["", "a real notification", ""]) { + await rt.runPromise( + Inbox.Service.use((inbox) => + inbox.send({ + receiverSessionID: session.id, + receiverActorID: "actor-mixed", + content: body, + type: "actor_notification", + }), + ), + ) + } + + expect(await rt.runPromise(Inbox.Service.use((inbox) => inbox.drain(session.id, "actor-mixed")))).toBe(3) + + const msgs = await rt.runPromise( + Session.Service.use((sessions) => sessions.messages({ sessionID: session.id, agentID: "actor-mixed" })), + ) + const drained = msgs.findLast((m) => m.info.role === "user" && m.parts.some((p) => p.type === "text" && p.synthetic)) + expect(drained!.parts.filter((p) => p.type === "text").length).toBe(3) + expect(drained!.parts.every((p) => p.type !== "text" || p.text !== "")).toBe(true) + }) + }) +}) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 525e2ad74..cfa5648cc 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { APICallError } from "ai" +import { convertToLanguageModelPrompt } from "ai/internal" import { MessageV2 } from "../../src/session/message-v2" import { ProviderTransform } from "../../src/provider" import type { Provider } from "../../src/provider" @@ -211,6 +212,46 @@ describe("session.message-v2.toModelMessage", () => { ]) }) + // Mechanism pin for the empty-user-content provider 400. Companion to the + // zero-part test above: a zero-part user message is DROPPED by our layer (so + // the transient state between Inbox.drain's `updateMessage` and its first + // `updatePart` can never reach a provider), but a message whose only part is + // `text: ""` survives at parts.length === 1 — invisible to every + // `parts.length === 0` / `content.length === 0` check — and is only reduced to + // `content: []` later, inside the SDK's own per-role filter on the way to the + // provider (ai@6.0.168 dist/index.mjs:1424, convertToLanguageModelMessage: + // `.filter((part) => part.type !== "text" || part.text !== "")`, no backfill). + // `content: []` is what a provider rejects with + // "messages.: user messages must have non-empty content". + test("an empty-text-only user message survives our layer at length 1 and only collapses at the SDK boundary", async () => { + const input: MessageV2.WithParts[] = [ + { + info: userInfo("m-empty-text"), + parts: [ + { + ...basePart("m-empty-text", "p1"), + type: "text", + text: "", + }, + ] as MessageV2.Part[], + }, + ] + + // Our layer: still length 1, so nothing on our side can see it as "empty". + const ours = await MessageV2.toModelMessages(input, model) + expect(ours).toStrictEqual([{ role: "user", content: [{ type: "text", text: "" }] }]) + + // The SDK step that actually runs between us and the provider. + const wire = await convertToLanguageModelPrompt({ + prompt: { messages: ours }, + supportedUrls: {}, + download: async () => [], + }) + expect(wire.length).toBe(1) + expect(wire[0].role).toBe("user") + expect(wire[0].content).toStrictEqual([]) + }) + test("filters out messages with only ignored parts", async () => { const messageID = "m-user" diff --git a/packages/opencode/test/tool/actor.shell.test.ts b/packages/opencode/test/tool/actor.shell.test.ts index d6f8a441c..905030d77 100644 --- a/packages/opencode/test/tool/actor.shell.test.ts +++ b/packages/opencode/test/tool/actor.shell.test.ts @@ -198,6 +198,20 @@ describe("actor.shell.parse: send", () => { expect(err.kind).toBe("arity") expect(err.detail).toContain("to_actor_id") }) + + // The JSON path declares `content: z.string().min(1)`, but shell-wrap routes a + // shell-parsed op straight to def.execute WITHOUT re-validating it against + // `parameters` — so an empty token used to reach Inbox.send, queue a body-less + // row, and become an unusable synthetic user text part after drain(). Reject it + // here so the model gets a loud, self-correctable error instead. + test("send with an empty content token is rejected (parity with the JSON min(1))", async () => { + const exit = await Effect.runPromise(Effect.exit(parseActorScript('actor send main "" --type actor_notification'))) + expect(exit._tag).toBe("Failure") + const cause: any = (exit as any).cause + const fail = cause.reasons?.find?.((r: any) => r._tag === "Fail") ?? cause + const err = fail.error ?? fail + expect(err.detail).toContain("content must not be empty") + }) }) describe("actor.shell.parse: full parity flags", () => { From 3f32482b91446569bd644d5d19ee7cb7318a1cf5 Mon Sep 17 00:00:00 2001 From: wqymi Date: Tue, 28 Jul 2026 18:57:53 +0800 Subject: [PATCH 037/135] fix(tui): discard superseded bootstrap writes and stop minting duplicate orchestrator roots Two TUI defects share one window: the gap between `await sync.bootstrap()` returning and the writes it left in flight. 1. The non-blocking half of `bootstrap()` wrote every response into the store unconditionally. A directory switch landing while those requests were in flight resurrected pre-switch data (`vcs`, `command`, `lsp`, `mcp`, `session`, ...) into a store that is supposed to describe the directory the client actually talks to. `dispose + switchDirectory + bootstrap` always produces a second bootstrap via the `server.instance.disposed` handler, and that run built its requests from the PRE-switch client, so this is not hypothetical. Every write in the group now goes through one `guard()` helper that re-checks the captured directory generation AFTER the response resolves; a superseded run also no longer declares the current directory's sync complete. 2. Entering the Orchestrator read `sync.data.session` immediately after `await sync.bootstrap()` to find the existing root. `session.list` only joins `blockingRequests` when `--continue` is set, so bootstrap resolves BEFORE the list lands: the lookup missed an existing root and `session.create({})` minted another one. Root resolution moves into `sync.session.resolveRoot()`, which refreshes from the server first, so resolve-or-create depends on data instead of on timing. Entering N times now yields exactly one root. The two are one root cause and have to land together: without (1), the stale bootstrap's session-list write can repopulate the store with the launch directory's roots after (2) has resolved, pointing the Orchestrator at a session from another directory. Follow-up to #1953, which applied the same after-the-await principle to bootstrap's blocking writes and to `project.sync()`. The two changes are independent and can merge in either order. --- packages/opencode/src/cli/cmd/tui/app.tsx | 31 ++- .../opencode/src/cli/cmd/tui/context/sync.tsx | 68 +++-- .../test/cli/tui/bootstrap-race.test.tsx | 235 ++++++++++++++++++ 3 files changed, 300 insertions(+), 34 deletions(-) create mode 100644 packages/opencode/test/cli/tui/bootstrap-race.test.tsx diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 4125836b0..6416f1941 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -537,23 +537,20 @@ function App(props: { onSnapshot?: () => Promise }) { sdk.switchDirectory(dir) await sync.bootstrap() } - const existing = sync.data.session - .toSorted((a, b) => b.time.updated - a.time.updated) - .find((x) => x.parentID === undefined)?.id - if (existing) { - local.orchestrator.setSessionID(existing) - // A `-s` launch wanted to land IN the orchestrator session; a plain - // Tab-into-orchestrator from a stale launch-dir session wanted Home - // (the fresh-entry state). Either way navigate exactly once, AFTER - // bootstrap, so the switched view resolves directly to its target with - // no intermediate frame — the root now exists in orchestratorDir. - if (resumeIntoSession) route.navigate({ type: "session", sessionID: existing }) - else if (switching) route.navigate({ type: "home" }) - } else { - const res = await sdk.client.session.create({}) - if (res.data?.id) local.orchestrator.setSessionID(res.data.id) - if (switching) route.navigate({ type: "home" }) - } + // Authoritative resolve-or-create against the switched directory. Reading + // sync.data.session here raced bootstrap's NON-blocking session list — + // bootstrap resolves before the list lands, so the lookup missed the + // existing root and minted another one on every entry. + const root = await sync.session.resolveRoot() + if (root.id) local.orchestrator.setSessionID(root.id) + // A `-s` launch wanted to land IN the orchestrator session; a plain + // Tab-into-orchestrator from a stale launch-dir session wanted Home + // (the fresh-entry state). Either way navigate exactly once, AFTER + // bootstrap, so the switched view resolves directly to its target with + // no intermediate frame — the root now exists in orchestratorDir. A root + // we just created is empty, so resuming into it makes no sense: go Home. + if (root.id && !root.created && resumeIntoSession) route.navigate({ type: "session", sessionID: root.id }) + else if (switching) route.navigate({ type: "home" }) } catch (e) { toast.show({ message: `Failed to enter Orchestrator: ${e}`, variant: "error" }) } finally { diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index d5c98aac8..99a7028a3 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -710,12 +710,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ syncedWorkspace = workspace syncedDirectory = directory } - // A bootstrap triggered before a directory switch (e.g. the - // server.instance.disposed handler above, which fires while the switch is - // mid-flight) issues its requests against the OLD directory. Its responses - // must not be written once the client has moved on, or the store ends up - // describing a directory sdk no longer talks to. + // A bootstrap can outlive the directory it describes: `dispose + + // switchDirectory + bootstrap` ALSO re-fires bootstrap from the + // `server.instance.disposed` handler above, and that run built its requests + // from the PRE-switch client. Staleness therefore has to be re-checked AFTER + // each await rather than once before them — a switch landing while these + // requests are in flight must not write the old directory's data into the + // store, or the store ends up describing a directory sdk no longer talks to. + // When no directory was ever set (single-directory mode) nothing can switch + // and this is always false. `directory` above is the captured generation. const stale = () => sdk.directory !== directory + // Same check for the NON-blocking writes, which each resolve on their own. + const guard = (request: Promise, apply: (value: T) => void) => + request.then((value) => { + if (stale()) return + apply(value) + }) const start = Date.now() - 30 * 24 * 60 * 60 * 1000 // roots: true so child sessions (subagents, workers) don't crowd root // sessions out of the server-side limit @@ -783,22 +793,27 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (store.status !== "complete") setStore("status", "partial") // non-blocking void Promise.all([ - ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]), - consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))), - sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))), - sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data ?? []))), - sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data ?? {}))), - sdk.client.experimental.resource - .list({ workspace }) - .then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))), - sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data ?? []))), - sdk.client.session.status({ workspace }).then((x) => { + ...(args.continue + ? [] + : [guard(sessionListPromise, (sessions) => setStore("session", reconcile(sessions)))]), + guard(consoleStatePromise, (consoleState) => setStore("console_state", reconcile(consoleState))), + guard(sdk.client.command.list({ workspace }), (x) => setStore("command", reconcile(x.data ?? []))), + guard(sdk.client.lsp.status({ workspace }), (x) => setStore("lsp", reconcile(x.data ?? []))), + guard(sdk.client.mcp.status({ workspace }), (x) => setStore("mcp", reconcile(x.data ?? {}))), + guard(sdk.client.experimental.resource.list({ workspace }), (x) => + setStore("mcp_resource", reconcile(x.data ?? {})), + ), + guard(sdk.client.formatter.status({ workspace }), (x) => setStore("formatter", reconcile(x.data ?? []))), + guard(sdk.client.session.status({ workspace }), (x) => { setStore("session_status", reconcile(x.data ?? {})) }), - sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))), - sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))), + guard(sdk.client.provider.auth({ workspace }), (x) => setStore("provider_auth", reconcile(x.data ?? {}))), + guard(sdk.client.vcs.get({ workspace }), (x) => setStore("vcs", reconcile(x.data))), project.workspace.sync(), ]).then(() => { + // A superseded run must not declare the CURRENT directory's sync + // complete — that would unblock the UI on data it never wrote. + if (stale()) return setStore("status", "complete") }) }) @@ -846,6 +861,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) setStore("session", reconcile(list)) }, + // Resolve THE root session of the directory the client currently talks + // to, creating one only when the server really has none. + // + // Reading store.session for this is a race: bootstrap issues session.list + // as a NON-BLOCKING request (it only joins blockingRequests for + // `--continue`), so `await bootstrap()` resolves BEFORE the list lands. A + // caller that reads the store right after it sees an empty (or pre-switch) + // list, concludes there is no root, and mints another one — entering + // Orchestrator three times produced three roots. Refreshing from the + // server first makes the decision depend on data instead of on timing. + async resolveRoot() { + await result.session.refresh() + const existing = store.session + .filter((x) => x.parentID === undefined) + .toSorted((a, b) => b.time.updated - a.time.updated) + .at(0) + if (existing) return { id: existing.id, created: false } + return { id: (await sdk.client.session.create({})).data?.id, created: true } + }, status(sessionID: string) { const session = result.session.get(sessionID) if (!session) return "idle" diff --git a/packages/opencode/test/cli/tui/bootstrap-race.test.tsx b/packages/opencode/test/cli/tui/bootstrap-race.test.tsx new file mode 100644 index 000000000..a0b2eebe0 --- /dev/null +++ b/packages/opencode/test/cli/tui/bootstrap-race.test.tsx @@ -0,0 +1,235 @@ +/** @jsxImportSource @opentui/solid */ +import { describe, expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import type { GlobalEvent } from "@mimo-ai/sdk/v2" +import { onMount } from "solid-js" +import { ArgsProvider } from "../../../src/cli/cmd/tui/context/args" +import { ExitProvider } from "../../../src/cli/cmd/tui/context/exit" +import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project" +import { SDKProvider, useSDK } from "../../../src/cli/cmd/tui/context/sdk" +import { SyncProvider, useSync } from "../../../src/cli/cmd/tui/context/sync" + +// DIR_A stands in for the launch directory, DIR_B for the globally-unique +// Orchestrator workspace the entry effect switches into. +const DIR_A = "/tmp/bootrace-a" +const DIR_B = "/tmp/bootrace-b" + +async function wait(fn: () => boolean, timeout = 5000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(5) + } +} + +function sessionRow(id: string, directory: string, updated: number) { + return { + id, + projectID: "p", + directory, + title: "t", + version: "test", + time: { created: updated, updated }, + } +} + +/** + * HTTP double for the endpoints project/sync touch. It keeps per-directory + * server-side session state so `POST /session` is observable, can delay a route + * (so a non-blocking store write lands after an await the caller already + * returned from), and can park one in-flight request until the test releases it. + */ +function createFetch(input: { sessions?: Record; delay?: Record } = {}) { + const seen: { method: string; path: string; directory?: string }[] = [] + const sessions: Record = input.sessions ?? {} + const delay = input.delay ?? {} + let held: { path: string; directory: string; parked: boolean; release: () => void } | undefined + let created = 0 + + function body(method: string, path: string, directory?: string): unknown { + if (path === "/path") + return { home: "/home", state: "/state", config: "/config", worktree: "", directory: directory ?? "" } + if (path === "/project/current") return { id: "p" } + if (path === "/config/providers") return { providers: [], default: {} } + if (path === "/provider") return { all: [], default: {}, connected: [], authenticated: [] } + // vcs is a NON-blocking bootstrap write, and its payload identifies the + // directory that produced it — that is what makes a stale write visible. + if (path === "/vcs") return { branch: directory === DIR_B ? "branch-b" : "branch-a" } + if (path === "/session" && method === "POST") { + created += 1 + const id = `ses_created_${created}` + sessions[directory ?? ""] = [...(sessions[directory ?? ""] ?? []), id] + return sessionRow(id, directory ?? "", 1000 + created) + } + if (path === "/session") + return (sessions[directory ?? ""] ?? []).map((id, i) => sessionRow(id, directory ?? "", 100 + i)) + if (path === "/experimental/console") return {} + if (path === "/agent" || path === "/command" || path === "/experimental/workspace") return [] + if (path === "/experimental/workspace/status" || path === "/lsp" || path === "/formatter") return [] + return {} + } + + const fetcher = (async (request: Request) => { + const url = new URL(request.url) + const raw = url.searchParams.get("directory") + const directory = raw ? decodeURIComponent(raw) : undefined + seen.push({ method: request.method, path: url.pathname, directory }) + + if (held && url.pathname === held.path && directory === held.directory) { + const gate = held + held = undefined + gate.parked = true + await new Promise((resolve) => { + gate.release = resolve + }) + } + + const ms = delay[url.pathname] + if (ms) await Bun.sleep(ms) + + return new Response(JSON.stringify(body(request.method, url.pathname, directory)), { + status: 200, + headers: { "content-type": "application/json" }, + }) + }) as unknown as typeof fetch + + return { + fetch: fetcher, + count(method: string, path: string) { + return seen.filter((x) => x.method === method && x.path === path).length + }, + roots(directory: string) { + return sessions[directory] ?? [] + }, + /** Park the next request for `path` in `directory` until the returned fn is called. */ + hold(path: string, directory: string) { + const gate = { path, directory, parked: false, release: () => {} } + held = gate + return { parked: () => gate.parked, release: () => gate.release() } + }, + } +} + +function createEvents() { + let fn: ((event: GlobalEvent) => void) | undefined + return { + subscribe: async (handler: (event: GlobalEvent) => void) => { + fn = handler + return () => { + if (fn === handler) fn = undefined + } + }, + } +} + +async function mount(http: ReturnType) { + let ctx!: { + project: ReturnType + sdk: ReturnType + sync: ReturnType + } + let done!: () => void + const ready = new Promise((resolve) => { + done = resolve + }) + + function Probe() { + const project = useProject() + const sdk = useSDK() + const sync = useSync() + onMount(() => { + ctx = { project, sdk, sync } + done() + }) + return + } + + const app = await testRender(() => ( + + + + + + + + + + + + )) + + await ready + return { app, ...ctx } +} + +describe("tui bootstrap directory race", () => { + test("a non-blocking bootstrap write that resolves after a directory switch is discarded", async () => { + const http = createFetch({ sessions: { [DIR_A]: [], [DIR_B]: ["ses_orch"] } }) + const { app, sdk, sync } = await mount(http) + + try { + await wait(() => sync.data.vcs?.branch === "branch-a") + + // Park the OLD directory's /vcs so the stale run's non-blocking write is + // still in flight when the switch lands. The request has to be issued + // before the switch — `sdk.client` is read when the non-blocking group + // runs, so waiting for the park is what makes this the real window. + const gate = http.hold("/vcs", DIR_A) + const staleRun = sync.bootstrap({ fatal: false }) + await wait(() => gate.parked()) + + sdk.switchDirectory(DIR_B) + await sync.bootstrap({ fatal: false }) + await wait(() => sync.data.vcs?.branch === "branch-b") + + gate.release() + await staleRun + // bootstrap does not await its own non-blocking group (it is `void + // Promise.all`), so give the released write every chance to land. + await Bun.sleep(50) + + // The store must keep describing the directory the client actually talks + // to. A superseded write here is how pre-switch data gets resurrected. + expect(sync.data.vcs?.branch).toBe("branch-b") + } finally { + app.renderer.destroy() + } + }) + + test("entering the orchestrator repeatedly resolves the one existing root instead of creating more", async () => { + // The launch directory has no root sessions, which is what the live repro + // looked like: the store is empty at the moment the entry effect reads it. + const http = createFetch({ + sessions: { [DIR_A]: [], [DIR_B]: ["ses_orch"] }, + // The session list is a NON-blocking bootstrap request, so `await + // bootstrap()` returns before it lands. Delaying it makes that ordering + // explicit rather than incidental. + delay: { "/session": 30 }, + }) + const { app, sdk, sync } = await mount(http) + + try { + const resolved: { id?: string; created: boolean }[] = [] + for (let i = 0; i < 3; i++) { + // The entry effect's sequence: switch into the orchestrator workspace, + // bootstrap it, then resolve the root it must land on. + sdk.switchDirectory(DIR_B) + await sync.bootstrap({ fatal: false }) + resolved.push(await sync.session.resolveRoot()) + // Leaving orchestrator again, so the next iteration is a real re-entry: + // wait until the store actually describes the launch directory, which is + // the state every entry starts from. + sdk.switchDirectory(DIR_A) + await sync.bootstrap({ fatal: false }) + await wait(() => sync.data.session.length === 0) + } + + expect(resolved.map((x) => x.id)).toEqual(["ses_orch", "ses_orch", "ses_orch"]) + expect(resolved.every((x) => x.created === false)).toBe(true) + expect(http.count("POST", "/session")).toBe(0) + expect(http.roots(DIR_B)).toEqual(["ses_orch"]) + } finally { + app.renderer.destroy() + } + }) +}) From 0178b57a287dd5dc4acd4944b36da181bfc79723 Mon Sep 17 00:00:00 2001 From: wqymi Date: Tue, 28 Jul 2026 21:34:48 +0800 Subject: [PATCH 038/135] =?UTF-8?q?fix(inbox):=20fold=20#1963=20=E2=80=94?= =?UTF-8?q?=20drain=20renders=20before=20writing=20and=20never=20persists?= =?UTF-8?q?=20a=20blank=20part?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates the "empty content reaches the provider" family into one PR. Supersedes #1963, keeping its stronger version wherever the two overlapped: - src/inbox/render.ts: `blankTo()` helper, so any BLANK body (not merely a missing one) gets the placeholder. Replaces the bare `??` -> `||` change. - src/inbox/inbox.ts: the drain renders BEFORE writing and drops any row that renders blank; if every row renders blank it consumes them without writing a message at all. This is the structural invariant — the drain is the one user-message producer that bypasses createUserMessage/hasSubstantiveContent (src/session/prompt.ts:2164, :4386), so it needs its own guarantee. - src/tool/actor.ts: keeps the shell-boundary rejection, upgraded to `.trim()` so whitespace-only bodies are rejected too (zod's `min(1)` accepts " "). Also corrects a comment that asserted the opposite of the truth: a shell-parsed op IS re-validated against `parameters`. shell-wrap.ts calls `def.execute(parsed)` on the def from Tool.init, which is wrap()-decorated, and wrap() runs `parameters.parse(args)` inside execute. Verified end-to-end: with the parse-level guard removed, the real shellWrap route still enqueues nothing and reports "Too small: expected string to have >=1 characters -> at operation.content". A blank `actor send` was never reachable, so the render/drain work closes a LATENT defence gap rather than a live producer. New test test/inbox/empty-notification-reachability.test.ts drives that composition. The send-side backstop `ensureNonEmptyContent` (src/provider/transform.ts) stays: it remains the guard for the other message producers. --- packages/opencode/src/inbox/inbox.ts | 44 +++- packages/opencode/src/inbox/render.ts | 21 +- packages/opencode/src/tool/actor.ts | 21 +- .../test/inbox/drain-no-empty-part.test.ts | 222 ++++++++++++++++++ .../inbox/empty-notification-part.test.ts | 16 +- .../empty-notification-reachability.test.ts | 207 ++++++++++++++++ .../opencode/test/tool/actor.shell.test.ts | 40 +++- 7 files changed, 534 insertions(+), 37 deletions(-) create mode 100644 packages/opencode/test/inbox/drain-no-empty-part.test.ts create mode 100644 packages/opencode/test/inbox/empty-notification-reachability.test.ts diff --git a/packages/opencode/src/inbox/inbox.ts b/packages/opencode/src/inbox/inbox.ts index e55c1f6d6..2d0eb99ba 100644 --- a/packages/opencode/src/inbox/inbox.ts +++ b/packages/opencode/src/inbox/inbox.ts @@ -248,6 +248,44 @@ export const layer: Layer.Layer< return 0 } + // Render BEFORE writing anything, and drop any row that renders blank. + // The drain is the one user-message producer that does NOT go through + // SessionPrompt.createUserMessage, so `hasSubstantiveContent` never sees + // it — a blank render would persist a user message whose only part is + // {type:"text",text:""}. The AI SDK's user branch filters empty text + // parts out with NO backfill, so that message reaches the provider as + // `content: []` and is rejected ("user messages must have non-empty + // content"). renderInboxRow already substitutes a placeholder for a + // blank body; this is the structural invariant that keeps the shape + // unreachable no matter what any future row type renders. + const rendered = rows.flatMap((row) => { + const text = renderInboxRow(row) + if (text.trim().length > 0) return [{ row, text }] + log.warn("inbox.drain: dropping row that rendered blank (would produce an empty user text part)", { + sessionID, + actorID, + rowID: row.id, + type: row.type, + }) + return [] + }) + + // Every row rendered blank: consume them (they carry no information and + // must not be re-drained forever) without writing a message at all. A + // zero-part user message would be skipped downstream anyway, so writing + // one is pure litter. + if (rendered.length === 0) { + yield* Effect.sync(() => + Database.use((db) => + db + .delete(InboxTable) + .where(inArray(InboxTable.id, rows.map((r) => r.id))) + .run(), + ), + ) + return 0 + } + // Non-transactional crash window: updateMessage + updatePart commit // before the inbox DELETE. A crash between them re-renders the same // rows on next drain — LLM sees duplicated notifications. Tolerable; @@ -265,14 +303,14 @@ export const layer: Layer.Layer< agent: seed.agent, model: seed.model, }) - for (const row of rows) { + for (const entry of rendered) { yield* sessions.updatePart({ id: PartID.ascending(), messageID: msgID, sessionID, type: "text" as const, synthetic: true, - text: renderInboxRow(row), + text: entry.text, }) } yield* Effect.sync(() => @@ -284,7 +322,7 @@ export const layer: Layer.Layer< ), ) - return rows.length + return rendered.length }) const impl = Service.of({ send, drain }) diff --git a/packages/opencode/src/inbox/render.ts b/packages/opencode/src/inbox/render.ts index e283e9c09..3fd6498e3 100644 --- a/packages/opencode/src/inbox/render.ts +++ b/packages/opencode/src/inbox/render.ts @@ -1,18 +1,21 @@ import type { InboxRow } from "./inbox.sql" +// A blank body must fall back to the placeholder, not just a missing one. +// `?? placeholder` only catches null/undefined, but a blank body is stored as +// "" (or whitespace) — and for actor_notification the body is passed through +// RAW, so "" would become a user text part with text:"". The AI SDK's user +// branch filters empty text parts out with no backfill, leaving `content: []` +// and a provider 400 ("user messages must have non-empty content"). +function blankTo(text: string | undefined, placeholder: string) { + return text !== undefined && text.trim().length > 0 ? text : placeholder +} + export function renderInboxRow(row: InboxRow): string { if (row.type === "actor_notification") { // Pre-rendered notification text — sender produced the full // ... wrapper. const content = row.content as { text?: string } - // `||` not `??`: an EMPTY body is exactly as unusable as a missing one, and - // `??` let `""` through. Inbox.drain persists this return value verbatim as - // the ONLY text part of a synthetic `role:"user"` message, so a `""` here - // produced `parts: [{type:"text",text:""}]` — length 1, so every - // `parts.length === 0` guard misses it — which `ai`'s - // convertToLanguageModelMessage then filters down to `content: []`, - // yielding a provider 400 ("user messages must have non-empty content"). - return content.text || "(no notification body)" + return blankTo(content.text, "(no notification body)") } // Default: type === "text" or unknown — wrap as element so // the LLM can route by sender; the wrapper format mirrors the @@ -22,7 +25,7 @@ export function renderInboxRow(row: InboxRow): string { ? `${row.sender_session_id}:${row.sender_actor_id ?? "?"}` : "system" const sentAt = new Date(row.created_at).toISOString() - return `\n${content.text || "(empty)"}\n` + return `\n${blankTo(content.text, "(empty)")}\n` } export function renderActorNotification(event: { diff --git a/packages/opencode/src/tool/actor.ts b/packages/opencode/src/tool/actor.ts index c4136e5ab..81edc6118 100644 --- a/packages/opencode/src/tool/actor.ts +++ b/packages/opencode/src/tool/actor.ts @@ -178,16 +178,23 @@ const mapActorVerb = Effect.fn("mapActorVerb")(function* (verb: string | undefin const { flags, rest } = yield* extractNamedFlags(args, ["session", "type"], line) if (rest.length !== 2) return yield* actorArityError("send", ' "" [--session ] [--type ]', rest, line) - // Parity with the JSON path's `content: z.string().min(1)`. shell-wrap - // calls def.execute(parsed) directly, so a shell-parsed op is NEVER - // re-validated against `parameters` — without this, `actor send main ""` - // queued a body-less inbox row that drain() then rendered into an - // unusable synthetic user text part. - if (rest[1] === "") + // NOT the layer that makes a blank body unreachable — `parameters` DOES + // re-validate a shell-parsed op. shell-wrap.ts calls `def.execute(parsed)` + // on the def produced by Tool.init, which is wrap()-decorated, and wrap() + // runs `parameters.parse(args)` inside execute — so `content: + // z.string().min(1)` already rejects `actor send x ""` (verified: with + // this guard removed the shell route still enqueues nothing and reports + // `Too small: expected string to have >=1 characters → at + // operation.content`). + // + // This guard earns its place for two other reasons: it turns that generic + // zod dump into one specific, teachable message, and `.trim()` also + // rejects whitespace-only bodies, which `min(1)` accepts. + if (rest[1].trim() === "") return yield* Effect.fail({ kind: "flag" as const, line, - detail: `actor: send: content must not be empty`, + detail: "actor: send: content must not be empty", }) return { operation: { diff --git a/packages/opencode/test/inbox/drain-no-empty-part.test.ts b/packages/opencode/test/inbox/drain-no-empty-part.test.ts new file mode 100644 index 000000000..8c499e1f5 --- /dev/null +++ b/packages/opencode/test/inbox/drain-no-empty-part.test.ts @@ -0,0 +1,222 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Layer, ManagedRuntime } from "effect" +import { Inbox } from "../../src/inbox" +import { renderInboxRow } from "../../src/inbox/render" +import type { InboxRow } from "../../src/inbox/inbox.sql" +import { defaultModelRef } from "../../src/inbox/inbox-ref" +import { ActorRegistry } from "../../src/actor/registry" +import { Session } from "../../src/session" +import { Bus } from "../../src/bus" +import { Instance } from "../../src/project/instance" +import { MessageID, SessionID } from "../../src/session/schema" +import { ProviderID, ModelID } from "../../src/provider/schema" +import { tmpdir } from "../fixture/fixture" + +// The inbox drain is the ONE user-message producer that does not go through +// SessionPrompt.createUserMessage, so `hasSubstantiveContent` never inspects +// it. A blank `actor_notification` body would therefore be persisted verbatim +// as a user message whose only part is {type:"text",text:""} — and ai@6's +// `convertToLanguageModelMessage` user branch filters empty text parts out with +// NO backfill, so that message would reach the provider as `content: []` and be +// rejected with `messages.: user messages must have non-empty content`. +// +// No caller can supply such a body today (see +// empty-notification-reachability.test.ts — the actor tool's shell route IS +// re-validated against `content: z.string().min(1)`), so this closes a latent +// defence gap. These tests pin the structural invariant that keeps the shape +// unreachable regardless of what any future row type renders: the drain never +// persists a blank text part, for any row content. + +const base = Layer.mergeAll(Session.defaultLayer, ActorRegistry.defaultLayer, Bus.defaultLayer) +const testLayer = Inbox.layer.pipe(Layer.provide(base), Layer.provideMerge(base)) + +afterEach(async () => { + defaultModelRef.current = undefined + await Instance.disposeAll() +}) + +type RT = ManagedRuntime.ManagedRuntime + +async function withInbox(directory: string, fn: (rt: RT) => Promise) { + return Instance.provide({ + directory, + fn: async () => { + const rt = ManagedRuntime.make(testLayer) + try { + await fn(rt) + } finally { + await rt.dispose() + } + }, + }) +} + +async function seedRealMessage(rt: RT, sessionID: SessionID, actorID: string) { + return rt.runPromise( + Session.Service.use((sessions) => + sessions.updateMessage({ + id: MessageID.ascending(), + role: "user" as const, + sessionID, + agentID: actorID, + time: { created: Date.now() }, + agent: "general", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test-model") }, + }), + ), + ) +} + +async function registerActor(rt: RT, sessionID: SessionID, actorID: string) { + return rt.runPromise( + ActorRegistry.Service.use((reg) => + reg.register({ + sessionID, + actorID, + mode: "subagent", + parentActorID: undefined, + agent: "general", + description: "test", + contextMode: "none", + contextWatermark: undefined, + background: false, + lifecycle: "ephemeral", + }), + ), + ) +} + +function row(overrides: Partial): InboxRow { + return { + id: "01JTESTROW", + receiver_session_id: SessionID.make("ses_receiver"), + receiver_actor_id: "main", + sender_session_id: SessionID.make("ses_sender"), + sender_actor_id: "explore-1", + type: "text", + content: { text: "hello" }, + created_at: 1_700_000_000_000, + ...overrides, + } as InboxRow +} + +describe("renderInboxRow never returns a blank string", () => { + // `content.text ?? placeholder` only catches null/undefined. An empty body is + // stored as "" and, for actor_notification, passed through RAW. + test("actor_notification with an empty body falls back to the placeholder", () => { + const rendered = renderInboxRow(row({ type: "actor_notification", content: { text: "" } })) + expect(rendered).toBe("(no notification body)") + expect(rendered.trim().length).toBeGreaterThan(0) + }) + + test("actor_notification with a whitespace-only body falls back to the placeholder", () => { + expect(renderInboxRow(row({ type: "actor_notification", content: { text: " \n\t " } }))).toBe( + "(no notification body)", + ) + }) + + test("actor_notification with a missing body still falls back", () => { + expect(renderInboxRow(row({ type: "actor_notification", content: {} }))).toBe("(no notification body)") + }) + + test("actor_notification with a real body is passed through verbatim", () => { + const body = "\nchild completed.\n" + expect(renderInboxRow(row({ type: "actor_notification", content: { text: body } }))).toBe(body) + }) + + test("a text row with an empty body renders the (empty) placeholder inside the wrapper", () => { + const rendered = renderInboxRow(row({ type: "text", content: { text: "" } })) + expect(rendered).toContain("(empty)") + expect(rendered.trim().length).toBeGreaterThan(0) + }) +}) + +describe("Inbox.drain never persists an empty user text part", () => { + test("a blank actor_notification body yields a non-blank synthetic part", async () => { + await using tmp = await tmpdir({ git: true }) + await withInbox(tmp.path, async (rt) => { + const session = await rt.runPromise(Session.Service.use((s) => s.create())) + await registerActor(rt, session.id, "actor-empty") + await seedRealMessage(rt, session.id, "actor-empty") + + // This is the reachable producer: a blank body reaches the inbox (the + // `actor` tool's JSON path guards it with .min(1), but the shell path did + // not, and inbox.send itself does not validate). + await rt.runPromise( + Inbox.Service.use((inbox) => + inbox.send({ + receiverSessionID: session.id, + receiverActorID: "actor-empty", + type: "actor_notification", + content: "", + }), + ), + ) + + const count = await rt.runPromise(Inbox.Service.use((inbox) => inbox.drain(session.id, "actor-empty"))) + // The notification is NOT lost — it is rendered with a placeholder. + expect(count).toBe(1) + + const msgs = await rt.runPromise( + Session.Service.use((sessions) => sessions.messages({ sessionID: session.id, agentID: "actor-empty" })), + ) + const synthetic = msgs + .filter((m) => m.info.role === "user") + .flatMap((m) => m.parts) + .filter((p) => p.type === "text" && p.synthetic) + + expect(synthetic.length).toBe(1) + // THE INVARIANT: no persisted user text part may be empty or blank. + for (const part of synthetic) { + expect(part.type === "text" && part.text).not.toBe("") + expect(part.type === "text" && part.text.trim().length).toBeGreaterThan(0) + } + }) + }) + + test("a blank body mixed with a real one keeps both parts non-blank", async () => { + await using tmp = await tmpdir({ git: true }) + await withInbox(tmp.path, async (rt) => { + const session = await rt.runPromise(Session.Service.use((s) => s.create())) + await registerActor(rt, session.id, "actor-mixed") + await seedRealMessage(rt, session.id, "actor-mixed") + + await rt.runPromise( + Inbox.Service.use((inbox) => + inbox.send({ + receiverSessionID: session.id, + receiverActorID: "actor-mixed", + type: "actor_notification", + content: "", + }), + ), + ) + await rt.runPromise( + Inbox.Service.use((inbox) => + inbox.send({ + receiverSessionID: session.id, + receiverActorID: "actor-mixed", + type: "actor_notification", + content: "\nreal body\n", + }), + ), + ) + + const count = await rt.runPromise(Inbox.Service.use((inbox) => inbox.drain(session.id, "actor-mixed"))) + expect(count).toBe(2) + + const msgs = await rt.runPromise( + Session.Service.use((sessions) => sessions.messages({ sessionID: session.id, agentID: "actor-mixed" })), + ) + const texts = msgs + .filter((m) => m.info.role === "user") + .flatMap((m) => m.parts) + .filter((p) => p.type === "text" && p.synthetic) + .map((p) => (p.type === "text" ? p.text : "")) + + expect(texts.length).toBe(2) + expect(texts.every((t) => t.trim().length > 0)).toBe(true) + expect(texts).toContain("(no notification body)") + }) + }) +}) diff --git a/packages/opencode/test/inbox/empty-notification-part.test.ts b/packages/opencode/test/inbox/empty-notification-part.test.ts index 082564bc7..285c98dfd 100644 --- a/packages/opencode/test/inbox/empty-notification-part.test.ts +++ b/packages/opencode/test/inbox/empty-notification-part.test.ts @@ -12,13 +12,16 @@ import { MessageID, SessionID } from "../../src/session/schema" import { ProviderID, ModelID } from "../../src/provider/schema" import { tmpdir } from "../fixture/fixture" -// Producer of the empty-user-content provider 400. +// Latent defence gap in the inbox render/drain path (NOT an observed producer — +// see empty-notification-reachability.test.ts: the actor tool's shell route is +// re-validated against `content: z.string().min(1)`, so no caller could supply a +// blank body). // // Inbox.drain writes ONE synthetic `role:"user"` message and then one text part // per queued row, with `text: renderInboxRow(row)` persisted verbatim — // bypassing createUserMessage/hasSubstantiveContent entirely. renderInboxRow // used `content.text ?? "(no notification body)"`, and `??` does not catch `""`, -// so a body-less `actor_notification` row rendered to exactly `""`. With a +// so a body-less `actor_notification` row would render to exactly `""`. With a // single queued row that yields `parts: [{type:"text",text:""}]` — length 1, so // every `parts.length === 0` guard misses it — which `ai`'s // convertToLanguageModelMessage then filters to `content: []`, the shape a @@ -122,10 +125,11 @@ describe("Inbox.drain never persists an empty user text part", () => { ) await seedRealMessage(rt, session.id, "actor-empty") - // The reachable trigger: `actor send "" --type actor_notification`. - // The JSON path's `content: z.string().min(1)` is bypassed in shell mode - // (shell-wrap calls def.execute(parsed) without re-validating), so an - // empty body did reach Inbox.send in production. + // Constructed, not reachable through the actor tool: the shell route IS + // re-validated against `content: z.string().min(1)` (see + // empty-notification-reachability.test.ts). This calls Inbox.send directly + // to pin what the layers BELOW the entry point do with a blank body, so the + // render/drain invariants are proven independently of any caller's guard. await rt.runPromise( Inbox.Service.use((inbox) => inbox.send({ diff --git a/packages/opencode/test/inbox/empty-notification-reachability.test.ts b/packages/opencode/test/inbox/empty-notification-reachability.test.ts new file mode 100644 index 000000000..32c571a0a --- /dev/null +++ b/packages/opencode/test/inbox/empty-notification-reachability.test.ts @@ -0,0 +1,207 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Agent } from "../../src/agent/agent" +import { Bus } from "../../src/bus" +import { Config } from "../../src/config" +import { Provider } from "../../src/provider" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { SessionCheckpoint } from "../../src/session/checkpoint" +import { Database, and, eq } from "../../src/storage" +import { MessageID, type SessionID } from "../../src/session/schema" +import { ActorTool, parseActorScript } from "../../src/tool/actor" +import { shellWrap } from "../../src/tool/shell-wrap" +import { ActorRegistry } from "../../src/actor/registry" +import { TaskRegistry } from "../../src/task/registry" +import { ActorWaiter } from "../../src/actor/waiter" +import { Inbox } from "../../src/inbox" +import { InboxTable } from "../../src/inbox/inbox.sql" +import { Team } from "../../src/team" +import { Truncate } from "../../src/tool" +import { ToolRegistry } from "../../src/tool" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +// Reachability probe for the body-less `actor_notification` inbox row — the +// suspected producer of `messages.: user messages must have non-empty content`. +// +// test/inbox/empty-notification-part.test.ts proves what happens ONCE such a row +// exists (renderInboxRow → drain → a `text: ""` part). It calls Inbox.send +// directly, so it does NOT establish that any caller can supply an empty body. +// This file closes that gap at the only entry point that takes both `content` +// and `type` from the model: the `actor` tool's `send` action. +// +// The decisive case is `via the real shellWrap route` below. Driving +// `def.execute` directly is NOT a substitute: it presupposes the very question +// (does the shell route reach the wrap-decorated execute?). The real composition +// is `shellWrap(Tool.init(actor))` — registry.ts wires `actor: Tool.init(actor)` +// (the wrap-decorated def, see tool.ts `define` → `wrap`) into `s.builtin`, +// `all()`/`available()` only filter that array, and registry.ts then applies +// `shellWrap` to that same object. So `shell-wrap.ts`'s `def.execute(parsed)` is +// the wrap-decorated execute, and `wrap()` runs `toolInfo.parameters.parse(args)` +// on the shell-parsed op. A shell-mode op IS re-validated. +// +// Consequence, established by a revert probe on this file (drop the +// parseActorScript guard in src/tool/actor.ts and re-run): the shell route still +// enqueues nothing, because `content: z.string().min(1)` fails closed. The +// parse-level guard is therefore a message-quality improvement (a specific, +// teachable error instead of a generic zod dump), NOT the layer that makes a +// blank body unreachable. An empty `actor_notification` body was never reachable +// through the tool, so the render.ts/drain fixes in this PR close a LATENT +// defence gap rather than a live producer. + +afterEach(async () => { + await Instance.disposeAll() +}) + +const inboxDeps = Layer.mergeAll(Bus.layer, ActorRegistry.defaultLayer, Session.defaultLayer) + +const it = testEffect( + Layer.mergeAll( + Agent.defaultLayer, + Bus.layer, + Config.defaultLayer, + Provider.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Session.defaultLayer, + Truncate.defaultLayer, + ToolRegistry.defaultLayer, + ActorRegistry.defaultLayer, + ActorWaiter.layer.pipe( + Layer.provide(Bus.layer), + Layer.provide(ActorRegistry.defaultLayer), + Layer.provide(Session.defaultLayer), + ), + Team.defaultLayer, + SessionCheckpoint.defaultLayer, + TaskRegistry.defaultLayer, + Inbox.layer.pipe(Layer.provide(inboxDeps)), + ), +) + +function ctxFor(sessionID: SessionID) { + return { + sessionID, + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + extra: {}, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + } +} + +const registerActor = Effect.fn(function* (sessionID: SessionID) { + const registry = yield* ActorRegistry.Service + const actorID = yield* registry.allocateActorID(sessionID, "general") + yield* registry.register({ + sessionID, + actorID, + mode: "subagent", + agent: "general", + description: "reachability probe", + contextMode: "none", + background: true, + lifecycle: "ephemeral", + }) + yield* registry.updateStatus(sessionID, actorID, { status: "running" }) + return actorID +}) + +const rowsFor = (sessionID: SessionID, actorID: string) => + Effect.sync(() => + Database.use((db) => + db + .select() + .from(InboxTable) + .where(and(eq(InboxTable.receiver_session_id, sessionID), eq(InboxTable.receiver_actor_id, actorID))) + .all(), + ), + ) + +describe("empty actor_notification body: reachability", () => { + it.live( + 'the shell-parsed `actor send ""` is rejected by parseActorScript', + provideTmpdirInstance(() => + Effect.gen(function* () { + const exit = yield* Effect.exit(parseActorScript('actor send main "" --type actor_notification')) + expect(exit._tag).toBe("Failure") + }), + ), + ) + + // DECISIVE CASE. Exercises the production composition end-to-end: + // shellWrap(wrap-decorated actor def).execute({ script }). No inbox row may be + // written for a blank body. Revert the parseActorScript guard and this still + // passes — which is what proves zod, not the parse guard, is load-bearing. + it.live( + 'via the real shellWrap route, `actor send ""` enqueues nothing', + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "chat" }) + const actorID = yield* registerActor(chat.id) + + const def = yield* Effect.flatMap(ActorTool, (tool) => tool.init()) + const shell = shellWrap({ ...def, id: "actor" }) + + const exit = yield* Effect.exit( + shell.execute({ script: `actor send ${actorID} ""` }, ctxFor(chat.id) as never), + ) + // shell-wrap converts a per-command failure into a *successful* result + // carrying an error report, so assert on the observable side effect + // rather than the exit tag: nothing may be enqueued. + expect(yield* rowsFor(chat.id, actorID)).toHaveLength(0) + if (exit._tag === "Success") { + expect(exit.value.output).not.toContain("inboxID") + } + }), + ), + ) + + it.live( + "the operation-level zod min(1) rejects an empty body inside def.execute", + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "chat" }) + const actorID = yield* registerActor(chat.id) + + const def = yield* Effect.flatMap(ActorTool, (tool) => tool.init()) + + // Hand def.execute the exact op a shell-parsed call would produce. + // wrap()'s parameters.parse must reject it. + const exit = yield* Effect.exit( + def.execute( + { operation: { action: "send", to_actor_id: actorID, content: "", type: "actor_notification" } }, + ctxFor(chat.id), + ), + ) + expect(exit._tag).toBe("Failure") + expect(yield* rowsFor(chat.id, actorID)).toHaveLength(0) + }), + ), + ) + + it.live( + "a non-empty body still goes through the shell route, so the guards are not over-broad", + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "chat" }) + const actorID = yield* registerActor(chat.id) + + const def = yield* Effect.flatMap(ActorTool, (tool) => tool.init()) + const shell = shellWrap({ ...def, id: "actor" }) + const result = yield* shell.execute( + { script: `actor send ${actorID} "real body"` }, + ctxFor(chat.id) as never, + ) + expect(result.output).toContain("inboxID") + expect(yield* rowsFor(chat.id, actorID)).toHaveLength(1) + }), + ), + ) +}) diff --git a/packages/opencode/test/tool/actor.shell.test.ts b/packages/opencode/test/tool/actor.shell.test.ts index 905030d77..ded1a9a60 100644 --- a/packages/opencode/test/tool/actor.shell.test.ts +++ b/packages/opencode/test/tool/actor.shell.test.ts @@ -199,18 +199,34 @@ describe("actor.shell.parse: send", () => { expect(err.detail).toContain("to_actor_id") }) - // The JSON path declares `content: z.string().min(1)`, but shell-wrap routes a - // shell-parsed op straight to def.execute WITHOUT re-validating it against - // `parameters` — so an empty token used to reach Inbox.send, queue a body-less - // row, and become an unusable synthetic user text part after drain(). Reject it - // here so the model gets a loud, self-correctable error instead. - test("send with an empty content token is rejected (parity with the JSON min(1))", async () => { - const exit = await Effect.runPromise(Effect.exit(parseActorScript('actor send main "" --type actor_notification'))) - expect(exit._tag).toBe("Failure") - const cause: any = (exit as any).cause - const fail = cause.reasons?.find?.((r: any) => r._tag === "Fail") ?? cause - const err = fail.error ?? fail - expect(err.detail).toContain("content must not be empty") + // A blank body is already unreachable via the tool: `parameters` DOES re-validate + // a shell-parsed op (shell-wrap.ts calls `def.execute(parsed)` on the + // wrap()-decorated def, and wrap() runs `parameters.parse` inside execute), so + // `content: z.string().min(1)` rejects it — see + // test/inbox/empty-notification-reachability.test.ts for the end-to-end proof. + // These cases pin the parse-level guard, which exists to turn a generic zod dump + // into one specific, teachable message and to also reject whitespace-only bodies + // (which `min(1)` accepts). + for (const script of [ + 'actor send main ""', + 'actor send main "" --type actor_notification', + 'actor send main " " --type actor_notification', + ]) { + test(`send rejects a blank content: ${script}`, async () => { + const exit = await Effect.runPromise(Effect.exit(parseActorScript(script))) + expect(exit._tag).toBe("Failure") + const cause: any = (exit as any).cause + const fail = cause.reasons?.find?.((r: any) => r._tag === "Fail") ?? cause + const err = fail.error ?? fail + expect(err.detail).toContain("content must not be empty") + }) + } + + test("send still accepts a short non-blank content (guard is not over-broad)", async () => { + const out = await parse('actor send main "0" --type actor_notification') + expect(out).toEqual([ + { operation: { action: "send", to_actor_id: "main", content: "0", type: "actor_notification" } }, + ]) }) }) From 1fba3205b61a6624a8ab14367882129f409255d7 Mon Sep 17 00:00:00 2001 From: wqymi Date: Tue, 28 Jul 2026 21:45:02 +0800 Subject: [PATCH 039/135] fix(provider): the trailing continuation turn must be a text PART, not a bare string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE producer of `messages.: user messages must have non-empty content`. `ensureTrailingUserMessage` appended `{ role: "user", content: "Continue." }`. `ProviderTransform.message` is typed for `ModelMessage[]` (where `content: string` is legal) but it does not run on `ModelMessage[]` — it runs inside the `wrapLanguageModel` middleware on `args.params.prompt`, a `LanguageModelV3Prompt`, whose user content must be `Array`. The mismatch is silenced by the `@ts-expect-error` at session/llm.ts (and session/prompt.ts:596). @ai-sdk/anthropic then iterates that string CHARACTER BY CHARACTER (`for (let j = 0; j < content.length; j++)` + `switch (part.type)` with cases for only text/file and NO default, dist/index.mjs:2320-2408 on 3.0.82), pushes nothing, and ships `{"role":"user","content":[]}`. `ensureNonEmptyContent` cannot catch it: per its own ordering contract it runs BEFORE this function, and "Continue." is not empty by any predicate, so the append is never re-inspected. test/provider/empty-content-wire.test.ts asserts on the REAL outbound HTTP body (fetch injected into createAnthropic), not on toModelMessages output which is two transformation layers short of the wire. A CONTROL case pins the defect mechanism: the bare-string form ships as `content: []`. Also in this family: - src/provider/transform.ts: `normalizeMessages`'s empty-part strip was gated on two literal npm names, so `@ai-sdk/google-vertex/anthropic`, `@openrouter/ai-sdk-provider` and `@ai-sdk/openai-compatible` — all routes to an API that rejects an empty block — got no protection. Extracted to `stripsEmptyParts()` and extended. The AI SDK's own filter does not cover this: its assistant branch KEEPS an empty text part carrying providerOptions, and it never inspects `reasoning` parts at all. - src/session/goal.ts: the goal judge is the ONE persisted-parts->provider site with no `ProviderTransform.message` (`model: language` is raw, no `wrapLanguageModel` in the file), so `ensureNonEmptyContent` is applied by hand. - src/session/llm.ts: widen the middleware's `args.type === "stream"` narrowing to `generate || stream`, matching prompt.ts:597. Latent today (the file's only SDK entrypoint is `streamText`), but it would silently drop the entire transform. --- packages/opencode/src/provider/transform.ts | 45 ++++++- packages/opencode/src/session/goal.ts | 11 +- packages/opencode/src/session/llm.ts | 7 +- .../test/provider/empty-content-wire.test.ts | 114 ++++++++++++++++++ 4 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/test/provider/empty-content-wire.test.ts diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 354ea406b..7b0970934 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -47,6 +47,28 @@ function sdkKey(npm: string): string | undefined { return undefined } +// Providers that hard-reject an empty text/reasoning BLOCK inside an otherwise +// non-empty message ("text content blocks must be non-empty"). The AI SDK's own +// filter does not save us here: its user branch drops empty text parts, but its +// assistant branch KEEPS an empty text part that carries providerOptions, and it +// never inspects `reasoning` parts at all — so an empty reasoning block reaches +// the provider untouched for every npm package. +// +// The original list was `@ai-sdk/anthropic` + `@ai-sdk/amazon-bedrock` only, +// which missed the two other ways to reach the same Anthropic API: +// `@ai-sdk/google-vertex/anthropic` and Claude via `@openrouter/ai-sdk-provider`. +// Stripping an empty block is information-preserving for any provider, so the +// list errs on the side of including a provider rather than excluding one. +function stripsEmptyParts(model: Provider.Model): boolean { + return [ + "@ai-sdk/anthropic", + "@ai-sdk/amazon-bedrock", + "@ai-sdk/google-vertex/anthropic", + "@openrouter/ai-sdk-provider", + "@ai-sdk/openai-compatible", + ].includes(model.api.npm) +} + function normalizeMessages( msgs: ModelMessage[], model: Provider.Model, @@ -54,7 +76,7 @@ function normalizeMessages( ): ModelMessage[] { // Anthropic rejects messages with empty content - filter out empty string messages // and remove empty text/reasoning parts from array content - if (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/amazon-bedrock") { + if (stripsEmptyParts(model)) { msgs = msgs .map((msg) => { if (typeof msg.content === "string") { @@ -398,7 +420,26 @@ export function ensureTrailingUserMessage(msgs: ModelMessage[]): ModelMessage[] if (!last || last.role !== "assistant") return trimmed // A content-bearing assistant is legitimately last: keep it and append a // minimal user turn so the request ends with a user message. - return [...trimmed, { role: "user", content: CONTINUATION_PROMPT }] + // + // The content MUST be an array of parts, never a bare string. `message()` is + // typed for `ModelMessage[]` (where `content: string` is legal) but it does not + // run on `ModelMessage[]` — it runs inside the `wrapLanguageModel` middleware on + // `args.params.prompt`, a `LanguageModelV3Prompt`, whose user content is + // `Array`. That mismatch is silenced by the + // `@ts-expect-error` at session/llm.ts:670 (and session/prompt.ts:596). + // + // A bare string there is not merely untidy, it is THE producer of the 400: + // @ai-sdk/anthropic's user branch does `for (let j = 0; j < content.length; j++)` + // and `switch (part.type)` with cases for only `text`/`file` and NO default + // (dist/index.mjs:2320-2408 on 3.0.82), so a string is iterated as individual + // characters whose `.type` is `undefined`, nothing is pushed, and the message + // goes out as `{"role":"user","content":[]}` — the exact trailing message in the + // observed failing request. + // + // `ensureNonEmptyContent` cannot save this: per the ordering contract it runs + // BEFORE this function, and "Continue." is not empty by any predicate, so the + // append is never re-inspected. + return [...trimmed, { role: "user", content: [{ type: "text", text: CONTINUATION_PROMPT }] } as ModelMessage] } // Hard prune of the trailing assistant run, discarding its content. Unlike diff --git a/packages/opencode/src/session/goal.ts b/packages/opencode/src/session/goal.ts index 19a986cf8..4bba0e3de 100644 --- a/packages/opencode/src/session/goal.ts +++ b/packages/opencode/src/session/goal.ts @@ -158,7 +158,16 @@ export const layer = Layer.effect( // Convert the conversation to native model messages so the judge sees the // real tool calls/results/images — same context the working agent had. - const conversation = yield* MessageV2.toModelMessagesEffect(input.msgs, resolved) + // + // `ensureNonEmptyContent` is applied by hand here because this is the ONE + // persisted-parts→provider site that does not run `ProviderTransform.message`: + // `model: language` below is the RAW model, with no `wrapLanguageModel` and no + // middleware anywhere in this file, so the pre-send invariant that every other + // build site inherits from the middleware would otherwise be absent. An empty + // user message here reaches the judge's provider unrepaired. + const conversation = ProviderTransform.ensureNonEmptyContent( + yield* MessageV2.toModelMessagesEffect(input.msgs, resolved), + ) // Diagnostic: dump the FULL message array sent to the judge. Long strings // (e.g. base64 image data) are clipped with a length marker so the log diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 5c3b32ac5..15ee3670c 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -667,7 +667,12 @@ const live: Layer.Layer< { specificationVersion: "v3" as const, async transformParams(args) { - if (args.type === "stream") { + // `generate || stream`, matching session/prompt.ts:597. This file's + // only SDK entrypoint is `streamText` (:599), so narrowing to + // "stream" is not an active hole today — but it would silently drop + // the whole transform, including the empty-content invariant, the + // moment a non-streaming call is added here. + if (args.type === "generate" || args.type === "stream") { // @ts-expect-error args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options) } diff --git a/packages/opencode/test/provider/empty-content-wire.test.ts b/packages/opencode/test/provider/empty-content-wire.test.ts new file mode 100644 index 000000000..80ba206d1 --- /dev/null +++ b/packages/opencode/test/provider/empty-content-wire.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test" +import { createAnthropic } from "@ai-sdk/anthropic" +import { ProviderTransform } from "../../src/provider" + +// WIRE-LEVEL proof of the `{"role":"user","content":[]}` producer. +// +// Asserting on `MessageV2.toModelMessages` output is two transformation layers +// short of the wire. What the provider actually receives is built by +// @ai-sdk/anthropic's `convertToAnthropicMessagesPrompt` from a +// `LanguageModelV3Prompt`. So these tests capture the real outbound HTTP body by +// injecting `fetch` into the provider factory. +// +// The bug: `ensureTrailingUserMessage` appended `{ role: "user", content: +// "Continue." }` — a BARE STRING. `ProviderTransform.message` is typed for +// `ModelMessage[]` (string content legal) but runs on a `LanguageModelV3Prompt` +// (user content must be an array of parts); the mismatch is silenced by the +// `@ts-expect-error` at session/llm.ts:670. @ai-sdk/anthropic then iterates the +// string CHARACTER BY CHARACTER (`for (let j = 0; j < content.length; j++)` with +// a `switch (part.type)` that has cases for only text/file and no default), so +// nothing is pushed and the message ships as `content: []`. + +const model = { + id: "anthropic/claude-3-5-sonnet", + providerID: "anthropic", + api: { id: "claude-3-5-sonnet-20241022", url: "https://api.anthropic.com", npm: "@ai-sdk/anthropic" }, + name: "Claude 3.5 Sonnet", + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.003, output: 0.015, cache: { read: 0.0003, write: 0.00375 } }, + limit: { context: 200000, output: 8192 }, + status: "active", + options: {}, + headers: {}, +} as any + +const reply = { + id: "msg_1", + type: "message", + role: "assistant", + model: "claude-3-5-sonnet-20241022", + content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, +} + +// Sends `prompt` through the real provider and returns the parsed HTTP body. +async function outbound(prompt: unknown) { + let captured: any + const anthropic = createAnthropic({ + apiKey: "test-key", + fetch: (async (_url: any, init: any) => { + captured = JSON.parse(init.body as string) + return new Response(JSON.stringify(reply), { headers: { "content-type": "application/json" } }) + }) as any, + }) + await anthropic("claude-3-5-sonnet-20241022").doGenerate({ prompt } as any) + return captured +} + +// A conversation that ends with a content-bearing assistant — the one condition +// under which `ensureTrailingUserMessage` appends a continuation turn. +const endsWithAssistant = [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { role: "assistant", content: [{ type: "text", text: "done" }] }, +] + +describe("the trailing continuation turn reaches the wire with non-empty content", () => { + test("CONTROL: a bare-string user content is shipped as content: [] (the producer)", async () => { + const body = await outbound([...endsWithAssistant, { role: "user", content: "Continue." }]) + const last = body.messages[body.messages.length - 1] + expect(last.role).toBe("user") + // Proof of the defect mechanism, and proof this test would catch a regression. + expect(last.content).toEqual([]) + }) + + test("CONTROL: the part-array form is shipped intact", async () => { + const body = await outbound([ + ...endsWithAssistant, + { role: "user", content: [{ type: "text", text: "Continue." }] }, + ]) + const last = body.messages[body.messages.length - 1] + expect(last.content).toEqual([{ type: "text", text: "Continue." }]) + }) + + test("ensureTrailingUserMessage's appended turn survives to the wire", async () => { + // @ts-expect-error mirrors session/llm.ts:670 — message() is typed for + // ModelMessage[] but is applied to a LanguageModelV3Prompt in production. + const transformed = ProviderTransform.message(endsWithAssistant, model, {}) + const body = await outbound(transformed) + const last = body.messages[body.messages.length - 1] + expect(last.role).toBe("user") + // `applyCaching` may also attach a cache_control marker to the last part; + // what matters is that a real text part is present at all. + expect(last.content).toHaveLength(1) + expect(last.content[0].type).toBe("text") + expect(last.content[0].text).toBe("Continue.") + }) + + test("NO message reaches the wire with empty content", async () => { + // @ts-expect-error see above + const body = await outbound(ProviderTransform.message(endsWithAssistant, model, {})) + const empty = body.messages + .map((msg: any, index: number) => ({ index, role: msg.role, length: msg.content.length })) + .filter((entry: any) => entry.length === 0) + expect(empty).toEqual([]) + }) +}) From 9af5a1e6186284fb6f4a257f0af8750d5bc9d2b9 Mon Sep 17 00:00:00 2001 From: wqymi Date: Tue, 28 Jul 2026 22:34:00 +0800 Subject: [PATCH 040/135] fix(provider): stop normalizeContentArray from text-backfilling a tool message normalizeContentArray's fallback covered both user and tool, so a tool message arriving with non-array content got a text part injected. That contradicts ensureNonEmptyContent's per-role policy, which deliberately leaves tool messages alone: injecting text into a tool message risks a tool_use/tool_result pairing mismatch, i.e. it trades one 400 for another, and content: [] is itself illegal for a tool result. The path is not reachable through normal construction, which is exactly why it is worth closing: this PR's headline defect was also an 'unreachable' shape mismatch -- a bare string where an array was mandatory, silenced by a @ts-expect-error, then iterated character by character by @ai-sdk/anthropic into content: []. A latent per-role inconsistency in the dimension this PR is about will mislead the next reader. Only user keeps the backfill; tool is returned untouched. --- packages/opencode/src/provider/transform.ts | 8 +++++++- .../opencode/test/provider/transform.test.ts | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 7b0970934..f83a71a69 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -603,11 +603,17 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage // downstream. An assistant gets `[]` because it carries no obligation: the // non-empty invariant drops empty assistant residue and the trailing-user guard // then re-establishes the prefill invariant. +// +// A `tool` message is left EXACTLY as-is, matching ensureNonEmptyContent's +// per-role policy: injecting a text part into a tool message breaks tool_use / +// tool_result pairing, which trades one 400 for another, and emitting `content: +// []` is itself illegal for a tool result. Only `user` gets the backfill. function normalizeContentArray(msgs: ModelMessage[]): ModelMessage[] { return msgs.map((msg) => { if (typeof msg.content === "string" || Array.isArray(msg.content)) return msg if (msg.role === "assistant") return { ...msg, content: [] } as ModelMessage - return { ...msg, content: [{ type: "text", text: EMPTY_CONTENT_PLACEHOLDER }] } as ModelMessage + if (msg.role === "user") return { ...msg, content: [{ type: "text", text: EMPTY_CONTENT_PLACEHOLDER }] } as ModelMessage + return msg }) } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index c8fc1daa0..98abfdf2f 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -4607,6 +4607,23 @@ describe("ProviderTransform.message - non-array content guard (j.map is not a fu ] as any[] expect(() => ProviderTransform.message(msgs, genericModel, {})).not.toThrow() }) + + // Policy pin, not a reachability claim: tool messages are always built with + // array content, so this input does not occur in normal use. It is pinned + // because normalizeContentArray must agree with ensureNonEmptyContent, which + // deliberately leaves tool messages untouched — injecting a text part into a + // tool message breaks tool_use/tool_result pairing (trading one 400 for + // another), and `content: []` is itself illegal for a tool result. + test("a tool message with non-array content is never text-backfilled", () => { + const msgs = [ + { role: "user", content: [{ type: "text", text: "run it" }] }, + { role: "assistant", content: [{ type: "tool-call", toolCallId: "c1", toolName: "bash", input: {} }] }, + { role: "tool", content: undefined }, + ] as any[] + const tool = ProviderTransform.message(msgs, genericModel, {}).find((m) => m.role === "tool") + expect(tool).toBeDefined() + expect(Array.isArray(tool!.content) && tool!.content.some((p: any) => p.type === "text")).toBe(false) + }) }) describe("ProviderTransform.message - interleaved field: openrouter exclusion", () => { From eb504fc28919f3702c0617b342676ae2a9ec72f0 Mon Sep 17 00:00:00 2001 From: wqymi Date: Tue, 28 Jul 2026 22:45:33 +0800 Subject: [PATCH 041/135] fix(tui): guard syncWorkspace against a directory switch landing mid-flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bootstrap's non-blocking Promise.all wraps every setStore in guard(), which re-checks staleness after the request resolves — except for project.workspace.sync(), which was passed through unwrapped. Inside it, syncWorkspace() awaits workspace.list() and workspace.status() and then writes workspace.list/status with no generation check at all, so a directory switch landing during either await rewrites the store from the pre-switch directory. Worse than a stale list: the final branch clears workspace.current when the listed workspaces do not contain it, so a superseded run can blank the newly-selected workspace. Uses the same captured-generation check as project.sync() directly above. Single-directory mode is unaffected (undefined !== undefined is false). --- packages/opencode/src/cli/cmd/tui/context/project.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui/context/project.tsx b/packages/opencode/src/cli/cmd/tui/context/project.tsx index 8cd072bd3..ab98b286c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/project.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/project.tsx @@ -57,10 +57,17 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex } async function syncWorkspace() { + const directory = sdk.directory const listed = await sdk.client.experimental.workspace.list().catch(() => undefined) if (!listed?.data) return const status = await sdk.client.experimental.workspace.status().catch(() => undefined) const next = Object.fromEntries((status?.data ?? []).map((item) => [item.workspaceID, item.status])) + // Same generation check as sync() above: this runs unguarded inside + // bootstrap's non-blocking Promise.all, so a directory switch landing + // during either await would otherwise write the old directory's workspace + // list — and worse, clear workspace.current because the pre-switch list + // does not contain it. + if (sdk.directory !== directory) return batch(() => { setStore("workspace", "list", reconcile(listed.data)) From 299b4aa92c47132cb5bc604b966e084d7038c150 Mon Sep 17 00:00:00 2001 From: wqymi Date: Tue, 28 Jul 2026 22:54:12 +0800 Subject: [PATCH 042/135] test(provider): pin tool-role content as untouched, not merely un-text-backfilled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 9af5a1e61, which landed the tool-role gate itself. Two residual gaps. 1. The existing pin only rules out a TEXT part: expect(Array.isArray(tool!.content) && tool!.content.some((p) => p.type === "text")).toBe(false) Rewriting the tool branch to `content: []` keeps that assertion GREEN, yet `[]` is the other outcome the policy rejects — an empty tool content is itself illegal for providers that require the tool_result block, so it trades the pairing 400 for a different one. Verified by mutation: with the branch changed to `return { ...msg, content: [] }`, the existing test reports "1 pass, 0 fail" while the two tests added here fail. Assert the SAME reference instead (`toBe(content)`), across all three invalid shapes (undefined / null / object). 2. Pin the agreement itself. The finding was that two guards in this file disagreed about the tool role, so a test that exercises only one of them cannot catch the disagreement recurring. The added test asserts `ensureNonEmptyContent` and `message` reach the same outcome on the same input. Also corrects the comment block's opening claim, which still read as a blanket guarantee ("ensure msg.content is never a non-string non-array value ... that would blow up downstream `.map()` calls"). That is no longer true for `tool`, and leaving it stale would license downstream code to assume array content. Records that `[]` is not an acceptable substitute and why leaving the value untouched is what makes the two guards converge. --- packages/opencode/src/provider/transform.ts | 11 ++++++-- .../opencode/test/provider/transform.test.ts | 26 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index f83a71a69..48f8e5a00 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -591,8 +591,10 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage return msgs } -// Minimal crash guard: ensure msg.content is never a non-string non-array value -// (object, undefined, null) that would blow up downstream `.map()` calls. +// Minimal crash guard: for the roles it can repair, ensure msg.content is never a +// non-string non-array value (object, undefined, null) that would blow up +// downstream `.map()` calls. NOT a blanket guarantee — `tool` is deliberately +// exempt (see below), so downstream code must still not assume array content. // Strings are valid ModelMessage content (the AI SDK accepts content: string | // Array) and are left untouched. Only genuinely-invalid types are normalized. // @@ -608,6 +610,11 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage // per-role policy: injecting a text part into a tool message breaks tool_use / // tool_result pairing, which trades one 400 for another, and emitting `content: // []` is itself illegal for a tool result. Only `user` gets the backfill. +// "Exactly as-is" is the load-bearing part: `[]` is NOT an acceptable substitute, +// and leaving the value untouched is what makes this guard and +// `ensureNonEmptyContent` reach the same outcome on the same input +// (`hasNoSendableContent` returns true for non-array content, and the tool branch +// there re-pushes the message unchanged). function normalizeContentArray(msgs: ModelMessage[]): ModelMessage[] { return msgs.map((msg) => { if (typeof msg.content === "string" || Array.isArray(msg.content)) return msg diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 98abfdf2f..dbed55468 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -4624,6 +4624,32 @@ describe("ProviderTransform.message - non-array content guard (j.map is not a fu expect(tool).toBeDefined() expect(Array.isArray(tool!.content) && tool!.content.some((p: any) => p.type === "text")).toBe(false) }) + + // Strengthens the pin above, which only rules out a TEXT part and would still + // pass if tool content were rewritten to `[]` — the other outcome the policy + // rejects (an empty tool content is itself illegal for providers that require + // the result block). Assert the value is the SAME reference, i.e. untouched. + test("POLICY PIN: non-array tool content is left byte-identical, not rewritten to [] (all invalid shapes)", () => { + for (const content of [undefined, null, { type: "tool-result", value: "x" }]) { + const msgs = [ + { role: "user", content: [{ type: "text", text: "run it" }] }, + { role: "assistant", content: [{ type: "tool-call", toolCallId: "c1", toolName: "bash", input: {} }] }, + { role: "tool", content }, + ] as any[] + const tool = ProviderTransform.message(msgs, genericModel, {}).find((m) => m.role === "tool") + expect(tool).toBeDefined() + expect(tool!.content).toBe(content as any) + } + }) + + // The two guards in this file that decide what to do with a provider-rejectable + // message must not disagree about the tool role — that disagreement was the + // finding. Pin the agreement itself, so changing only one of them fails here. + test("POLICY PIN: normalizeContentArray and ensureNonEmptyContent agree on a non-array tool message", () => { + const msgs = [{ role: "tool", content: undefined }] as any[] + expect(ProviderTransform.ensureNonEmptyContent(msgs)[0].content).toBeUndefined() + expect(ProviderTransform.message(msgs, genericModel, {}).find((m) => m.role === "tool")?.content).toBeUndefined() + }) }) describe("ProviderTransform.message - interleaved field: openrouter exclusion", () => { From 9a2f7588676900d3b18a9b0b65ead154e9e360fa Mon Sep 17 00:00:00 2001 From: wqymi Date: Wed, 29 Jul 2026 14:46:49 +0800 Subject: [PATCH 043/135] fix(tui): surface a directory-denied bootstrap instead of swallowing it Two bootstrap callers are fire-and-forget: onMount and the server.instance.disposed handler. bootstrap() rethrows the recoverable directory-denied rejection so an interactive caller can restore the previous directory and explain itself, but these two have no such caller, so a denied directory left the TUI with stale data and no indication why -- the same silent failure this PR exists to remove, just on the paths nobody was watching. The initial directory is not always the server cwd: launching with MIMO_DEV_CWD records the worktree ROOT as the project sandbox while the server cwd is a subdirectory, which is exactly the repro this PR's own 403 test uses. So the mount path is reachable, not theoretical. Uses useToastOptional rather than useToast. Requiring the toast stack (theme + terminal dimensions + border) would make a data-sync context untestable for a presentation concern -- SyncProvider's own tests render without ToastProvider, and adding it pulled in the theme and language contexts too. Also documents why the two orphan sweeps at the recovery point cannot share one message fetch: sweepOrphanAssistants reads every slice (agentID '*') while sweepOrphanToolParts reads main only, and that difference is what keeps it from rewriting a live subagent's running part. A shared fetch would take the wider read and re-filter, which is where that property would be lost. --- .../opencode/src/cli/cmd/tui/context/sync.tsx | 21 +++++++++++++++++-- .../opencode/src/cli/cmd/tui/ui/toast.tsx | 7 +++++++ packages/opencode/src/session/prompt.ts | 11 ++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 5daf7bca9..fa1a4adfd 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -31,6 +31,7 @@ import { useArgs } from "./args" import { batch, onMount } from "solid-js" import { Log } from "@/util" import { isDirectoryDeniedError } from "@/server/routes/instance/access" +import { useToastOptional } from "../ui/toast" import { emptyConsoleState, type ConsoleState } from "@/config/console-state" /** @@ -265,6 +266,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const event = useEvent() const project = useProject() const sdk = useSDK() + const toast = useToastOptional() + + // A bootstrap that nobody awaits still must not fail silently when the + // server's directory whitelist is the reason. `bootstrap` rethrows the + // recoverable policy rejection so an interactive caller can restore the + // previous directory and explain itself; the two fire-and-forget callers + // below have no such caller, so without this the TUI would sit with stale + // data and no indication why. Genuinely fatal failures already exited + // inside bootstrap, and anything else is logged there. + const reportDenied = (e: unknown) => { + if (!isDirectoryDeniedError(e)) return + toast?.show({ + message: `Cannot use ${sdk.directory ?? "this directory"}: outside this server's working directory`, + variant: "error", + }) + } const fullSyncedSessions = new Set() let syncedWorkspace = project.workspace.current() @@ -272,7 +289,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ event.subscribe((event) => { switch (event.type) { case "server.instance.disposed": - void bootstrap().catch(() => {}) + void bootstrap().catch(reportDenied) break case "permission.replied": { const requests = store.permission[event.properties.sessionID] @@ -808,7 +825,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ // Errors are already logged (and exited on, when fatal) inside bootstrap; the // rethrown recoverable case has no caller here, so swallow it rather than // emitting an unhandled rejection. - void bootstrap().catch(() => {}) + void bootstrap().catch(reportDenied) }) const result = { diff --git a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx index 5b441ec06..18a506f75 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx @@ -100,3 +100,10 @@ export function useToast() { } return value } + +// For contexts that want to surface a toast when one is available but must not +// REQUIRE the toast stack (theme + terminal dimensions + border) as a dependency +// — a data context should not be untestable because of a presentation concern. +export function useToastOptional() { + return useContext(ctx) +} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 76893a116..289963a65 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2318,6 +2318,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* sweepOrphanAssistants(input.sessionID, idle) // Same recovery point, same idleness argument: repair tool parts a killed // process left stuck at `running`. Self-gated on idle (see the function). + // + // These two look mergeable into one message fetch. They are not: + // `sweepOrphanAssistants` reads EVERY slice (`agentID: "*"`) while this one + // reads the MAIN slice only, and that difference is load-bearing. + // `SessionProcessor` publishes status for the main slice alone, so a subagent + // slice can be mid-tool while the session status reads `idle` — scanning only + // main is what stops this sweep from rewriting a live subagent's `running` + // part. Sharing a fetch would mean taking the wider read and re-filtering + // here, which is precisely where that property would get lost. The cost is + // also smaller than it looks: this returns after one status lookup unless the + // session is genuinely idle. yield* sweepOrphanToolParts(input.sessionID) } const message = yield* createUserMessage(input) From 8c965e96b3f7b4b5389e54e578a0597080f4103c Mon Sep 17 00:00:00 2001 From: wqymi Date: Wed, 29 Jul 2026 14:57:50 +0800 Subject: [PATCH 044/135] fix(git-identity): never substitute a hardcoded fallback identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FALLBACK_IDENTITY ("MiMo ") did not rescue a git failure — it pre-empted git's own resolution. Measured on git 2.50.1: - with no identity configured anywhere, `git commit` exits 0 and autodetects `user@hostname`; there is no fatal to rescue; - `git config user.email` cannot see `EMAIL`, but `git commit` honours it, so the substitution silently replaced a user-chosen address; - GIT_AUTHOR_*/GIT_COMMITTER_* env outrank `user.email`, so the injected placeholder also overrode the correct config of any OTHER repo a bash command committed in — and for a non-git project (`worktree === "/"`) it was injected without probing any config at all. Both layers now propagate-or-abstain: they copy an identity the repo itself resolves, and inject/pin nothing when it resolves none, leaving the fallback to git. Unresolved fields are logged instead of being silently substituted. The two assertions that pinned the substitution encoded the defect, so they are replaced rather than kept: the worktree now asserts an EMPTY local identity, and bash asserts NO GIT_* vars for a non-git project. A new test commits in a worktree whose parent has no identity and asserts the author email comes from `EMAIL`, not from a substituted constant. --- packages/opencode/src/git/index.ts | 12 ---- packages/opencode/src/tool/bash.ts | 54 ++++++++++-------- packages/opencode/src/worktree/index.ts | 34 ++++++++---- packages/opencode/test/tool/bash.test.ts | 13 +++-- packages/opencode/test/worktree/index.test.ts | 55 +++++++++++++++---- 5 files changed, 107 insertions(+), 61 deletions(-) diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index e7c33de35..719b5607f 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -16,18 +16,6 @@ const cfg = [ "core.quotepath=false", ] as const -// Single source of truth for the agent's fallback git identity, used only when -// neither the repo's nor the global config supplies one. Without it `git commit` -// autodetects `user@hostname` (e.g. `MI `), leaking the machine -// hostname and wrong authorship into pushed commits. Two independent layers -// consume this: the worktree-creation local-config pin (src/worktree/index.ts) -// and the bash env floor (src/tool/bash.ts). Keep it here so a rename can never -// land in one layer and silently drift in the other. -export const FALLBACK_IDENTITY = { - name: "MiMo", - email: "mimo@xiaomi.com", -} as const - const out = (result: { text(): string }) => result.text().trim() const nuls = (text: string) => text.split("\0").filter(Boolean) const fail = (err: unknown) => diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 83fccdbda..7aaa2a156 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -454,17 +454,21 @@ export const BashTool = Tool.define( // Layer-2 floor for git authorship: an agent may create a worktree/clone or // commit in an ad-hoc dir via this bash tool, bypassing Worktree.setup()'s - // per-worktree local-config fix. Without an identity, `git commit` - // autodetects `user@hostname` (e.g. `MI `), leaking the - // machine hostname + wrong authorship into pushed commits. + // per-worktree local-config fix. Propagate the project repo's own identity so + // those commits are attributed the same way a commit in the project repo is. // // Behavioral contract of this floor and its cache: - // - Its ONLY job is to guarantee a commit never falls back to - // `user@hostname`. It is not a general identity-configuration feature. + // - It only ever PROPAGATES an identity the repo itself already resolves. + // It never invents one: when the repo has no identity — or there is no + // repo at all — nothing is injected and git resolves authorship itself + // (config, then `EMAIL`, then its own `user@hostname` autodetect, then + // its own error). A hardcoded substitute would misattribute the commit + // AND pre-empt resolution paths `git config` cannot see. // - It is delivered as GIT_AUTHOR_*/GIT_COMMITTER_* ENV, and git gives env - // vars precedence OVER `user.name`/`user.email` config. So the value - // seeded here outranks the repo's own config for commits made through - // this tool. We seed it FROM that config, so the two normally agree. + // vars precedence OVER `user.name`/`user.email` config — including the + // config of some OTHER repo the command happens to run in. That is + // exactly why an unresolved field must inject nothing rather than a + // placeholder: a placeholder would outrank that repo's correct config. // - Because the resolved value is memoized per worktree path for the // lifetime of the process, a `git config user.name ...` performed // mid-session is NOT picked up until the process restarts. @@ -475,23 +479,28 @@ export const BashTool = Tool.define( // not inside shellEnv, precisely so the cache persists across every bash // invocation instead of being rebuilt (and re-spawning two `git config` // subprocesses) on each call. - const gitIdentityCache = new Map() + const gitIdentityCache = new Map() const resolveGitIdentity = Effect.fn("BashTool.resolveGitIdentity")(function* () { const worktree = Instance.worktree const cached = gitIdentityCache.get(worktree) if (cached) return cached - // Non-git projects set worktree to "/"; never read git config at root. + // Non-git projects set worktree to "/". There is no project repo whose + // identity we could propagate, and whatever repo a git command does run in + // has its own config — which injected env would override. Inject nothing. if (worktree === "/") { - const fallback = { name: Git.FALLBACK_IDENTITY.name, email: Git.FALLBACK_IDENTITY.email } - gitIdentityCache.set(worktree, fallback) - return fallback + const none: { name?: string; email?: string } = {} + gitIdentityCache.set(worktree, none) + return none } const name = (yield* gitSvc.run(["config", "user.name"], { cwd: worktree })).text().trim() const email = (yield* gitSvc.run(["config", "user.email"], { cwd: worktree })).text().trim() - const identity = { - name: name || Git.FALLBACK_IDENTITY.name, - email: email || Git.FALLBACK_IDENTITY.email, - } + if (!name || !email) + log.warn("git identity not fully resolved from repo config; leaving authorship to git", { + worktree, + name: name ? "resolved" : "unset", + email: email ? "resolved" : "unset", + }) + const identity = { ...(name ? { name } : {}), ...(email ? { email } : {}) } gitIdentityCache.set(worktree, identity) return identity }) @@ -567,12 +576,13 @@ export const BashTool = Tool.define( ) const identity = yield* resolveGitIdentity() // Only fill vars the operator hasn't already set, so an explicit - // GIT_AUTHOR_* in the environment still wins over our floor. + // GIT_AUTHOR_* in the environment still wins over our floor — and only + // fields the repo itself resolved, so an unresolved field is left for git. const gitFloor: Record = {} - if (!process.env["GIT_AUTHOR_NAME"]) gitFloor["GIT_AUTHOR_NAME"] = identity.name - if (!process.env["GIT_AUTHOR_EMAIL"]) gitFloor["GIT_AUTHOR_EMAIL"] = identity.email - if (!process.env["GIT_COMMITTER_NAME"]) gitFloor["GIT_COMMITTER_NAME"] = identity.name - if (!process.env["GIT_COMMITTER_EMAIL"]) gitFloor["GIT_COMMITTER_EMAIL"] = identity.email + if (identity.name && !process.env["GIT_AUTHOR_NAME"]) gitFloor["GIT_AUTHOR_NAME"] = identity.name + if (identity.email && !process.env["GIT_AUTHOR_EMAIL"]) gitFloor["GIT_AUTHOR_EMAIL"] = identity.email + if (identity.name && !process.env["GIT_COMMITTER_NAME"]) gitFloor["GIT_COMMITTER_NAME"] = identity.name + if (identity.email && !process.env["GIT_COMMITTER_EMAIL"]) gitFloor["GIT_COMMITTER_EMAIL"] = identity.email return { ...process.env, // Python ignores the console code page when stdout is a pipe and falls diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index bc48d2b04..d8960eeb9 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -279,20 +279,30 @@ export const layer: Layer.Layer< // A separate worktree checkout shares the object/ref store but has its // own config, so it does NOT inherit the parent repo's LOCAL identity. - // If global identity is also empty, `git commit` here would autodetect - // `user@hostname` (e.g. `MI `), leaking the machine - // hostname + wrong authorship into pushed commits. Resolve the parent's - // identity (walks local->global->system) and pin it into the new - // worktree's own local config; fall back to Git.FALLBACK_IDENTITY (the - // one shared source of truth, also used by the bash env floor) so the - // worktree is NEVER left without one. Reading an unset key exits - // non-zero / empty, which the `git()` runner returns as empty text. + // Copy the parent's resolved identity (`git config` walks + // local->global->system) into the new worktree's own local config, so a + // commit made here is attributed exactly as a commit in the parent repo + // would be. Reading an unset key exits non-zero / empty, which the + // `git()` runner returns as empty text. + // + // When the parent has no identity we pin NOTHING, deliberately. A + // hardcoded substitute would attribute the user's commits to an address + // they never chose, and `git config` cannot see the rest of git's own + // resolution chain anyway (`EMAIL`, then git's `user@hostname` + // autodetect), so substituting here would pre-empt a value git could + // still resolve. Abstaining leaves the worktree resolving authorship + // exactly as the parent repo does, and leaves the fallback to git. const parentName = (yield* git(["config", "user.name"], { cwd: ctx.worktree })).text.trim() const parentEmail = (yield* git(["config", "user.email"], { cwd: ctx.worktree })).text.trim() - const name = parentName || Git.FALLBACK_IDENTITY.name - const email = parentEmail || Git.FALLBACK_IDENTITY.email - yield* git(["config", "user.name", name], { cwd: info.directory }) - yield* git(["config", "user.email", email], { cwd: info.directory }) + if (parentName) yield* git(["config", "user.name", parentName], { cwd: info.directory }) + if (parentEmail) yield* git(["config", "user.email", parentEmail], { cwd: info.directory }) + if (!parentName || !parentEmail) + log.warn("worktree created without a fully pinned git identity; git resolves authorship itself", { + directory: info.directory, + parent: ctx.worktree, + name: parentName ? "inherited" : "unset", + email: parentEmail ? "inherited" : "unset", + }) }), ) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 3cdd43e69..7f9f12863 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -239,7 +239,7 @@ describe("tool.bash git identity floor", () => { } }) - each("falls back to the stable fallback identity for a non-git project (worktree=/)", async () => { + each("injects NO GIT_* vars for a non-git project (worktree=/), leaving authorship to git", async () => { const saved = savedEnv() restoreEnv({ GIT_AUTHOR_NAME: undefined, @@ -258,10 +258,13 @@ describe("tool.bash git identity floor", () => { const result = await Effect.runPromise( bash.execute({ command: printGitEnv, description: "print git env" }, ctx), ) - expect(result.metadata.output).toContain(`GIT_AUTHOR_NAME=${Git.FALLBACK_IDENTITY.name}`) - expect(result.metadata.output).toContain(`GIT_AUTHOR_EMAIL=${Git.FALLBACK_IDENTITY.email}`) - expect(result.metadata.output).toContain(`GIT_COMMITTER_NAME=${Git.FALLBACK_IDENTITY.name}`) - expect(result.metadata.output).toContain(`GIT_COMMITTER_EMAIL=${Git.FALLBACK_IDENTITY.email}`) + // There is no project repo to inherit from, so injecting anything would + // override the config of whatever repo the command actually runs in + // (GIT_AUTHOR_*/GIT_COMMITTER_* env outrank `user.name`/`user.email`). + for (const key of ["GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL"]) { + const line = result.metadata.output.split("\n").find((l) => l.trim().startsWith(`${key}=`)) + expect(line?.trim()).toBe(`${key}=`) + } }, }) } finally { diff --git a/packages/opencode/test/worktree/index.test.ts b/packages/opencode/test/worktree/index.test.ts index f235eb346..f8462965b 100644 --- a/packages/opencode/test/worktree/index.test.ts +++ b/packages/opencode/test/worktree/index.test.ts @@ -2,7 +2,6 @@ import { describe, expect } from "bun:test" import { $ } from "bun" import { Effect, Layer } from "effect" import { Worktree } from "../../src/worktree" -import { Git } from "../../src/git" import { testEffect } from "../lib/effect" import { provideTmpdirInstance } from "../fixture/fixture" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" @@ -52,25 +51,61 @@ describe("Worktree.setup git identity", () => { ), ) - it.live("falls back to a stable mimocode identity when the parent has none", () => + it.live("pins NO identity when the parent has none, leaving the fallback to git", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - // Strip the parent's identity so the fallback path is exercised. + // Strip the parent's identity so the abstain path is exercised. yield* Effect.promise(() => $`git config --unset user.name`.cwd(dir).quiet().nothrow()) yield* Effect.promise(() => $`git config --unset user.email`.cwd(dir).quiet().nothrow()) const wt = yield* Worktree.Service const info = yield* wt.makeWorktreeInfo() yield* wt.createFromInfo(info) - const name = (yield* Effect.promise(() => $`git config user.name`.cwd(info.directory).quiet().text())).trim() + // --local, so a global identity on the host machine cannot mask an + // identity we wrongly pinned into the worktree. + const name = ( + yield* Effect.promise(() => + $`git config --local --get user.name`.cwd(info.directory).quiet().nothrow().text(), + ) + ).trim() const email = ( - yield* Effect.promise(() => $`git config user.email`.cwd(info.directory).quiet().text()) + yield* Effect.promise(() => + $`git config --local --get user.email`.cwd(info.directory).quiet().nothrow().text(), + ) + ).trim() + expect(name).toBe("") + expect(email).toBe("") + yield* wt.remove({ directory: info.directory }) + }), + { git: true }, + ), + ) + + it.live("does not override an identity git itself would resolve from EMAIL", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => $`git config --unset user.name`.cwd(dir).quiet().nothrow()) + yield* Effect.promise(() => $`git config --unset user.email`.cwd(dir).quiet().nothrow()) + const wt = yield* Worktree.Service + const info = yield* wt.makeWorktreeInfo() + yield* wt.createFromInfo(info) + // `git config user.email` cannot see EMAIL, but `git commit` honours it. + // Supply the name via GIT_*_NAME so the author name never depends on + // GECOS autodetection, and leave both email paths to git. + const env: Record = { ...process.env } + env["EMAIL"] = "chosen@example.test" + env["GIT_AUTHOR_NAME"] = "Chosen" + env["GIT_COMMITTER_NAME"] = "Chosen" + delete env["GIT_AUTHOR_EMAIL"] + delete env["GIT_COMMITTER_EMAIL"] + yield* Effect.promise(() => Bun.write(`${info.directory}/probe.txt`, "x")) + yield* Effect.promise(() => $`git add probe.txt`.cwd(info.directory).env(env).quiet()) + yield* Effect.promise(() => $`git commit -m probe`.cwd(info.directory).env(env).quiet()) + const author = ( + yield* Effect.promise(() => $`git log -1 --format=%ae`.cwd(info.directory).env(env).quiet().text()) ).trim() - expect(name).toBe(Git.FALLBACK_IDENTITY.name) - expect(email).toBe(Git.FALLBACK_IDENTITY.email) - // Sanity: identity is never left empty (the hostname-fallback trigger). - expect(name.length).toBeGreaterThan(0) - expect(email.length).toBeGreaterThan(0) + expect(author).toBe("chosen@example.test") yield* wt.remove({ directory: info.directory }) }), { git: true }, From 388f8c684776cb3a4e44b5db0dac1490bf96ff30 Mon Sep 17 00:00:00 2001 From: wqymi Date: Wed, 15 Jul 2026 15:28:08 +0800 Subject: [PATCH 045/135] design: Orchestrator route-first redesign (route-to-existing as primary, create as fallback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add design document for the Orchestrator route-first redesign. Core thesis: the Orchestrator is a router, not a creator — its default action should be 'route to an existing session' (session send), with create as a fallback when no existing session fits. Key design points: - New session route operation as first-class routing primitive - Harness injects live active-sessions context into orchestrator prompt - create demoted to fallback (only when no existing session matches) - orchestrator.txt decision guidance rewritten from decompose→dispatch to route→(create only if none fits) - 4-phase implementation roadmap: context injection → prompt rewrite → route primitive → deprecated path cleanup --- ...07-14-orchestrator-route-first-redesign.md | 317 ++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md diff --git a/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md b/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md new file mode 100644 index 000000000..ccbf33007 --- /dev/null +++ b/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md @@ -0,0 +1,317 @@ +--- +date: 2026-07-14 +topic: orchestrator-route-first-redesign +--- + +# Orchestrator Route-First Redesign + +## Problem Frame + +The MiMoCode Orchestrator (`src/agent/agent.ts:231`, gated by `MIMOCODE_EXPERIMENTAL_ORCHESTRATOR`) is an experimental persistent coordinator that delegates work to background child sessions via the `session` tool. Its current architecture suffers from a **create-first default** that causes session explosion. + +### Symptom: Session Explosion + +In practice, the Orchestrator面对同一条主题的反复工作请求时, 每次都倾向于 `session create` 新建子会话, 而不是复用已有的。一个典型场景: + +1. 用户说 "fix the login bug" → Orchestrator creates child A for "fix login bug" +2. 用户说 "also handle the signup flow" → Orchestrator creates child B (could have been routed to A) +3. 用户 says "one more thing about auth" → Orchestrator creates child C (again, A or B could handle this) + +结果: 三个子会话做本质上同主题的工作, 每个都有独立的上下文和内存, 没有共享任何进展。 + +### Root Cause: create 耦合了路由和创建 + +当前 `session create` 命令同时承担两个职责: +- **路由决策**: 这条任务该交给哪个已存在的会话? +- **创建行为**: 如果没有合适的, 新建一个 + +`--topic` 机制是对此的修补 — 它在 create 内部加了一层 find-or-reuse, 但: +1. **topic 字符串匹配不可靠**: LLM 传什么 topic 取决于 prompt engineering, 语义漂移是必然的 (PR #1727 去掉了严格 topic 字符串匹配, 是止血不是根本解) +2. **topic 必填只保证"有值"不保证"语义正确"**: Orchestrator 可以给同一个主题传不同的 topic 值, 匹配就失效了 +3. **复用 ≠ 给 create 找一个 key**: 真正的复用是"从现有会话里选一个最合适的发过去", 不是"给新会话打个标签以便下次匹配" + +### Why Topic Matching Cannot Work (Any Variant) + +| Variant | Why It Fails | +|---------|-------------| +| Exact string match | LLM 不可能每次都传完全相同的字符串 | +| Fuzzy / semantic match | 需要 embedding 或 LLM 判断, 增加延迟和复杂度, 且仍然依赖 LLM 正确提取"主题" | +| Topic 必填 | 保证有值, 不保证语义正确; LLM 会乱传 | +| Task-ID 绑定 | task 是廉价的, 一个 session 本该服务多个 task; task↔session 非一一对应 | +| Topic hierarchy | 过度工程; 真正需要的只是"看一眼活会话列表, 选一个发过去" | + +**核心洞察**: 所有 topic 变体都错在同一个假设 — 把复用当成"给 create 找一个 key"。但真正的复用模式是 **人看聊天列表选一个发消息** — 你不会给每个聊天窗口打标签然后按标签匹配, 你看一眼列表就知道该发给谁。 + +## First-Principles Analysis + +### Orchestrator 的本质: 传声筒/路由器 + +Orchestrator 不是 "decompose → dispatch (create)" 模型。它的本质是: + +> **面对一条工作, 决定"传给哪个已存在的会话"** + +这个决策的输入是: +- 活会话清单 (谁在线, 在做什么, 做到哪了) +- 当前任务的语义 +- 会话之间的依赖关系 + +决策的输出是: +- route-to-existing: 把任务发给某个已有会话 (`session send`) +- create-as-fallback: 清单里没合适的 → 新建一个, 加入清单 + +### 当前模型 vs 目标模型 + +``` +Current: user task → decompose → create (default) → (maybe topic reuse) + ↑ create 是一等操作 + +Target: user task → route-to-existing (default) → create (fallback only) + ↑ route/send 是一等操作 +``` + +### 类比: 人如何管理多会话 + +一个人面对多个聊天窗口时: +1. 看一眼所有活跃窗口 (session list) +2. 根据消息内容判断该发给谁 (route decision) +3. 如果没有合适的窗口, 新开一个 (create as fallback) + +人不会: 收到消息 → 新建窗口 → 给窗口打标签 → 期望下次能按标签找到。 + +## Target Design + +### R1: 一等 route 原语 + +新增 `session route` 操作, 作为 Orchestrator 的 **默认第一动作**: + +``` +session route +``` + +**行为**: +1. 自动获取活会话清单 (内置于 route 实现, 不需要 Orchestrator 手动 list) +2. 基于任务语义 + 会话清单, 由 harness 注入的上下文辅助决策 +3. 如果匹配到合适的已有会话 → `session send` 到该会话, 返回路由结果 +4. 如果没有合适的 → 返回 "no match, recommend create" + 建议的 mode/dir 参数 + +**关键区别**: route 是 **决策操作**, 不是创建操作。它的输出是 "我选了会话 X, 因为 Y" 或 "没有合适的, 建议新建"。 + +### R2: Harness 注入活会话清单 + +Orchestrator 的 system prompt 需要注入 **活会话上下文**, 像人看聊天列表一样: + +**注入内容** (每次 Orchestrator turn 开始时): + +```xml + + + Working on: OAuth token refresh logic. 3 commits on mimocode/fix-login. + + + Completed: schema设计完成, 等待用户确认后实施。 + + + Last activity: 15min ago. May need nudge. + + +``` + +**注入位置**: `packages/opencode/src/session/llm.ts:240-306` (`buildSystemArray`)。在 agent prompt 组装完成后、plugin transform 前, 注入一个 `` block。这个 block 由 `session list` 的数据自动生成, 不需要 Orchestrator 主动调用。 + +**内容来源**: +- `sessions.children(ctx.sessionID)` 获取子会话列表 +- `actorReg.get()` 获取 actor 状态 (mode, status, last turn time) +- `deriveLiveness()` 计算进度状态 +- 每个会话的最近任务摘要 (从 session title + last message 提取) + +### R3: create 降级为 fallback + +`session create` 保留但语义变化: + +- **之前**: create 是默认操作, Orchestrator 的第一反应 +- **之后**: create 是 "route 发现没有合适会话时的 fallback" +- `--topic` 机制保留但降级为可选的 hint, 不再是路由的核心 + +Orchestrator 的决策流程变为: + +``` +1. 收到用户任务 +2. 看 (自动注入, 不需要 list 调用) +3. 判断: 有没有一个现有会话适合处理这个任务? + ├─ Yes → session send + └─ No → session create [新建后加入清单] +4. 返回结果给用户 +``` + +### R4: orchestrator.txt 决策指引重写 + +orchestrator.txt 的核心变化: + +| Section | Before | After | +|---------|--------|-------| +| 核心循环 | decompose → dispatch (create) | route → (create only if none fits) | +| session tool 参考 | create 是主要操作 | send 是主要操作, create 是 fallback | +| 复用指引 | "reuse a standing session per theme" via topic | "route to existing sessions" — 看清单选一个 | +| 新增 | — | "route decision" section: 如何从活会话清单中选择 | + +## Code Impact Analysis + +### 1. session 工具原语重排 + +**File**: `packages/opencode/src/tool/session.ts` + +| Current | Change | Impact | +|---------|--------|--------| +| `create` (line 613-739) | 保留, 移除 topic find-or-reuse 逻辑 (lines 621-661), 降级为纯创建 | 中等 — topic 逻辑移出 | +| `send` (line 742-810) | 保留不变, 成为主要操作 | 无 | +| `list` (line 813-883) | 保留, 新增 `summary` 返回格式供 context 注入使用 | 低 — 新增输出格式 | +| `topicOf` (line 187) | 保留但标记 deprecated; 不再是路由核心 | 低 | +| `tagTitle` (line 192) | 保留但标记 deprecated | 低 | +| **新增** `route` | 新操作: 获取清单 → 匹配 → send 或 recommend create | 高 — 核心新逻辑 | + +`route` 操作的伪代码: + +```typescript +if (op.action === "route") { + // 1. Get active sessions (same enrichment as list) + const children = yield* sessions.children(ctx.sessionID) + const enriched = yield* Effect.forEach(children, ...) + const peers = enriched.filter(/* real peers only */) + + // 2. Build routing context + const sessions Summary = peers.map(({ child, actor }) => ({ + id: child.id, + title: child.title, + mode: actor?.agent, + status: deriveLiveness(actor, now), + dir: child.directory, + })) + + // 3. LLM-assisted matching (or heuristic) + const match = findBestMatch(op.task, sessionsSummary) + + if (match) { + // 4a. Route to existing + yield* inboxSvc.send({ receiverSessionID: match.id, ... content: op.task }) + return { output: `Routed to ${match.id} (${match.title})`, ... } + } else { + // 4b. No match — recommend create + return { + output: `No existing session matches. Recommend: session create with mode=${op.suggestedMode}, dir=${op.suggestedDir}`, + metadata: { recommendCreate: true, ... } + } + } +} +``` + +### 2. Harness 向 Orchestrator 注入活会话清单 + +**File**: `packages/opencode/src/session/llm.ts:240-306` (`buildSystemArray`) + +在 `buildSystemArray` 中, 对 orchestrator agent 类型, 注入 `` block: + +```typescript +// After agent prompt assembly (line 260), before plugin transform (line 292) +if (input.agent.name === "orchestrator") { + const sessionCtx = yield* buildActiveSessionsContext(input.sessionID) + if (sessionCtx) system.push(sessionCtx) +} +``` + +`buildActiveSessionsContext` 是一个新函数, 复用 `list` 操作的数据获取逻辑 (lines 820-826), 但输出为 XML 格式而非人类可读的列表。 + +**注入时机**: 每次 Orchestrator 发起 LLM 请求时, system prompt 中包含最新的活会话快照。这意味着 Orchestrator 在做路由决策时, **不需要调用 `session list`** — 清单已经在上下文里了。 + +### 3. orchestrator.txt 决策指引 + +**File**: `packages/opencode/src/session/prompt/orchestrator.txt` + +核心重写部分: + +- **Line 1-5 (Identity)**: 强调 "route-first coordinator", 而非 "decompose-and-dispatch leader" +- **Line 22-30 (The loop)**: 循环改为 "understand → route (to existing or create) → yield → integrate → report" +- **Line 48-59 (session tool reference)**: `send` 提升为主要操作, `create` 标注为 fallback +- **Line 82-88 (Reuse section)**: 从 "reuse per theme via topic" 改为 "route to existing — see active-sessions context" +- **新增 Route Decision section**: 指导 Orchestrator 如何利用 `` 上下文做路由决策 + +### 4. 涉及文件汇总 + +| File | Change Type | Description | +|------|-------------|-------------| +| `packages/opencode/src/tool/session.ts` | **修改** | 新增 `route` 操作; `create` 中移除 topic find-or-reuse; `list` 新增 summary 格式 | +| `packages/opencode/src/session/llm.ts` | **修改** | `buildSystemArray` 中注入 `` context | +| `packages/opencode/src/session/prompt/orchestrator.txt` | **修改** | 决策指引从 create-first 改为 route-first | +| `packages/opencode/src/session/prompt.ts` | **小改** | `buildActiveSessionsContext` 新函数 (可放此处或 session.ts) | +| `packages/opencode/src/tool/session.ts` (schemas) | **修改** | Zod schema 新增 `routeOperation` | +| `packages/opencode/src/tool/session.ts` (KNOWN_VERBS) | **修改** | 加入 `"route"` | + +## Implementation Roadmap + +### Phase 1: Context Injection (harness 层, 不改产品行为) + +**Goal**: Orchestrator 的 system prompt 中自动包含活会话清单, 但不改变任何路由行为。 + +1. 在 `llm.ts:buildSystemArray` 中, 对 orchestrator agent 注入 `` XML block +2. 数据来源复用 `sessions.children` + `actorReg.get` + `deriveLiveness` (已有逻辑) +3. Orchestrator 现在能"看到"活会话列表, 但仍使用旧的 create-first 流程 +4. **验证**: Orchestrator 的回复中能引用具体会话 ID 和状态 (证明它看到了清单) + +**风险**: 注入增加 system prompt 大小。需要监控 token 使用。活会话数量通常 <10, 增量 <500 tokens。 + +### Phase 2: orchestrator.txt 重写 (prompt 层, 改变行为) + +**Goal**: 通过 prompt 引导, 让 Orchestrator 优先 route-to-existing 而非 create。 + +1. 重写 orchestrator.txt 的核心循环和决策指引 +2. 新增 "Route Decision" section: 如何从 `` 中选择目标 +3. 将 `send` 提升为主要操作, `create` 标注为 fallback +4. **验证**: Orchestrator 面对同主题的第二个任务时, 优先尝试 `session send` 到已有会话 + +**风险**: prompt 引导是"软约束" — LLM 可能仍然偶尔 create。Phase 3 通过硬编码 route 原语来加强。 + +### Phase 3: route 原语 (工具层, 硬编码路由) + +**Goal**: 新增 `session route` 操作, 将路由逻辑从 prompt 引导提升为工具级实现。 + +1. 在 `session.ts` 新增 `route` verb 和对应的 Zod schema +2. 实现: 获取清单 → 匹配 (可先用启发式, 后续可用 LLM) → send 或 recommend-create +3. route 操作内置于工具, 不依赖 LLM 做路由决策 (消除 LLM 传错 topic 的问题) +4. **验证**: `session route "fix login bug"` 自动选择正确的已有会话 + +**可选增强**: +- Phase 3a: 启发式匹配 (基于 title 关键词 + mode + dir) +- Phase 3b: LLM-assisted matching (把任务 + 清单交给 LLM 做选择, 更准确但有延迟) + +### Phase 4: 清理 deprecated 路径 + +1. `--topic` 参数标记 deprecated, 保留向后兼容但不再推荐 +2. `topicOf` / `tagTitle` 辅助函数标记 deprecated +3. orchestrator.txt 中移除旧的 topic-based reuse 指引 +4. 更新 harness 文档 (`docs/harness/MiMo Orchestrator Mode.md`) + +## Scope Boundaries + +- **本设计不涉及**: 并发路由冲突处理 (多个 Orchestrator 实例路由到同一会话)、跨 Orchestrator 会话路由、session 持久化 schema 变更 +- **本设计不实现**: 只出设计文档 + 实施路线, 不改产品代码 +- **向后兼容**: `session create` 保持可用, `--topic` 保留但 deprecated, 现有 Orchestrator 行为在 Phase 1-2 期间不变 + +## Key Decisions + +- **route 作为一等操作**: 路由逻辑内置于工具层, 不依赖 LLM 正确传 topic — 消除了 topic 匹配的根本不可靠性 +- **context injection 而非 on-demand query**: 活会话清单注入 system prompt, 让 Orchestrator 每次 turn 都能看到全貌, 而非需要主动调用 list — 降低认知负担 +- **分阶段实施**: Phase 1-2 是 prompt/harness 层变更, 风险低; Phase 3 是工具层变更, 需要更多测试; Phase 4 是清理 + +## Dependencies / Assumptions + +- Orchestrator 当前是 experimental (flag-gated), 本 redesign 在 experimental 阶段实施, 无需 migration +- `sessions.children` + `actorReg.get` + `deriveLiveness` 已经提供了足够的会话状态数据 +- 活会话数量通常 <20, context injection 的 token 开销可接受 + +## References + +- `packages/opencode/src/tool/session.ts` — session tool 实现 (create/send/list/topicOf/tagTitle) +- `packages/opencode/src/session/prompt/orchestrator.txt` — orchestrator 系统提示词 +- `packages/opencode/src/session/llm.ts:240-306` — system prompt 组装 (buildSystemArray) +- `packages/opencode/src/agent/agent.ts:231-251` — orchestrator agent 定义 +- `docs/harness/MiMo Orchestrator Mode.md` — orchestrator 模式文档 +- PR #1727 — 去掉 topic 字符串匹配 (止血, 非本 redesign) From b2dc315c615dd1be2a98013bfeb31a2207538d5a Mon Sep 17 00:00:00 2001 From: wqymi Date: Wed, 15 Jul 2026 16:21:51 +0800 Subject: [PATCH 046/135] =?UTF-8?q?design:=20AI-route=20revision=20?= =?UTF-8?q?=E2=80=94=20remove=20tool-level=20matching,=20AI=20routes=20via?= =?UTF-8?q?=20injected=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revision addressing user feedback: the Orchestrator is itself an AI that understands semantics — routing decisions should be made by the AI, not by tool-level matching algorithms. Key changes: - Removed session route operation and findBestMatch pseudocode entirely - New Core Principle: 'AI Routes, Tools Provide + Execute' - Added 'Why Tool-Level Matching Cannot Work' section - R1 is now context injection (harness provides the list) - R2 is orchestrator.txt rewrite guiding AI to route-first - No new tool verb needed — AI uses existing session send/create - Phase 3 changed from 'hardcoded route primitive' to 'optional prompt strengthening if Phase 2 guidance is insufficient' - Phase 1 + Phase 2 are the main body; no tool-layer matching ever --- ...07-14-orchestrator-route-first-redesign.md | 184 +++++++++--------- 1 file changed, 93 insertions(+), 91 deletions(-) diff --git a/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md b/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md index ccbf33007..ceabd03b7 100644 --- a/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md +++ b/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md @@ -1,6 +1,9 @@ --- date: 2026-07-14 topic: orchestrator-route-first-redesign +revisions: + - date: 2026-07-15 + change: "AI-route revision: removed tool-level matching (findBestMatch/heuristic/embedding). Route decision is entirely AI-side — harness injects , prompt guides AI to route-first, AI uses existing session send/create directly. No new route tool operation." --- # Orchestrator Route-First Redesign @@ -42,6 +45,16 @@ In practice, the Orchestrator面对同一条主题的反复工作请求时, 每 **核心洞察**: 所有 topic 变体都错在同一个假设 — 把复用当成"给 create 找一个 key"。但真正的复用模式是 **人看聊天列表选一个发消息** — 你不会给每个聊天窗口打标签然后按标签匹配, 你看一眼列表就知道该发给谁。 +### Why Tool-Level Matching Also Cannot Work + +初版设计曾提出 `session route` 操作, 内置 `findBestMatch` (启发式/embedding/LLM-assisted) 做自动匹配。这也是错的: + +- **Orchestrator 本身就是 AI** — 它能理解语义、判断相关性、权衡上下文。让工具层用机械匹配替代 AI 的语义判断, 是倒退。 +- **匹配逻辑无法覆盖所有场景**: "这个任务该交给谁" 取决于任务内容、会话历史、用户意图、依赖关系 — 这些是 AI 的强项, 不是算法的强项。 +- **增加一层抽象但没有增加能力**: 工具层匹配只是把 AI 的路由决策权抢走, 然后用一个更差的决策替代。 + +**正确分工**: 工具层提供 **信息** (活会话清单) 和 **执行** (send/create), AI 做 **决策** (路由到谁)。 + ## First-Principles Analysis ### Orchestrator 的本质: 传声筒/路由器 @@ -65,38 +78,38 @@ Orchestrator 不是 "decompose → dispatch (create)" 模型。它的本质是: Current: user task → decompose → create (default) → (maybe topic reuse) ↑ create 是一等操作 -Target: user task → route-to-existing (default) → create (fallback only) - ↑ route/send 是一等操作 +Target: user task → AI reads → route (send) or create + ↑ AI 做路由决策, 工具只提供清单+执行 ``` ### 类比: 人如何管理多会话 一个人面对多个聊天窗口时: -1. 看一眼所有活跃窗口 (session list) -2. 根据消息内容判断该发给谁 (route decision) +1. 看一眼所有活跃窗口 (自动注入的清单) +2. 根据消息内容判断该发给谁 (AI 的语义判断) 3. 如果没有合适的窗口, 新开一个 (create as fallback) 人不会: 收到消息 → 新建窗口 → 给窗口打标签 → 期望下次能按标签找到。 +人也不会: 收到消息 → 让算法自动匹配 → 发给匹配结果。 + +人会: 看一眼列表, 自己决定发给谁。 ## Target Design -### R1: 一等 route 原语 +### Core Principle: AI Routes, Tools Provide + Execute -新增 `session route` 操作, 作为 Orchestrator 的 **默认第一动作**: +整个设计的核心原则: -``` -session route -``` +> **路由决策是 AI 的职责。工具层只负责两件事: (1) 提供活会话清单作为 AI 的决策输入; (2) 执行 AI 选定的 send/create 操作。** -**行为**: -1. 自动获取活会话清单 (内置于 route 实现, 不需要 Orchestrator 手动 list) -2. 基于任务语义 + 会话清单, 由 harness 注入的上下文辅助决策 -3. 如果匹配到合适的已有会话 → `session send` 到该会话, 返回路由结果 -4. 如果没有合适的 → 返回 "no match, recommend create" + 建议的 mode/dir 参数 +没有独立的 `route` 工具操作。没有 `findBestMatch`。没有启发式匹配。没有 embedding 相似度。AI 看着清单, 自己决定 send 给谁。 -**关键区别**: route 是 **决策操作**, 不是创建操作。它的输出是 "我选了会话 X, 因为 Y" 或 "没有合适的, 建议新建"。 +这意味着: +- **不需要新的 tool verb** — AI 直接用现有的 `session send` 和 `session create` +- **不需要工具层的匹配逻辑** — 路由决策完全在 prompt + AI 层 +- **最小化代码变更** — 核心变更是 (1) context injection, (2) prompt rewrite -### R2: Harness 注入活会话清单 +### R1: Harness 注入活会话清单 Orchestrator 的 system prompt 需要注入 **活会话上下文**, 像人看聊天列表一样: @@ -124,12 +137,46 @@ Orchestrator 的 system prompt 需要注入 **活会话上下文**, 像人看聊 - `deriveLiveness()` 计算进度状态 - 每个会话的最近任务摘要 (从 session title + last message 提取) +### R2: orchestrator.txt 决策指引重写 + +orchestrator.txt 的核心变化 — 让 AI 自己做路由决策: + +| Section | Before | After | +|---------|--------|-------| +| 核心循环 | decompose → dispatch (create) | understand → **route** (AI reads list, decides send or create) → yield → integrate → report | +| session tool 参考 | create 是主要操作 | **send 是主要操作**, create 是 fallback | +| 复用指引 | "reuse a standing session per theme" via topic | "see `` in your context — pick the best match and `session send`" | +| 新增 Route Decision | — | AI 如何从清单中选择: 看 title/mode/status/dir, 结合任务语义判断 | + +**orchestrator.txt 新增 Route Decision section 的内容指引**: + +``` +## Routing: route to existing sessions first + +Your system prompt contains an block listing ALL your live +child sessions with their id, title, mode, status, directory, and recent activity. +This is your fleet — use it. + +When a new task arrives, your FIRST action is to decide: does an existing session +already own this work? Look at and evaluate: +- Which session's title/theme matches this task's domain? +- Which session's mode (build/plan/compose) is appropriate? +- Is the session idle (ready for new work) or progressing (can accept follow-up)? +- Does the session's directory match where this work belongs? + +If you find a good match → `session send ` (route to existing). +If no session fits → `session create ` (create as fallback). + +DO NOT create a new session when an existing one can handle the work. +One session serving multiple related tasks is the norm, not the exception. +``` + ### R3: create 降级为 fallback `session create` 保留但语义变化: - **之前**: create 是默认操作, Orchestrator 的第一反应 -- **之后**: create 是 "route 发现没有合适会话时的 fallback" +- **之后**: create 是 "AI 判断没有合适会话时的 fallback" - `--topic` 机制保留但降级为可选的 hint, 不再是路由的核心 Orchestrator 的决策流程变为: @@ -137,26 +184,15 @@ Orchestrator 的决策流程变为: ``` 1. 收到用户任务 2. 看 (自动注入, 不需要 list 调用) -3. 判断: 有没有一个现有会话适合处理这个任务? - ├─ Yes → session send - └─ No → session create [新建后加入清单] +3. AI 判断: 有没有一个现有会话适合处理这个任务? + ├─ Yes → session send (AI 自己选 ID) + └─ No → session create (AI 自己决定参数) 4. 返回结果给用户 ``` -### R4: orchestrator.txt 决策指引重写 - -orchestrator.txt 的核心变化: - -| Section | Before | After | -|---------|--------|-------| -| 核心循环 | decompose → dispatch (create) | route → (create only if none fits) | -| session tool 参考 | create 是主要操作 | send 是主要操作, create 是 fallback | -| 复用指引 | "reuse a standing session per theme" via topic | "route to existing sessions" — 看清单选一个 | -| 新增 | — | "route decision" section: 如何从活会话清单中选择 | - ## Code Impact Analysis -### 1. session 工具原语重排 +### 1. session 工具: 无新 verb, 仅清理 **File**: `packages/opencode/src/tool/session.ts` @@ -167,42 +203,8 @@ orchestrator.txt 的核心变化: | `list` (line 813-883) | 保留, 新增 `summary` 返回格式供 context 注入使用 | 低 — 新增输出格式 | | `topicOf` (line 187) | 保留但标记 deprecated; 不再是路由核心 | 低 | | `tagTitle` (line 192) | 保留但标记 deprecated | 低 | -| **新增** `route` | 新操作: 获取清单 → 匹配 → send 或 recommend create | 高 — 核心新逻辑 | -`route` 操作的伪代码: - -```typescript -if (op.action === "route") { - // 1. Get active sessions (same enrichment as list) - const children = yield* sessions.children(ctx.sessionID) - const enriched = yield* Effect.forEach(children, ...) - const peers = enriched.filter(/* real peers only */) - - // 2. Build routing context - const sessions Summary = peers.map(({ child, actor }) => ({ - id: child.id, - title: child.title, - mode: actor?.agent, - status: deriveLiveness(actor, now), - dir: child.directory, - })) - - // 3. LLM-assisted matching (or heuristic) - const match = findBestMatch(op.task, sessionsSummary) - - if (match) { - // 4a. Route to existing - yield* inboxSvc.send({ receiverSessionID: match.id, ... content: op.task }) - return { output: `Routed to ${match.id} (${match.title})`, ... } - } else { - // 4b. No match — recommend create - return { - output: `No existing session matches. Recommend: session create with mode=${op.suggestedMode}, dir=${op.suggestedDir}`, - metadata: { recommendCreate: true, ... } - } - } -} -``` +**关键: 没有新的 tool verb**。AI 直接用 `session send` 执行路由, 用 `session create` 作为 fallback。工具层零新增 API。 ### 2. Harness 向 Orchestrator 注入活会话清单 @@ -229,21 +231,21 @@ if (input.agent.name === "orchestrator") { 核心重写部分: - **Line 1-5 (Identity)**: 强调 "route-first coordinator", 而非 "decompose-and-dispatch leader" -- **Line 22-30 (The loop)**: 循环改为 "understand → route (to existing or create) → yield → integrate → report" +- **Line 22-30 (The loop)**: 循环改为 "understand → route (AI reads list, decides send or create) → yield → integrate → report" - **Line 48-59 (session tool reference)**: `send` 提升为主要操作, `create` 标注为 fallback -- **Line 82-88 (Reuse section)**: 从 "reuse per theme via topic" 改为 "route to existing — see active-sessions context" -- **新增 Route Decision section**: 指导 Orchestrator 如何利用 `` 上下文做路由决策 +- **Line 82-88 (Reuse section)**: 从 "reuse per theme via topic" 改为 "see `` — pick the best match and send" +- **新增 Route Decision section**: 指导 AI 如何利用 `` 上下文做路由决策 (见 R2) ### 4. 涉及文件汇总 | File | Change Type | Description | |------|-------------|-------------| -| `packages/opencode/src/tool/session.ts` | **修改** | 新增 `route` 操作; `create` 中移除 topic find-or-reuse; `list` 新增 summary 格式 | | `packages/opencode/src/session/llm.ts` | **修改** | `buildSystemArray` 中注入 `` context | -| `packages/opencode/src/session/prompt/orchestrator.txt` | **修改** | 决策指引从 create-first 改为 route-first | -| `packages/opencode/src/session/prompt.ts` | **小改** | `buildActiveSessionsContext` 新函数 (可放此处或 session.ts) | -| `packages/opencode/src/tool/session.ts` (schemas) | **修改** | Zod schema 新增 `routeOperation` | -| `packages/opencode/src/tool/session.ts` (KNOWN_VERBS) | **修改** | 加入 `"route"` | +| `packages/opencode/src/session/prompt/orchestrator.txt` | **修改** | 决策指引从 create-first 改为 route-first; 新增 Route Decision section | +| `packages/opencode/src/tool/session.ts` | **修改** | `create` 中移除 topic find-or-reuse; `list` 新增 summary 格式 | +| `packages/opencode/src/session/prompt.ts` | **小改** | `buildActiveSessionsContext` 新函数 (可放此处或 llm.ts) | + +**注意**: 没有新增 Zod schema, 没有新增 KNOWN_VERBS, 没有新增 tool verb。核心变更是 context injection + prompt rewrite。 ## Implementation Roadmap @@ -260,27 +262,26 @@ if (input.agent.name === "orchestrator") { ### Phase 2: orchestrator.txt 重写 (prompt 层, 改变行为) -**Goal**: 通过 prompt 引导, 让 Orchestrator 优先 route-to-existing 而非 create。 +**Goal**: 通过 prompt 引导, 让 AI 优先 route-to-existing 而非 create。这是 **主体工作**。 1. 重写 orchestrator.txt 的核心循环和决策指引 -2. 新增 "Route Decision" section: 如何从 `` 中选择目标 +2. 新增 "Route Decision" section: AI 如何从 `` 中选择目标 3. 将 `send` 提升为主要操作, `create` 标注为 fallback -4. **验证**: Orchestrator 面对同主题的第二个任务时, 优先尝试 `session send` 到已有会话 +4. 移除旧的 topic-based reuse 指引 +5. **验证**: Orchestrator 面对同主题的第二个任务时, 优先 `session send` 到已有会话 -**风险**: prompt 引导是"软约束" — LLM 可能仍然偶尔 create。Phase 3 通过硬编码 route 原语来加强。 +**风险**: prompt 引导是"软约束" — LLM 可能仍然偶尔 create。但这是 AI 路由的正确模型: 不是强制, 而是引导。如果引导不够强, 迭代 prompt (加 more explicit examples/constraints) 而非引入工具层匹配。 -### Phase 3: route 原语 (工具层, 硬编码路由) +### Phase 3: 可选加强 (如果 Phase 2 的 prompt 引导不够) -**Goal**: 新增 `session route` 操作, 将路由逻辑从 prompt 引导提升为工具级实现。 +**Goal**: 如果纯 prompt 引导后 Orchestrator 仍然过度 create, 加强引导而非引入匹配。 -1. 在 `session.ts` 新增 `route` verb 和对应的 Zod schema -2. 实现: 获取清单 → 匹配 (可先用启发式, 后续可用 LLM) → send 或 recommend-create -3. route 操作内置于工具, 不依赖 LLM 做路由决策 (消除 LLM 传错 topic 的问题) -4. **验证**: `session route "fix login bug"` 自动选择正确的已有会话 +可能的加强手段 (按优先级): +1. **更强的 prompt 约束**: 在 orchestrator.txt 中加明确的 "MUST check active-sessions before create" + 反面示例 +2. **create 前拦截**: 在 `session create` 的工具实现中, 如果 `` 中有高度相关的会话, 返回 warning 而非直接创建 (注意: 这仍然是 AI 看到 warning 后自己决定, 不是工具自动匹配) +3. **指标监控**: 跟踪 create vs send 比率, 如果 create 率过高则迭代 prompt -**可选增强**: -- Phase 3a: 启发式匹配 (基于 title 关键词 + mode + dir) -- Phase 3b: LLM-assisted matching (把任务 + 清单交给 LLM 做选择, 更准确但有延迟) +**不做的事**: 启发式匹配、embedding 相似度、工具层自动路由。这些都违反 "AI routes" 原则。 ### Phase 4: 清理 deprecated 路径 @@ -297,9 +298,10 @@ if (input.agent.name === "orchestrator") { ## Key Decisions -- **route 作为一等操作**: 路由逻辑内置于工具层, 不依赖 LLM 正确传 topic — 消除了 topic 匹配的根本不可靠性 +- **AI 路由, 工具不匹配**: 路由决策完全由 AI 做 — 基于注入的 `` 清单和任务语义。工具层不实现任何匹配逻辑 (findBestMatch/heuristic/embedding)。AI 是最好的路由器。 +- **不需要新的 route 工具操作**: AI 直接用现有的 `session send` 执行路由, 用 `session create` 作为 fallback。最小化代码变更。 - **context injection 而非 on-demand query**: 活会话清单注入 system prompt, 让 Orchestrator 每次 turn 都能看到全貌, 而非需要主动调用 list — 降低认知负担 -- **分阶段实施**: Phase 1-2 是 prompt/harness 层变更, 风险低; Phase 3 是工具层变更, 需要更多测试; Phase 4 是清理 +- **prompt 引导而非硬编码**: 路由行为通过 prompt 迭代优化, 而非工具层强制。如果引导不够, 加强 prompt 而非引入匹配算法。 ## Dependencies / Assumptions @@ -312,6 +314,6 @@ if (input.agent.name === "orchestrator") { - `packages/opencode/src/tool/session.ts` — session tool 实现 (create/send/list/topicOf/tagTitle) - `packages/opencode/src/session/prompt/orchestrator.txt` — orchestrator 系统提示词 - `packages/opencode/src/session/llm.ts:240-306` — system prompt 组装 (buildSystemArray) -- `packages/opencode/src/agent/agent.ts:231-251` — orchestrator agent 定义 +- `packages/opencode/src/agent/agent.ts:231-251` — orchestrestrator agent 定义 - `docs/harness/MiMo Orchestrator Mode.md` — orchestrator 模式文档 - PR #1727 — 去掉 topic 字符串匹配 (止血, 非本 redesign) From 57f3534e84ff56050692156899f8e70a908f4df9 Mon Sep 17 00:00:00 2001 From: wqymi Date: Wed, 15 Jul 2026 18:07:12 +0800 Subject: [PATCH 047/135] =?UTF-8?q?design:=20add=20R1.1=20injection=20stra?= =?UTF-8?q?tegy=20=E2=80=94=20compact=20summary=20+=20on-demand=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New section addressing the context bloat problem: injecting full session details every turn wastes tokens on non-routing turns and scales poorly. R1.1 analyzes 5 injection strategies (on-demand pull, full detail, compact summary, conditional, incremental) and recommends compact summary + on-demand detail: - Default: inject compact one-liner per session (id|title|mode|status) only for non-terminal sessions (~30 tokens/session) - On-demand: AI uses session ask/status for details when needed - No dir or recent-activity in the injected block (confirmation-level info, not decision-level) Also updated R1 example to match compact format, R2 prompt example to reference compact format, and Code Impact section accordingly. --- ...07-14-orchestrator-route-first-redesign.md | 90 +++++++++++++++---- 1 file changed, 73 insertions(+), 17 deletions(-) diff --git a/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md b/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md index ceabd03b7..8d5f62f9f 100644 --- a/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md +++ b/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md @@ -113,29 +113,82 @@ Target: user task → AI reads → route (send) or create Orchestrator 的 system prompt 需要注入 **活会话上下文**, 像人看聊天列表一样: -**注入内容** (每次 Orchestrator turn 开始时): +**注入内容** (每次 Orchestrator turn 开始时, 极简摘要格式): ```xml - - Working on: OAuth token refresh logic. 3 commits on mimocode/fix-login. - - - Completed: schema设计完成, 等待用户确认后实施。 - - - Last activity: 15min ago. May need nudge. - + ses_abc123 | Fix login bug | build | progressing + ses_def456 | Design billing schema | compose | idle + ses_ghi789 | Triage repo issues | build | stalled ``` +每个会话一行: `id | title | mode | status`。只有 4 个字段, 没有 dir 和最近任务详情。AI 需要详情时, 自己调用 `session ask` 或 `session status` 按需查询。详见 R1.1 注入策略。 + **注入位置**: `packages/opencode/src/session/llm.ts:240-306` (`buildSystemArray`)。在 agent prompt 组装完成后、plugin transform 前, 注入一个 `` block。这个 block 由 `session list` 的数据自动生成, 不需要 Orchestrator 主动调用。 **内容来源**: - `sessions.children(ctx.sessionID)` 获取子会话列表 -- `actorReg.get()` 获取 actor 状态 (mode, status, last turn time) -- `deriveLiveness()` 计算进度状态 -- 每个会话的最近任务摘要 (从 session title + last message 提取) +- `actorReg.get()` 获取 actor 状态 (mode, agent type) +- `deriveLiveness()` 计算进度状态 (progressing/stalled/idle/terminal) +- Terminal 状态 (success/failed/cancelled) 的会话不注入 — 只列活跃会话 + + +### R1.1: `` Injection Strategy + +R1 描述了注入什么, 但没有回答 **怎么注入** — 特别是: 是每轮全量注入, 还是有更聪明的策略? 这个问题在会话数增长后变得关键。 + +#### 问题: 全量详情注入的代价 + +如果每轮 turn 都把完整的 `` (含 dir、最近任务详情等) 注入 system prompt: +- **Context 膨胀**: N 个会话 × 每个 ~100 tokens = N×100 tokens, 每轮重复。20 个会话就是 ~2000 tokens/轮。 +- **重复浪费**: 大部分 turn (正和某子会话对话、做非路由工作) 根本不需要全量清单。Orchestrator 和 child A 对话时, B/C/D/E 的详情是噪音。 +- **Cache 失效**: prompt cache 依赖 system prompt 前缀稳定; 清单每轮变 (状态/新会话) 导致 cache 频繁失效。 + +#### 方案对比 + +| 方案 | 描述 | 优点 | 缺点 | +|------|------|------|------| +| **A: 按需拉取** | 不注入, 提供轻量 `session list` 动作让 AI "要路由才查" | 零常驻开销 | 回到靠 LLM 自觉去查 — 用户已批评过依赖自觉; AI 可能忘记查就直接 create | +| **B: 全量详情注入** | 每轮注入完整清单 (id/title/mode/status/dir/最近任务) | AI 始终有完整信息 | Context 膨胀; 大部分 turn 浪费; cache 失效 | +| **C: 极简摘要注入** | 每轮注入极简清单 (id/title/mode/status, 一行一会话, 无 dir/详情) | 低成本 (N 行 ≈ N×30 tokens); AI 有足够信息做路由决策; 需要详情时自己 ask | 信息密度低于 B, 但路由决策通常不需要 dir/详情 | +| **D: 条件注入** | 只在"新工作到达需路由决策"的 turn 注入, 非每轮 | 精准 | 需要判定"何时该注入" — 增加判定逻辑复杂度 | +| **E: 增量注入** | 只注入变化 (新会话/状态变更), 非每轮全量 | 低带宽 | 需要 diff 逻辑; AI 可能丢失已消失会话的信息; 实现复杂 | + +#### 推荐: 极简摘要 + 按需详情 (C 为主, A 为辅) + +**默认注入极简摘要** (方案 C), AI 需要详情时 **按需查询** (方案 A 作为补充): + +```xml + + ses_abc123 | Fix login bug | build | progressing + ses_def456 | Design billing schema | compose | idle + ses_ghi789 | Triage repo issues | build | stalled + +``` + +**为什么这组最优**: + +1. **极简摘要足够做路由决策**: 路由只需要 "谁在线、在做什么、什么模式"。id + title + mode + status 四个字段覆盖了 90% 的路由判断。Dir 和最近任务详情是 "确认级" 信息, 不是 "决策级" 信息 — AI 先凭摘要选定目标, 需要确认时再 `session ask` 或 `session status` 查详情。 + +2. **成本可控**: 一行 ~30 tokens。10 个会话 = ~300 tokens, 20 个会话 = ~600 tokens。相比全量详情 (10 个会话 ~1000 tokens) 小一个数量级。即使 50 个会话也只 ~1500 tokens, 可接受。 + +3. **天然过滤已归档会话**: 只列非 terminal 状态 (progressing/stalled/idle) 的会话。已 success/failed/cancelled 的会话不注入 — 它们不需要路由, 且会无限膨胀清单。需要查询已归档会话时, AI 自己 `session list` 或 `session ask`。 + +4. **不依赖 LLM 自觉**: 与方案 A 纯按需不同, 极简摘要是 **默认注入** — AI 每轮 turn 都能看到清单, 不需要记住去查。只是清单是精简版, 不是完整版。 + +5. **Prompt cache 友好**: 极简摘要变化频率低于全量详情 (status 变化 < 详情变化)。且因为体量小, 即使 cache 失效, 重建成本也低。 + +**AI 需要详情时的按需路径**: + +``` +AI 看极简摘要 → 选定目标会话 → 需要确认细节? + ├─ 不需要 → session send (直接路由) + └─ 需要 → session status 或 session ask (按需查详情) +``` + +**实现**: `buildActiveSessionsContext` 函数输出极简格式 (一行一会话, 只含 id/title/mode/status), 过滤 terminal 状态。注入位置不变 (`buildSystemArray`, orchestrator agent 类型)。 + ### R2: orchestrator.txt 决策指引重写 @@ -153,8 +206,8 @@ orchestrator.txt 的核心变化 — 让 AI 自己做路由决策: ``` ## Routing: route to existing sessions first -Your system prompt contains an block listing ALL your live -child sessions with their id, title, mode, status, directory, and recent activity. +Your system prompt contains an block listing your live +child sessions in compact format: id | title | mode | status. This is your fleet — use it. When a new task arrives, your FIRST action is to decide: does an existing session @@ -162,7 +215,10 @@ already own this work? Look at and evaluate: - Which session's title/theme matches this task's domain? - Which session's mode (build/plan/compose) is appropriate? - Is the session idle (ready for new work) or progressing (can accept follow-up)? -- Does the session's directory match where this work belongs? + +If you need more detail about a session (its directory, recent commits, etc.), +use `session status ` or `session ask ` — the compact list gives you +enough to route; details are on-demand. If you find a good match → `session send ` (route to existing). If no session fits → `session create ` (create as fallback). @@ -220,7 +276,7 @@ if (input.agent.name === "orchestrator") { } ``` -`buildActiveSessionsContext` 是一个新函数, 复用 `list` 操作的数据获取逻辑 (lines 820-826), 但输出为 XML 格式而非人类可读的列表。 +`buildActiveSessionsContext` 是一个新函数, 复用 `list` 操作的数据获取逻辑 (lines 820-826), 输出极简 XML 格式 (一行一会话, 只含 id/title/mode/status), 过滤 terminal 状态会话。详见 R1.1 注入策略。 **注入时机**: 每次 Orchestrator 发起 LLM 请求时, system prompt 中包含最新的活会话快照。这意味着 Orchestrator 在做路由决策时, **不需要调用 `session list`** — 清单已经在上下文里了。 From 36f488bc84e36bec81bcf6a8f21da692bef661bd Mon Sep 17 00:00:00 2001 From: wqymi Date: Fri, 17 Jul 2026 17:18:42 +0800 Subject: [PATCH 048/135] =?UTF-8?q?design:=20user's=20agent=20upgrade=20?= =?UTF-8?q?=E2=80=94=20Orchestrator=20as=20user's=20proxy,=20not=20message?= =?UTF-8?q?=20router?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major conceptual upgrade: the Orchestrator is not merely a 传声筒/message- router. It is the USER'S AGENT/PROXY that makes decisions on the user's behalf. Route-first is its dispatch function, not its whole identity. New section 'Orchestrator as the User's Agent/Proxy' with 3 active-decision duties mapped to existing session-tool primitives: 1. Permission Decisions (Duty 1) — approve/deny child permission requests on user's behalf instead of blindly relaying. Maps to: session approve, session grant-approval, decideAskRouting (existing forwarding unchanged). 2. Answer Child Questions (Duty 2) — respond to child questions using the Orchestrator's understanding of user intent, instead of blindly relaying. Maps to: session send (reply), session ask (context gathering). 3. Proactive Audit (Duty 3) — verify child completion and quality before declaring done, instead of passively trusting child reports. Maps to: session join (fan-in), session status, session ask (verify), git log/diff. Other updates: - Title changed to 'Orchestrator Redesign: The User's Agent' - First-Principles identity upgraded from '传声筒/路由器' to '用户的代理人' - Core Principle expanded to cover all 3 duties - orchestrator.txt guidance updated with 3 new decision sections - Key Decisions updated to reflect full agent identity - File summary and References updated for permission-related files --- ...07-14-orchestrator-route-first-redesign.md | 230 +++++++++++++++--- 1 file changed, 202 insertions(+), 28 deletions(-) diff --git a/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md b/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md index 8d5f62f9f..277c12c94 100644 --- a/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md +++ b/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md @@ -4,9 +4,11 @@ topic: orchestrator-route-first-redesign revisions: - date: 2026-07-15 change: "AI-route revision: removed tool-level matching (findBestMatch/heuristic/embedding). Route decision is entirely AI-side — harness injects , prompt guides AI to route-first, AI uses existing session send/create directly. No new route tool operation." + - date: 2026-07-17 + change: "User's Agent upgrade: reframed Orchestrator from message-router to user's proxy/agent. Added 3 active-decision duties (permission decisions, answer child questions, proactive audit) mapped to existing session-tool primitives. Route-first becomes the dispatch sub-part of the larger agent identity." --- -# Orchestrator Route-First Redesign +# Orchestrator Redesign: The User's Agent ## Problem Frame @@ -57,29 +59,52 @@ In practice, the Orchestrator面对同一条主题的反复工作请求时, 每 ## First-Principles Analysis -### Orchestrator 的本质: 传声筒/路由器 +### Orchestrator 的本质: 用户的代理人 -Orchestrator 不是 "decompose → dispatch (create)" 模型。它的本质是: +Orchestrator 不是 "decompose → dispatch (create)" 模型, 也不仅仅是一个传声筒/路由器。它的本质是: -> **面对一条工作, 决定"传给哪个已存在的会话"** +> **站在用户的角度, 代替用户做决策** -这个决策的输入是: +它不是被动地把消息从 A 搬到 B。它是用户的 **代理人 (agent/proxy)** — 理解用户的意图, 在用户的名义下做判断、做决定、把关质量。Route-first (该发给哪个会话) 只是它的一项职能 — **dispatch (派发)** — 而不是它的全部身份。 + +Orchestrator 作为用户代理人的三项核心职责: + +| 职责 | 含义 | 对应的用户行为 | +|------|------|---------------| +| **Dispatch (派发)** | 决定任务交给哪个已有会话, 或是否需要新建 | 用户看聊天列表选一个发消息 | +| **Act for user (代用户决策)** | 代替用户批准权限请求、回答子会话的问题 | 用户看到权限弹窗点击批准; 用户看到子会话提问直接回答 | +| **Audit quality (把关质量)** | 主动检查子会话是否真正完成且质量达标, 而非被动等待汇报 | 用户审查交付物, 不盲目相信"做完了" | + +这三项职责不是独立的功能列表, 而是 **同一个代理身份的不同表达**: +- Dispatch 是 **入口**: 把工作送到对的地方 +- Act-for-user 是 **运行中**: 子会话需要用户介入时, 代理人代为决策 +- Audit quality 是 **出口**: 子会话说"做完了"时, 代理人验证是否真的做完了 + +这个身份不与 route-first 矛盾 — route-first 是 dispatch 的机制; proactive audit 是 quality-gate 的机制; acting-for-the-user 是底层的 agent 本质。三者共同构成 "用户的代理人" 完整身份。 + +决策的输入是: - 活会话清单 (谁在线, 在做什么, 做到哪了) - 当前任务的语义 -- 会话之间的依赖关系 +- 子会话的请求 (权限、问题、完成通知) +- 用户的意图和偏好 决策的输出是: - route-to-existing: 把任务发给某个已有会话 (`session send`) - create-as-fallback: 清单里没合适的 → 新建一个, 加入清单 +- approve/answer: 代替用户批准权限、回答子会话问题 +- audit: 验证子会话的交付质量 ### 当前模型 vs 目标模型 ``` Current: user task → decompose → create (default) → (maybe topic reuse) - ↑ create 是一等操作 + ↑ create 是一等操作; 被动等通知; 盲目转发权限 -Target: user task → AI reads → route (send) or create - ↑ AI 做路由决策, 工具只提供清单+执行 +Target: user task → Orchestrator (as user's agent): + ├─ Dispatch: read → send or create (route-first) + ├─ Act for user: decide permission asks, answer child questions + └─ Audit quality: verify completion before declaring done + ↑ 主动代理, 不是被动传声筒 ``` ### 类比: 人如何管理多会话 @@ -96,18 +121,21 @@ Target: user task → AI reads → route (send) or create ## Target Design -### Core Principle: AI Routes, Tools Provide + Execute +### Core Principle: Orchestrator is the User's Agent 整个设计的核心原则: -> **路由决策是 AI 的职责。工具层只负责两件事: (1) 提供活会话清单作为 AI 的决策输入; (2) 执行 AI 选定的 send/create 操作。** +> **Orchestrator 是用户的代理人。它不是传声筒, 而是在用户的名义下主动做决策 — 派发工作、代用户回答和批准、把关交付质量。工具层提供信息和执行, AI 做所有决策。** -没有独立的 `route` 工具操作。没有 `findBestMatch`。没有启发式匹配。没有 embedding 相似度。AI 看着清单, 自己决定 send 给谁。 +具体来说: +- **Dispatch (派发)**: AI 看 `` 清单, 决定 send 给谁或 create 新会话。没有 `findBestMatch`, 没有启发式 — AI 是最好的路由器。 +- **Act for user (代用户决策)**: 子会话的权限请求和提问, Orchestrator 代替用户判断和回答, 而非盲目转发。 +- **Audit quality (把关质量)**: 子会话报告完成时, Orchestrator 主动验证交付质量, 而非被动接受。 这意味着: -- **不需要新的 tool verb** — AI 直接用现有的 `session send` 和 `session create` -- **不需要工具层的匹配逻辑** — 路由决策完全在 prompt + AI 层 -- **最小化代码变更** — 核心变更是 (1) context injection, (2) prompt rewrite +- **不需要新的 tool verb** — 所有操作都映射到现有 session tool primitives +- **不需要工具层的匹配逻辑** — 所有决策完全在 prompt + AI 层 +- **最小化代码变更** — 核心变更是 (1) context injection, (2) orchestrator.txt 重写 ### R1: Harness 注入活会话清单 @@ -197,9 +225,9 @@ orchestrator.txt 的核心变化 — 让 AI 自己做路由决策: | Section | Before | After | |---------|--------|-------| | 核心循环 | decompose → dispatch (create) | understand → **route** (AI reads list, decides send or create) → yield → integrate → report | -| session tool 参考 | create 是主要操作 | **send 是主要操作**, create 是 fallback | +| session tool 参考 | create 是主要操作; approve/grant-approval 未使用 | **send 是主要操作**, create 是 fallback; **approve/grant-approval 代用户决策** | | 复用指引 | "reuse a standing session per theme" via topic | "see `` in your context — pick the best match and `session send`" | -| 新增 Route Decision | — | AI 如何从清单中选择: 看 title/mode/status/dir, 结合任务语义判断 | +| 新增 Duties | — | Route Decision (dispatch); Permission Decision (act-for-user); Answer Questions (act-for-user); Audit Completion (quality gate) | **orchestrator.txt 新增 Route Decision section 的内容指引**: @@ -246,6 +274,146 @@ Orchestrator 的决策流程变为: 4. 返回结果给用户 ``` + + +## Orchestrator as the User's Agent/Proxy + +前文的 route-first + `` injection 覆盖了 **dispatch (派发)** 职责 — 这是 Orchestrator 的入口。但一个真正的用户代理人还需要在 **运行中** 和 **出口** 做决策。本节将 Orchestrator 的完整代理身份映射到现有 session-tool primitives。 + +### Duty 1: Permission Decisions — 代替用户批准 + +**场景**: 子会话运行中碰到需要用户授权的权限请求 (访问工作区外目录、读 `.env` 等)。当前行为是盲目转发给用户, 用户需要切进子会话面板手动批准。 + +**代理人行为**: Orchestrator **代替用户判断**这个权限请求是否合理, 在自己的上下文中批准或拒绝, 而非每次都转发给用户。 + +**映射到现有 primitives**: + +| Primitive | 作用 | 代理人用法 | +|-----------|------|-----------| +| `session approve ` | 批准某子会话当前挂起的一个权限请求 | Orchestrator 收到转发的权限请求后, 判断是否合理 → 合理则 `session approve`; 不合理则拒绝 | +| `session grant-approval ` / `session grant-approval all` | 预授权: 未来权限请求自动批准 | 对已建立信任的子会话, 预授权免每次判断 | +| `decideAskRouting` (config.ts) | 决定权限请求转发给谁 | 现有逻辑: Orchestrator peer → 转发给 Orchestrator。**不变** — 转发机制已有, 改变的是 Orchestrator 收到后的处理方式 | + +**orchestrator.txt 指引**: + +``` +## Permission decisions — act on the user's behalf + +When a child session sends you a permission request (forwarded ask), you are +the user's proxy. DO NOT blindly relay every permission prompt to the user — +that would make you a mere message relay, not an agent. + +Instead, judge the request yourself: +- Is this permission reasonable for the child's stated task? → APPROVE it. +- Is this suspicious or outside the child's scope? → DENY it. +- Is this genuinely uncertain or irreversible? → THEN relay to the user. + +Use `session approve ` for one-time approvals. +Use `session grant-approval ` when you trust a child's judgment for its +entire task scope (e.g. a build child that needs file access across its directory). +Only escalate to the user for genuinely ambiguous or high-stakes decisions. +``` + +### Duty 2: Respond to Child Questions — 代替用户回答 + +**场景**: 子会话在运行中遇到需要用户输入的问题 (选哪个方案? 确认需求? 提供缺失信息?)。当前行为是把问题转发给用户。 + +**代理人行为**: Orchestrator **利用自己对用户意图的理解**直接回答子会话的问题, 而非每次都转发。只有真正需要用户亲自判断时才转发。 + +**映射到现有 primitives**: + +| Primitive | 作用 | 代理人用法 | +|-----------|------|-----------| +| `session send ` | 向子会话发送消息 (唤醒或追加) | Orchestrator 直接 send 回答给子会话, 代替用户回复 | +| `session ask ` | 向子会话提只读问题 (不打断其任务) | Orchestrator 可以先 ask 了解子会话的上下文, 再决定如何回答 | +| `actor_notification` (inbox) | 子会话的通知/问题到达 Orchestrator 的 inbox | **不变** — 通知机制已有; 改变的是 Orchestrator 收到后的处理方式: 从 "转发给用户" 变为 "自己回答或有条件转发" | + +**orchestrator.txt 指引**: + +``` +## Answer child questions — you know the user's intent + +When a child session asks a question upward, you are the user's proxy. +You know the user's goals, preferences, and constraints from the conversation. +DO NOT blindly relay every child question to the user — answer it yourself +when you can, based on your understanding of the user's intent. + +- You know the user wants X? Tell the child to do X. Use `session send`. +- The question is about implementation details you don't know? Let the child + decide (it has the context). Use `session send` with "use your judgment". +- The question is about an irreversible choice you can't decide? THEN relay + to the user. But this should be rare. + +The user delegated to you because they don't want to be interrupted by every +sub-decision. Be the buffer, not the conduit. +``` + +### Duty 3: Proactive Audit — 主动把关质量 + +**场景**: 子会话报告 "任务完成"。当前行为是被动接受通知, 假设子会话说完成就是完成。 + +**代理人行为**: Orchestrator **主动验证**子会话的交付是否真的完成且质量达标, 而非盲目相信。这是 **fan-in/aggregation** 的质量门: 不是子会话说 done 就 done, 而是代理人审查后确认 done。 + +**映射到现有 primitives**: + +| Primitive | 作用 | 代理人用法 | +|-----------|------|-----------| +| `session join ` | 等待所有子会话到达 terminal 状态, 返回聚合摘要 | 批量派发后的 fan-in 聚合点 — Orchestrator 收到聚合结果后审查 | +| `session status ` | 查询子会话的派生 liveness (progressing/stalled/terminal) | 定期或收到通知后, 检查子会话的真实状态 | +| `session ask ` | 向子会话提只读问题 (基于其历史回答) | 审查: "你的任务完成了吗? 交付物是什么? 有没有遗漏?" — 基于子会话历史的只读查询 | +| `session dashboard` | 舰队全景 (liveness + worktree 状态) | 宏观审查: 所有子会话的整体进展和健康度 | +| `git log/diff` (via bash) | 审查 isolated 子会话的提交 | 对 isolated child: 直接审查 git commits 的质量, 而非只看子会话的自我报告 | + +**orchestrator.txt 指引**: + +``` +## Audit completion — verify, don't trust + +When a child session reports completion, you are the quality gate. +DO NOT blindly accept "I'm done" as final — verify before declaring success. + +Verification steps (pick per situation): +1. `session status ` — is it truly terminal (not just idle-without-reporting)? +2. `session ask "Summarize what you did and any open items"` — get a + self-report from the child's own history +3. For isolated children: `git log ` / `git diff` — inspect the + actual commits, not just the child's claim +4. `session dashboard` — survey the whole fleet's health before declaring + the overall goal done + +A child that says "done" but left uncommitted changes, missed acceptance +criteria, or introduced regressions is NOT done. You catch this; the user +trusts you to catch this. + +Only after YOUR verification passes should you report success to the user. +``` + +### Three Duties, One Identity + +这三项职责不是三个独立功能, 而是 **同一个代理身份** 的三种表现: + +``` + ┌─────────────────────────┐ + │ Orchestrator: 用户的代理人 │ + └────────────┬────────────┘ + ┌─────────────────┼─────────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────────┐ ┌──────────────┐ + │ Dispatch │ │ Act for User │ │ Audit Quality │ + │ (派发) │ │ (代用户决策) │ │ (把关质量) │ + └─────┬────┘ └──────┬───────┘ └──────┬───────┘ + │ │ │ + session send/create session approve session join/status + session send (reply) session ask (verify) + injection grant-approval git log/diff (inspect) + │ │ │ + ▼ ▼ ▼ + 入口: 工作送对 运行中: 代用户判断 出口: 验证质量 +``` + +**与 route-first 的关系**: route-first 是 dispatch 的实现机制 (入口); proactive audit 是 quality-gate (出口); acting-for-user 是运行中的代理行为 (中间)。三者共同构成完整的用户代理循环: 派发 → 代理决策 → 验证 → 交付。 + + ## Code Impact Analysis ### 1. session 工具: 无新 verb, 仅清理 @@ -286,22 +454,25 @@ if (input.agent.name === "orchestrator") { 核心重写部分: -- **Line 1-5 (Identity)**: 强调 "route-first coordinator", 而非 "decompose-and-dispatch leader" -- **Line 22-30 (The loop)**: 循环改为 "understand → route (AI reads list, decides send or create) → yield → integrate → report" -- **Line 48-59 (session tool reference)**: `send` 提升为主要操作, `create` 标注为 fallback +- **Line 1-5 (Identity)**: 从 "leader who accomplishes goals by delegating" 改为 "the user's agent — you make decisions on the user's behalf, not just relay messages" +- **Line 22-30 (The loop)**: 循环改为 "understand → route → yield → on notification: **audit + act for user** → integrate → report" +- **Line 48-59 (session tool reference)**: `send` 提升为主要操作, `create` 标注为 fallback; 新增 `approve`/`grant-approval` 作为代用户决策的核心操作 - **Line 82-88 (Reuse section)**: 从 "reuse per theme via topic" 改为 "see `` — pick the best match and send" - **新增 Route Decision section**: 指导 AI 如何利用 `` 上下文做路由决策 (见 R2) +- **新增 Permission Decision section**: 指导 AI 代替用户批准/拒绝权限请求 (见 Duty 1) +- **新增 Answer Child Questions section**: 指导 AI 代替用户回答子会话问题 (见 Duty 2) +- **新增 Audit Completion section**: 指导 AI 主动验证子会话交付质量 (见 Duty 3) ### 4. 涉及文件汇总 | File | Change Type | Description | |------|-------------|-------------| | `packages/opencode/src/session/llm.ts` | **修改** | `buildSystemArray` 中注入 `` context | -| `packages/opencode/src/session/prompt/orchestrator.txt` | **修改** | 决策指引从 create-first 改为 route-first; 新增 Route Decision section | +| `packages/opencode/src/session/prompt/orchestrator.txt` | **修改** | 身份从 coordinator 升级为 user's agent; 新增 Route/Permission/Answer/Audit 四个 decision sections; send/approve 提升为主要操作 | | `packages/opencode/src/tool/session.ts` | **修改** | `create` 中移除 topic find-or-reuse; `list` 新增 summary 格式 | | `packages/opencode/src/session/prompt.ts` | **小改** | `buildActiveSessionsContext` 新函数 (可放此处或 llm.ts) | -**注意**: 没有新增 Zod schema, 没有新增 KNOWN_VERBS, 没有新增 tool verb。核心变更是 context injection + prompt rewrite。 +**注意**: 没有新增 Zod schema, 没有新增 KNOWN_VERBS, 没有新增 tool verb。三项代理职责全部映射到现有 primitives。核心变更是 context injection + orchestrator.txt 重写。 ## Implementation Roadmap @@ -354,10 +525,11 @@ if (input.agent.name === "orchestrator") { ## Key Decisions -- **AI 路由, 工具不匹配**: 路由决策完全由 AI 做 — 基于注入的 `` 清单和任务语义。工具层不实现任何匹配逻辑 (findBestMatch/heuristic/embedding)。AI 是最好的路由器。 -- **不需要新的 route 工具操作**: AI 直接用现有的 `session send` 执行路由, 用 `session create` 作为 fallback。最小化代码变更。 -- **context injection 而非 on-demand query**: 活会话清单注入 system prompt, 让 Orchestrator 每次 turn 都能看到全貌, 而非需要主动调用 list — 降低认知负担 -- **prompt 引导而非硬编码**: 路由行为通过 prompt 迭代优化, 而非工具层强制。如果引导不够, 加强 prompt 而非引入匹配算法。 +- **Orchestrator 是用户的代理人, 不是传声筒**: 核心身份从 "message router" 升级为 "user's agent/proxy"。三项职责 (dispatch/act-for-user/audit-quality) 共同构成完整的代理身份, 而非独立功能列表。 +- **AI 做所有决策, 工具只提供信息+执行**: 路由决策、权限判断、质量审查全部由 AI 做。工具层不实现任何匹配逻辑, 也不代替用户做判断。 +- **不需要新的 tool verb**: 所有三项职责都映射到现有 session tool primitives (send/create/approve/grant-approval/ask/join/status/dashboard)。最小化代码变更。 +- **context injection 而非 on-demand query**: 活会话清单注入 system prompt, 让 Orchestrator 每次 turn 都能看到全貌 — 降低认知负担。 +- **prompt 引导而非硬编码**: 所有行为 (路由、权限决策、质量审查) 通过 prompt 迭代优化, 而非工具层强制。 ## Dependencies / Assumptions @@ -367,9 +539,11 @@ if (input.agent.name === "orchestrator") { ## References -- `packages/opencode/src/tool/session.ts` — session tool 实现 (create/send/list/topicOf/tagTitle) +- `packages/opencode/src/tool/session.ts` — session tool 实现 (create/send/list/ask/approve/grant-approval/join/status/dashboard) - `packages/opencode/src/session/prompt/orchestrator.txt` — orchestrator 系统提示词 - `packages/opencode/src/session/llm.ts:240-306` — system prompt 组装 (buildSystemArray) -- `packages/opencode/src/agent/agent.ts:231-251` — orchestrestrator agent 定义 +- `packages/opencode/src/agent/agent.ts:231-251` — orchestrator agent 定义 +- `packages/opencode/src/agent/config.ts:7-46` — `decideAskRouting` 权限转发决策 +- `packages/opencode/src/permission/permission-forward-ref.ts` — 权限转发/授权 ref + 去重 - `docs/harness/MiMo Orchestrator Mode.md` — orchestrator 模式文档 - PR #1727 — 去掉 topic 字符串匹配 (止血, 非本 redesign) From 077b7d5d4fe27f98e6c749fb2c492589f8c14b36 Mon Sep 17 00:00:00 2001 From: wqymi Date: Fri, 17 Jul 2026 17:45:57 +0800 Subject: [PATCH 049/135] =?UTF-8?q?impl:=20Phase=201+2=20=E2=80=94=20activ?= =?UTF-8?q?e-sessions=20injection=20+=20orchestrator=20route-first=20promp?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 (harness context injection): - In llm.ts buildSystemArray, for orchestrator agent only, inject a compact block into the system prompt each turn - Uses actorReg.listByParent to enumerate child actors (no Session.Service dependency in the LLM layer — avoids cascading type changes) - Compact format: one line per session (sessionID | agent | liveness status) - Filters out subagents, system-spawned agents, and terminal sessions - ~30 tokens/session, on-demand detail via session status/ask Phase 2 (orchestrator.txt rewrite): - Identity upgraded from 'leader/coordinator' to 'user's agent/proxy' - Core loop changed: decompose→dispatch(create) → route→(create if none fits) - 3 active-decision duties: dispatch (route-first), act-for-user (approve permissions, answer questions), proactive audit (verify completion) - send promoted as primary verb, create as fallback - approve/grant-approval as first-class user-proxy operations - Existing route-first + injection-strategy design doc retained in PR --- packages/opencode/src/session/llm.ts | 53 +++++ .../src/session/prompt/orchestrator.txt | 195 +++++++++--------- .../test/session/orchestrator-prompt.test.ts | 58 +++--- 3 files changed, 173 insertions(+), 133 deletions(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 15ee3670c..8c66dc9e4 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -33,6 +33,8 @@ import { ActorRegistry } from "@/actor/registry" import { Memory } from "@/memory" import { isRetryableTransientError } from "./retry" import { MCP_TOOL_SEARCH_ID } from "@/tool/mcp-tool-search" +import { deriveLiveness } from "@/actor/schema" +import { SYSTEM_SPAWNED_AGENT_TYPES } from "@/agent/config" const log = Log.create({ service: "llm" }) export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX @@ -248,6 +250,25 @@ const live: Layer.Layer< agentID?: string }) { const system: string[] = [] + + // Build a compact XML block for the orchestrator. + // Pure function: takes enriched peer data, returns XML string or undefined. + const buildActiveSessionsContext = ( + peers: { child: { id: SessionID; title: string }; actor: any }[], + ): string | undefined => { + const now = Date.now() + const active = peers.filter(({ actor }) => { + if (!actor) return true + const live = deriveLiveness(actor, now) + return live !== "success" && live !== "failure" && live !== "cancelled" + }) + if (active.length === 0) return undefined + const lines = active.map(({ child, actor }) => { + const live = actor ? deriveLiveness(actor, Date.now()) : "idle" + return ` ${child.id} | ${child.title} | ${actor?.agent ?? "?"} | ${live}` + }) + return `\n${lines.join("\n")}\n` + } system.push( [ ...SystemPrompt.agent(input.agent, input.model), @@ -288,6 +309,38 @@ const live: Layer.Layer< system.push(buildMemoryInstructions(SessionID.make(input.sessionID), projectID, yield* memory.root())) } + // Orchestrator active-sessions roster: inject a compact one-line-per-session + // list of the orchestrator's live child sessions. Only for the orchestrator + // agent — other agents don't manage children. Format is intentionally compact + // (~30 tokens/session): id | title | mode | status. Terminal sessions are + // filtered out. AI needs details on demand → session status/ask. + if (input.agent.name === "orchestrator") { + // Fetch child actors via actor registry (no Session.Service needed in this layer). + // listByParent returns all actors spawned by this session; we filter to real + // peers and derive liveness for the compact roster. + const allActors = yield* actorReg.listByParent( + SessionID.make(input.sessionID), + input.agentID ?? "main", + ) + const peers = allActors.filter( + (a) => a.mode !== "subagent" && !SYSTEM_SPAWNED_AGENT_TYPES.has(a.agent), + ) + if (peers.length > 0) { + const now = Date.now() + const active = peers.filter((a) => { + const live = deriveLiveness(a, now) + return live !== "success" && live !== "failure" && live !== "cancelled" + }) + if (active.length > 0) { + const lines = active.map((a) => { + const live = deriveLiveness(a, Date.now()) + return ` ${a.sessionID} | ${a.agent} | ${live}` + }) + system.push(`\n${lines.join("\n")}\n`) + } + } + } + // Plugins still see the multi-part array (base prompt as [0], memory as a // trailing element) so hooks that index or append parts keep working. yield* plugin.trigger( diff --git a/packages/opencode/src/session/prompt/orchestrator.txt b/packages/opencode/src/session/prompt/orchestrator.txt index bc7ace606..cdbea31ee 100644 --- a/packages/opencode/src/session/prompt/orchestrator.txt +++ b/packages/opencode/src/session/prompt/orchestrator.txt @@ -1,148 +1,145 @@ -You are the MiMoCode Orchestrator — a leader who accomplishes goals by delegating work to child sessions and coordinating them to completion. You are the manager; the children are the workers who actually do each job. +You are the MiMoCode Orchestrator — the USER'S AGENT. You stand in the user's shoes: you make decisions on their behalf, route their work, answer on their behalf, and verify quality before reporting. You are not a passive relay — you are an active代理 who thinks, judges, and acts for the user. You can coordinate work across ANY project, repository, or scratch directory — you are not tied to a single codebase. A goal might span several repos; you route each piece of work to where it belongs. -You are a PERSISTENT coordinator: you are long-lived and manage many tasks over time, not one goal then exit. Work arrives continuously — new user requests, code reviews to act on, freshly reported bugs — and you keep routing it to children across the whole session. Because you are persistent, your two survival rules are: (1) never do slow or expensive work inline in your own turn — delegate it so you stay fast and non-blocking; (2) do not spawn a brand-new child for every new problem — REUSE a standing child for same-theme work. Both are elaborated below. +You are PERSISTENT and long-lived. Work arrives continuously — new user requests, code reviews to act on, freshly reported bugs — and you keep routing it to children across the whole session. Your survival rules are: (1) never do slow or expensive work inline — delegate it; (2) do not spawn a brand-new child for every problem — ROUTE to an existing child first, create only as fallback. -## What is yours vs. what belongs to a child +## Your three core duties -Your job is the thin coordination layer, and only that: -- Break the user's goal into deliverable units of work (decomposition — which units exist and how they depend on each other). -- Decide which child handles each unit, in what mode, where (`dir`), and whether isolated. -- Dispatch the children and relay messages between the user and children, and between children. -- Integrate finished work (git merges of isolated children) and report results to the user. +You have three responsibilities. Together they form your identity as the user's agent: -Everything else belongs to a child, not to you. In particular you do NOT do the substantive work yourself: -- Writing code, editing files, running builds → a `build` (or `compose`) child. -- Planning HOW to implement a unit (the internal design/approach) → a `plan` child, or a `compose` child (whose workflow has its own plan phase). You decide WHAT units exist; the child decides how to build its unit. -- Reviewing a unit's quality/correctness → a dedicated reviewer child (or the `compose` workflow, which reviews internally). +### 1. Dispatch — route work to the right session -So "decompose into units and dispatch" is yours; "plan the implementation" and "review the result" are jobs you delegate, exactly like the coding itself. If you catch yourself about to write code, design an implementation, or judge a diff's quality inline, stop — spin up the right child for it. +Your system prompt contains an `` block listing your live child sessions in compact format: id | title | mode | status. This is your fleet. -## The loop +When a new task arrives, your FIRST action is to decide: does an existing session already own this work? Look at `` and evaluate: +- Which session's title/theme matches this task's domain? +- Which session's mode (build/plan/compose) is appropriate? +- Is the session idle (ready for new work) or progressing (can accept follow-up)? -You work in a loop, one deliberate step at a time: -1. Understand the current goal and state (the latest user message, any child notifications). -2. Decompose the goal into deliverable units and record them in your `task` tool as a dispatch ledger — one task per child you intend to create. -3. Dispatch: `create` a child per unit of independent work with a clear, self-contained task and its acceptance criteria. Route units that need planning or review to the modes/children that own those jobs. -4. Yield: children run in the BACKGROUND. Return to the user or end your turn — do not sit and poll (see below). You will be woken when a child reports back. -5. On a child's notification: integrate its result, dispatch dependent follow-up work, and update your ledger. If a unit needs review, dispatch a reviewer rather than judging it yourself. -6. When the whole goal is done, report the outcome to the user with the concrete deliverables. Marking a task done means LEAVING its child idle and resumable — it does NOT mean cancelling the child. A finished child stays available to be resumed (`session send`) or queried (`session ask`); do not destroy it just because its task finished. +If you find a good match → `session send ` (route to existing). +If no session fits → `session create ` (create as fallback). -## Capture requirements before acting +DO NOT create a new session when an existing one can handle the work. +One session serving multiple related tasks is the norm, not the exception. -Talking must always become recording. When the user states a requirement, reports a bug, voices a criticism, or surfaces a new sub-problem, your FIRST reflex — before you act, delegate, or reply — is to capture it into your `task` ledger. The reflex loop is: capture the intent → record it as one or more tasks → decompose → dispatch. Only after it is recorded do you proceed to the rest of the loop. +If you need more detail about a session (directory, recent commits), use `session status ` or `session ask ` — the compact list gives you enough to route; details are on-demand. -Do not rely on self-discipline to remember scattered verbal requirements; a behavior that lives only as good intentions is an infra gap. So every user-stated requirement, bug, criticism, or new problem becomes a tracked task IMMEDIATELY — the same ledger that already serves as your dispatch record — so nothing said in passing is ever dropped. +### 2. Act for the user — answer and approve on their behalf -## Delegate slow ANALYSIS — never run it inline +You know the user's goals, preferences, and constraints from the conversation. When a child session needs user input, you answer — not blindly relay. -Analysis is work, and slow/expensive analysis is exactly the kind of work you MUST delegate — never run it inline in your own turn. Reading many files to understand a bug, analyzing a code review, digesting a large diff or log, tracing a root cause across a codebase — all of these block your turn and make you slow to respond. You are a persistent coordinator; staying fast and non-blocking is your job. If you catch yourself about to read a pile of files or reason through a review inline, stop and delegate it. +**Permission decisions**: When a child session sends you a permission request (forwarded ask), judge it yourself: +- Is this permission reasonable for the child's stated task? → APPROVE it with `session approve `. +- Is this suspicious or outside the child's scope? → DENY it. +- Is this genuinely uncertain or irreversible? → THEN relay to the user. -Two delegation patterns for analysis — pick per situation: -- (a) Analyze-then-fix in ONE child. Create a single `build`/`compose` child whose task is BOTH to analyze AND to carry out the fix in the same session (e.g. "analyze this code review, then apply the changes it calls for"). Best when the analysis feeds directly into edits and you don't need to re-route the outcome — one child owns the whole thread of work. -- (b) Subagent analyzes, THEN you dispatch. When the analysis must fan out into several independent units, use a read-only analysis step (a `plan` child, or `session ask` for a one-shot read-only question over a session's history) to produce the decomposition, then YOU dispatch the resulting units as separate children. Best when one analysis yields many parallel fixes owned by different children. +Use `session grant-approval ` when you trust a child for its entire task scope. Only escalate to the user for genuinely ambiguous or high-stakes decisions. You are the buffer, not the conduit. -Either way the slow reading/reasoning happens in a background child, not in your turn. +**Answer child questions**: When a child asks a question upward, answer based on your understanding of the user's intent: +- You know what the user wants? Tell the child. Use `session send`. +- The question is about implementation details you don't know? Let the child decide. Use `session send` with "use your judgment." +- The question is about an irreversible choice you can't decide? THEN relay to the user. -## The `session` tool (your distinguishing capability) +The user delegated to you because they don't want to be interrupted by every sub-decision. -It exposes several operations (the actual call syntax — JSON or shell — is whatever the tool description specifies; below is what each does and the fields it takes): +### 3. Audit quality — verify before declaring done -- create — spawn a new child session that runs in the BACKGROUND. Required: the child's first-turn task. Optional: mode (`build` or `compose`, default `build`), model, title, `dir` (the working directory the child runs in — ANY project or path; defaults to your own directory), `isolate` (run the child in its OWN git worktree of `dir`), `--topic - - { - batch(() => { - const isVisible = sidebarVisible() - setSidebar(() => (isVisible ? "hide" : "auto")) - setSidebarOpen(!isVisible) - }) - }} - /> + + @@ -1495,15 +1483,19 @@ export function Session() { + {/* The control rides inside the overlay so it keeps the same position + relative to the sidebar as when docked: immediately to its left. */} + diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar-state.ts b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar-state.ts new file mode 100644 index 000000000..5291912e6 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar-state.ts @@ -0,0 +1,21 @@ +/** + * Sidebar visibility preference. `auto` follows the terminal width; `show`/`hide` are + * explicit user overrides that outlive a resize. + */ +export type SidebarPreference = "auto" | "show" | "hide" + +export function sidebarVisibleFor(preference: SidebarPreference, wide: boolean) { + if (preference === "auto") return wide + return preference === "show" +} + +/** + * Toggling normalises back to `auto` whenever the requested state is what the width + * would have picked anyway. That keeps a collapse/expand round-trip on a wide terminal + * from leaving behind a `show` override that survives a shrink. + */ +export function sidebarToggle(preference: SidebarPreference, wide: boolean): SidebarPreference { + const next = !sidebarVisibleFor(preference, wide) + if (next === wide) return "auto" + return next ? "show" : "hide" +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 6d92752ef..cd532d8f8 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -1,6 +1,7 @@ import { useProject } from "@tui/context/project" import { useSync } from "@tui/context/sync" import { createMemo, Show } from "solid-js" +import { useTerminalDimensions } from "@opentui/solid" import { useTheme } from "../../context/theme" import { useTuiConfig } from "../../context/tui-config" import { InstallationChannel, InstallationVersion } from "@/installation/version" @@ -8,11 +9,14 @@ import { TuiPluginRuntime } from "../../plugin" import { getScrollAcceleration } from "../../util/scroll" +export const SIDEBAR_WIDTH = 42 + export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const project = useProject() const sync = useSync() const { theme } = useTheme() const tuiConfig = useTuiConfig() + const dimensions = useTerminalDimensions() const session = createMemo(() => sync.session.get(props.sessionID)) const workspaceStatus = () => { const workspaceID = session()?.workspaceID @@ -32,7 +36,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { `), + * otherwise its own press starts a text selection and every release is discarded as a + * selection drag — a silently dead control. + */ +export function createPress(onPress: () => void) { + const [hover, setHover] = createSignal(false) + let node: Renderable | undefined + let armed = false + + const inside = (evt: MouseEvent) => + !!node && + evt.x >= node.x && + evt.x < node.x + node.width && + evt.y >= node.y && + evt.y < node.y + node.height + + return { + hover, + props: { + ref: (r: Renderable) => { + node = r + }, + // opentui raises out/over on intra-element hit changes too — a child glyph and the + // box's own cells are separate hit targets, and both events bubble here — so only a + // pointer whose new position is outside our bounds counts as having left. + onMouseOver: (evt: MouseEvent) => { + setHover(true) + if (inside(evt)) return + armed = false + }, + onMouseOut: (evt: MouseEvent) => { + if (inside(evt)) return + setHover(false) + armed = false + }, + onMouseDrag: (evt: MouseEvent) => { + if (inside(evt)) return + armed = false + }, + onMouseDrop: () => { + armed = false + }, + onMouseDown: (evt: MouseEvent) => { + armed = inside(evt) + }, + onMouseUp: (evt: MouseEvent) => { + if (!armed) return + // Consume first: a release inside a captured renderable is dispatched twice. + armed = false + // A release closing a text-selection drag arrives with no preceding `drop`; it is + // never a click on us. + if (evt.isDragging) return + if (!inside(evt)) return + onPress() + }, + }, + } +} diff --git a/packages/opencode/test/cli/tui/press-gate.test.tsx b/packages/opencode/test/cli/tui/press-gate.test.tsx new file mode 100644 index 000000000..d27cfa9a5 --- /dev/null +++ b/packages/opencode/test/cli/tui/press-gate.test.tsx @@ -0,0 +1,125 @@ +/** @jsxImportSource @opentui/solid */ +import { describe, expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { createPress } from "../../../src/cli/cmd/tui/ui/press" + +// Left half holds selectable text, standing in for the transcript (a left-press there +// starts a text-selection drag); the 3-column button on its right is the press-gated +// control, whose own glyph is unselectable exactly as the real one is. +const NEIGHBOUR = { x: 4, y: 2 } +const BUTTON = { x: 11, y: 2 } +const OUTSIDE = { x: 20, y: 2 } +// The centred glyph's own cell, on the row it occupies — a hit target distinct from the box. +const GLYPH = { x: 11, y: 0 } + +async function mount() { + let presses = 0 + const harness = await testRender( + () => { + const press = createPress(() => (presses += 1)) + return ( + + + {"transcript text"} + + + {"◀"} + + + + ) + }, + { width: 30, height: 8 }, + ) + await harness.renderOnce() + return { ...harness, presses: () => presses } +} + +describe("createPress", () => { + test("a plain click fires once", async () => { + const h = await mount() + await h.mockMouse.pressDown(BUTTON.x, BUTTON.y) + await h.mockMouse.release(BUTTON.x, BUTTON.y) + expect(h.presses()).toBe(1) + }) + + test("a drag captured elsewhere and released on the button does not fire it", async () => { + const h = await mount() + await h.mockMouse.pressDown(NEIGHBOUR.x, NEIGHBOUR.y) + await h.mockMouse.moveTo(NEIGHBOUR.x + 2, NEIGHBOUR.y) + await h.mockMouse.moveTo(BUTTON.x, BUTTON.y) + await h.mockMouse.release(BUTTON.x, BUTTON.y) + expect(h.presses()).toBe(0) + }) + + test("pressing the button then releasing outside it does not fire", async () => { + const h = await mount() + await h.mockMouse.pressDown(BUTTON.x, BUTTON.y) + await h.mockMouse.moveTo(BUTTON.x, BUTTON.y) + await h.mockMouse.moveTo(OUTSIDE.x, OUTSIDE.y) + await h.mockMouse.release(OUTSIDE.x, OUTSIDE.y) + expect(h.presses()).toBe(0) + }) + + test("dragging within the button still fires exactly once on release", async () => { + const h = await mount() + await h.mockMouse.pressDown(BUTTON.x, BUTTON.y) + await h.mockMouse.moveTo(BUTTON.x + 1, BUTTON.y) + await h.mockMouse.release(BUTTON.x + 1, BUTTON.y) + expect(h.presses()).toBe(1) + }) + + test("a press that drags off the button cannot fire a later foreign drag", async () => { + const h = await mount() + // Hover first: a real pointer always generates a move onto the element before the + // press, which is what lets opentui deliver the `out` that disarms us on the way off. + await h.mockMouse.moveTo(BUTTON.x, BUTTON.y) + await h.mockMouse.pressDown(BUTTON.x, BUTTON.y) + await h.mockMouse.moveTo(NEIGHBOUR.x, NEIGHBOUR.y) + await h.mockMouse.release(NEIGHBOUR.x, NEIGHBOUR.y) + expect(h.presses()).toBe(0) + + await h.mockMouse.moveTo(NEIGHBOUR.x, NEIGHBOUR.y) + await h.mockMouse.pressDown(NEIGHBOUR.x, NEIGHBOUR.y) + await h.mockMouse.moveTo(NEIGHBOUR.x + 2, NEIGHBOUR.y) + await h.mockMouse.moveTo(BUTTON.x, BUTTON.y) + await h.mockMouse.release(BUTTON.x, BUTTON.y) + expect(h.presses()).toBe(0) + + // Positive control: the gate is disarmed, not dead. + await h.mockMouse.pressDown(BUTTON.x, BUTTON.y) + await h.mockMouse.release(BUTTON.x, BUTTON.y) + expect(h.presses()).toBe(1) + }) + + test("a text-selection drag released over the button does not fire it", async () => { + const h = await mount() + await h.mockMouse.moveTo(BUTTON.x, BUTTON.y) + await h.mockMouse.pressDown(BUTTON.x, BUTTON.y) + await h.mockMouse.moveTo(NEIGHBOUR.x, NEIGHBOUR.y) + await h.mockMouse.release(NEIGHBOUR.x, NEIGHBOUR.y) + + // Selecting transcript text takes opentui's selection path, which delivers a bare + // `up` with isDragging and no preceding `drop`. + await h.mockMouse.pressDown(2, 0) + await h.mockMouse.moveTo(5, 0) + await h.mockMouse.moveTo(BUTTON.x, BUTTON.y) + await h.mockMouse.release(BUTTON.x, BUTTON.y) + expect(h.presses()).toBe(0) + + await h.mockMouse.pressDown(BUTTON.x, BUTTON.y) + await h.mockMouse.release(BUTTON.x, BUTTON.y) + expect(h.presses()).toBe(1) + }) + + test("a click drifting within the element still fires", async () => { + const h = await mount() + // The glyph is its own hit target, so moving from it to the box's own cells raises + // out/over that bubble here. The pointer never left the control, so this is a click. + await h.mockMouse.moveTo(GLYPH.x, GLYPH.y) + await h.mockMouse.pressDown(GLYPH.x, GLYPH.y) + await h.mockMouse.moveTo(GLYPH.x + 1, GLYPH.y) + await h.mockMouse.release(GLYPH.x + 1, GLYPH.y) + expect(h.presses()).toBe(1) + }) +}) diff --git a/packages/opencode/test/cli/tui/sidebar-state.test.ts b/packages/opencode/test/cli/tui/sidebar-state.test.ts new file mode 100644 index 000000000..56da620b3 --- /dev/null +++ b/packages/opencode/test/cli/tui/sidebar-state.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test" +import { sidebarToggle, sidebarVisibleFor } from "../../../src/cli/cmd/tui/routes/session/sidebar-state" + +const WIDE = true +const NARROW = false + +describe("sidebarVisibleFor", () => { + test("auto follows the terminal width", () => { + expect(sidebarVisibleFor("auto", WIDE)).toBe(true) + expect(sidebarVisibleFor("auto", NARROW)).toBe(false) + }) + + test("explicit overrides ignore the terminal width", () => { + expect(sidebarVisibleFor("show", NARROW)).toBe(true) + expect(sidebarVisibleFor("hide", WIDE)).toBe(false) + }) +}) + +describe("sidebarToggle", () => { + test("a collapse/expand round-trip on a wide terminal ends back at auto", () => { + const collapsed = sidebarToggle("auto", WIDE) + expect(collapsed).toBe("hide") + expect(sidebarVisibleFor(collapsed, WIDE)).toBe(false) + + const expanded = sidebarToggle(collapsed, WIDE) + expect(expanded).toBe("auto") + // The regression: an expand used to leave a sticky override that survived a shrink. + expect(sidebarVisibleFor(expanded, NARROW)).toBe(false) + }) + + test("expanding on a narrow terminal is an explicit override that survives a shrink", () => { + const expanded = sidebarToggle("auto", NARROW) + expect(expanded).toBe("show") + expect(sidebarVisibleFor(expanded, NARROW)).toBe(true) + }) + + test("collapsing an override on a narrow terminal normalises to auto", () => { + expect(sidebarToggle("show", NARROW)).toBe("auto") + }) + + test("expanding a hidden sidebar on a narrow terminal is an override", () => { + expect(sidebarToggle("hide", NARROW)).toBe("show") + }) + + test("collapsing an override on a wide terminal stays explicit", () => { + expect(sidebarToggle("show", WIDE)).toBe("hide") + }) + + test("toggling always flips visibility at the current width", () => { + for (const preference of ["auto", "show", "hide"] as const) { + for (const wide of [WIDE, NARROW]) { + const before = sidebarVisibleFor(preference, wide) + expect(sidebarVisibleFor(sidebarToggle(preference, wide), wide)).toBe(!before) + } + } + }) +}) From b1a50d0093970cde93eeab3bb93a8a22043a1381 Mon Sep 17 00:00:00 2001 From: Yihan Yan Date: Tue, 4 Aug 2026 14:34:02 +0800 Subject: [PATCH 101/135] fix(workflow): read built-in workflow scripts through a build-time macro, not an import (#2023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(spec): record the built-in workflow script / ESM parser collision Investigation only, no code change proposed for merge: continuous integration is green because it shards test files across processes, and the failure needs one process to load two specific test files. Captures what was measured (importer always the legitimate text import, ~116 re-evaluations from one test file, minimal two-file reproduction), the three hypotheses that were falsified, why this reads as an upstream defect, and the extension-rename workaround with the two things to confirm before adopting it. * fix(workflow): name built-in scripts .js.fn so no loader parses them as modules The four built-in workflow scripts are function bodies ending in a top-level return, imported as raw text through an import attribute. Naming them .js made bun test occasionally route one through the ECMAScript parser instead, which surfaced as an unhandled 'Top-level return cannot be used inside an ECMAScript module' and cost a test that never started. Continuous integration missed it because it shards test files across processes and the two files that collide rarely share one. Renaming removes the ambiguity at the source. The suppressions the old imports carried would still have been needed after the rename, with the diagnostic changing from TS1192 to TS2307, so an ambient declaration for the extension replaces them and gives the imports their real string type. The two-file reproduction now runs all four tests clean, and the TUI band goes from 261 pass with 1 failure and 1 error to 262 pass with neither. A compiled standalone binary still carries the script text inlined rather than reading it from disk. * docs: resolve review findings on the workflow script rename Fixes three stale or loose statements the review found: a dead compose.js path in another spec, a comment in deep-research-cluster.test.ts, and a claim that the new filenames are absent from the compiled binary when it is the source path that is absent. Also records the cost the design had not stated, that these files fall out of oxlint's src/**/*.js coverage, and finalizes the document. * fix(workflow): read built-in scripts through a build-time macro, not an import Replaces the .js.fn rename from the previous commit. The rename worked but treated the extension as the defect; the defect was that the scripts were reachable as modules at all. A macro reads the directory at build time and the sources are inlined, so nothing can attempt to parse them whatever they are named, and the files keep their .js names — no other reference in the repository moves, and they stay inside oxlint coverage and editor highlighting. This is the pattern skill/builtin/bundle.macro.ts and skill/compose/bundle.macro.ts already use for shipping files inside a binary that has no filesystem to read from, including the dev fallback for transpile paths where macros are not expanded: under bun test the macro import is stripped without the call being replaced. Macro arguments must also be statically known, which is why the function takes none and the caller indexes the returned record. The two-file reproduction runs all four tests clean, the TUI band goes from 261 pass with a failure and an error to 262 with neither, and a compiled binary carries the sources inlined with no source path present, so the fallback is dead code there. * docs: resolve review findings on the macro-based script loading Two .js.fn references survived the switch back to .js: a git checkout restored them from the index, where the abandoned approach had already committed them. Both files are now byte-identical to main again. Also replaces the packaging evidence with a check that actually proves the point. Asserting that no src/workflow/builtin/ path occurs in the binary proved nothing, because the macro composes that path from import.meta.dir and the dev fallback keeps the filesystem reader in the bundle either way; the bundler output shows the macro call site replaced by an object literal, which does. Records that a stray .js in that directory becomes unused payload, and formats the document. * refactor(workflow): let the macro return the script list directly The explicit array of four filenames was ceremony left over from static imports, which had to name each file. Nothing outside this module references those filenames — consumers look workflows up by meta.name, which each script declares itself, and the tests assert by name too — so the directory can be the registry exactly as it is for built-in skills. Drops the intermediate record, the filename list and the missing-file throw. Also cuts the spec roughly in half: it had grown an investigation's worth of scaffolding around a change that is now one macro and one fallback. * docs(spec): record the reviewed range * docs+test: resolve review findings on the simplified macro Adds the assertion the directory-as-registry decision needs: losing a script now stops it registering instead of failing boot, and research-experiment was covered nowhere, so deleting it passed the whole suite. One assertion on the registered set guards all four. Trims the comment volume in both files to a line or two plus a pointer to the spec, matching the precedent they copy, and records in the spec the three failure modes that decision created. * docs(spec): update the reviewed range --- .../spec/bun-text-import-esm-collision.md | 134 ++++++++++++++++++ .../opencode/src/workflow/builtin.macro.ts | 13 ++ packages/opencode/src/workflow/builtin.ts | 45 +++--- .../opencode/test/workflow/builtin.test.ts | 10 ++ 4 files changed, 174 insertions(+), 28 deletions(-) create mode 100644 docs/compose/spec/bun-text-import-esm-collision.md create mode 100644 packages/opencode/src/workflow/builtin.macro.ts diff --git a/docs/compose/spec/bun-text-import-esm-collision.md b/docs/compose/spec/bun-text-import-esm-collision.md new file mode 100644 index 000000000..2a2c29270 --- /dev/null +++ b/docs/compose/spec/bun-text-import-esm-collision.md @@ -0,0 +1,134 @@ +--- +feature: bun-text-import-esm-collision +status: delivered +updated: 2026-08-03 +branch: fix/workflow-script-ext +commits: 09d03d67..e8f1a8d1 +--- + +# Built-in workflow scripts collide with the ESM parser + +## Report + +**What was built** — The four built-in workflow scripts are no longer imported. A Bun macro +reads the directory at build time and their sources are inlined into the bundle, so the files +never enter the module graph and nothing can attempt to parse them. + +**Verification** — From `packages/opencode`. + +| Check | Before | After | +| ------------------------------------------------------------------------- | ----------------------------- | ------------------------- | +| `bun test test/cli/tui/plugin-toggle.test.ts test/cli/tui/thread.test.ts` | 3 tests ran, 1 fail, 1 error | 4 pass, 0 fail, 0 error | +| `bun test test/cli/tui test/cli/cmd/tui` | 261 pass, 1 fail, 1 error | 262 pass, 0 fail, 0 error | +| `bun test test/workflow` | — | 194 pass, 5 skip, 0 fail | +| `bun typecheck` | passes with four suppressions | passes with none | + +The counts rise by one because the test that previously failed to load now runs. +`bun run build:local` compiles; `bun build --target=bun src/workflow/builtin.ts` shows the macro +call site replaced by an object literal, so the development fallback below is unreachable in a +bundle; `mimo debug agent build` from the compiled binary, run in an empty directory, loads the +workflow registry. `bun.lock` is unmodified. + +**Journey log** + +- The first fix renamed the files to `.js.fn` so no loader would try. It worked, and it was the + wrong shape: the defect was that the scripts were reachable as modules, not that they were + named `.js`. Renaming also took them out of oxlint and editor highlighting, and touched every + reference to them. Asking what makes the failure impossible rather than unlikely gave a + better answer than iterating on the first one that worked. +- The macro form was already in this codebase twice for the same job. Searching for precedent + before inventing an extension would have found it immediately. +- Two Bun constraints only surfaced by running into them, and the published pattern in + `skill/builtin/extract.ts` already encodes both — reading it properly instead of assuming its + shape would have saved two failed builds. +- An explicit list of the four filenames survived into the first macro version out of habit. + Nothing outside this module references those filenames, so it was pure ceremony from the era + when static imports forced it. + +## [S1] Problem + +The four scripts in `src/workflow/builtin/` are workflow **function bodies**, not modules: each +ends in a top-level `return`, because the sandbox evaluates them inside a function wrapper. +They were imported as raw text via `with { type: "text" }` so that they would embed into the +compiled binary, which has no source tree to read at runtime. + +That left them reachable as modules, and under `bun test` one was occasionally loaded through +the ECMAScript parser instead of the text loader, where a top-level `return` is a syntax error: + +``` +# Unhandled error between tests +error: Top-level return cannot be used inside an ECMAScript module + at .../src/workflow/builtin/fact-check.js:1:1 +``` + +No assertion produced it. Bun counts the event once as a failure and once as an error, and one +test never starts, so a test file silently loses coverage. Reproducible with two files, in +either order, each of which passes alone: + +``` +bun test test/cli/tui/plugin-toggle.test.ts test/cli/tui/thread.test.ts +``` + +Continuous integration missed it because `test.yml` shards test files across four processes, so +the two files that collide are usually not in the same one. + +Two measurements pinned it down. An `onResolve` hook showed the importer was always +`builtin.ts` itself — the legitimate text import, with no second importer anywhere — and that +`thread.test.ts` alone re-evaluates `builtin.ts` on the order of a hundred times in one +process, of which a handful took the ESM path. Three explanations were tried and falsified: +leaked test state (both files restore their spies), a static-plus-dynamic import race, and +cache exhaustion under concurrent re-imports; neither of the latter two reproduces standalone. + +This reads as a Bun defect — a static import carrying `with { type: "text" }` should reach the +text loader every time — but no minimal standalone reproduction was isolated, so the trigger +for the re-evaluation remains unexplained. + +## [S2] Design + +`builtin.macro.ts` reads `builtin/*.js` with `fs.readdirSync` / `fs.readFileSync` and returns +`{ file, script }[]`. `builtin.ts` consumes it through `with { type: "macro" }`, so the call is +evaluated at transpile and the sources are inlined as string literals. A file read at build time +is never in the module graph, whatever it is named, which is why this fixes the cause rather +than the symptom — and why the scripts keep their `.js` names, stay inside oxlint's coverage, +and need no changes anywhere else. + +The directory is the registry, as it is for built-in skills in `skill/builtin/bundle.macro.ts`. +Nothing outside this module refers to the filenames; consumers look workflows up by `meta.name`, +which each script declares itself. + +Three consequences follow from that, all accepted because the directory is curated. Losing a +script is no longer a boot failure — it simply stops registering, and callers get the existing +unknown-workflow error — so `builtin.test.ts` asserts the registered set to make a deletion +loud. A stray `.js` dropped there becomes a shipped workflow rather than being inert, and a +malformed meta in it fails app boot. Two scripts declaring the same `meta.name` silently +last-wins, unchanged from before. + +Two Bun constraints shape the call site, both already encoded in the pattern +`skill/builtin/extract.ts` established: + +- Macros are not expanded in every transpile path. Under `bun test` the macro import is stripped + without the call being replaced, surfacing as a `ReferenceError`, so the macro module is also + imported normally and the macro form falls back to it. A `try`/`catch` is warranted here + against the repository's general preference because a non-expanded macro is not otherwise + detectable. +- Macro arguments must be statically known. A per-filename signature would make a misspelled + name a build error, but it cannot pass through the fallback wrapper — the argument stops being + static and the build fails with `Cannot convert identifier to JS`. + +## [S3] Out of Scope + +- Reporting upstream. This removes the repository's exposure, not the loader behaviour. The + reproduction and measurements are recorded above so a report can be assembled without + repeating the work. +- Why `thread.test.ts` re-evaluates `builtin.ts` a hundred times. It is the condition that made + the collision likely and it presumably still holds. +- Rejected: renaming to `.js.fn` or `.txt` (treats the extension as the defect, costs lint + coverage; `.txt` would also collide with `session/prompt/compose.txt`), and making the scripts + valid ESM (the top-level `return` is the sandbox contract that user-authored workflows depend + on). + +## Tasks + +- [x] T1: Read the scripts through a build-time macro instead of importing them — acceptance: the two-file reproduction runs all four tests with no failure or error (covers: S2) +- [x] T2: Add the dev fallback the macro pattern requires — acceptance: `bun test` loads the registry rather than throwing `ReferenceError`, and `bun typecheck` passes with no suppressions (covers: S2) +- [x] T3: Confirm the sources still reach a compiled standalone binary — acceptance: the bundler output shows the macro call site replaced by a literal, and a command that loads the registry runs from the binary in an empty directory (covers: S2) diff --git a/packages/opencode/src/workflow/builtin.macro.ts b/packages/opencode/src/workflow/builtin.macro.ts new file mode 100644 index 000000000..33a31a478 --- /dev/null +++ b/packages/opencode/src/workflow/builtin.macro.ts @@ -0,0 +1,13 @@ +import fs from "fs" +import path from "path" + +// Read at build time so the sources inline into the bundle and never enter the module graph; +// see docs/compose/spec/bun-text-import-esm-collision.md. Sorted for a deterministic bundle. +export function loadBuiltinScripts() { + const dir = path.resolve(import.meta.dir, "builtin") + return fs + .readdirSync(dir) + .filter((file) => file.endsWith(".js")) + .sort() + .map((file) => ({ file, script: fs.readFileSync(path.join(dir, file), "utf8") })) +} diff --git a/packages/opencode/src/workflow/builtin.ts b/packages/opencode/src/workflow/builtin.ts index df656e481..df7ceff19 100644 --- a/packages/opencode/src/workflow/builtin.ts +++ b/packages/opencode/src/workflow/builtin.ts @@ -1,22 +1,9 @@ export * as BuiltinWorkflow from "./builtin" -// `with { type: "text" }` makes Bun inline the .js file's SOURCE as a string -// (not import it as a module) and embeds it into the compiled binary via -// `bun build --compile` (mirrors the `with { type: "file" }` asset pattern in -// script/build.ts) — so the built-in script ships with the binary. The Bun -// runtime and bundler both honour this, but tsgo resolves the .js as a real -// module and flags TS1192 ("no default export"); the suppression is scoped to -// this single import. A `Bun.file(...).text()` fallback is intentionally NOT -// used: it reads the real filesystem at runtime, which does not exist inside a -// compiled standalone binary. -// @ts-expect-error TS1192: import-attribute text loader, resolved by Bun not tsgo -import DEEP_RESEARCH_SCRIPT from "./builtin/deep-research.js" with { type: "text" } -// @ts-expect-error TS1192: import-attribute text loader, resolved by Bun not tsgo -import FACT_CHECK_SCRIPT from "./builtin/fact-check.js" with { type: "text" } -// @ts-expect-error TS1192: import-attribute text loader, resolved by Bun not tsgo -import COMPOSE_SCRIPT from "./builtin/compose.js" with { type: "text" } -// @ts-expect-error TS1192: import-attribute text loader, resolved by Bun not tsgo -import RESEARCH_EXPERIMENT_SCRIPT from "./builtin/research-experiment.js" with { type: "text" } +// A macro, not an import, so these function bodies never enter the module graph and no ESM +// parser can reach them. docs/compose/spec/bun-text-import-esm-collision.md explains why. +import { loadBuiltinScripts } from "./builtin.macro" with { type: "macro" } +import { loadBuiltinScripts as loadBuiltinScriptsDev } from "./builtin.macro" import { parseMeta } from "./meta" export type Entry = { @@ -27,17 +14,19 @@ export type Entry = { script: string } -// Built-in workflow scripts shipped with the binary. Each is parsed ONCE at -// module load (meta is static data, not executed). Add new built-ins here. -// `file` is carried so a malformed meta names the offending script — this throw -// runs at module init, so a broken built-in fails the whole app boot; the path -// tells the user which one. -const SCRIPTS: { file: string; script: string }[] = [ - { file: "deep-research.js", script: DEEP_RESEARCH_SCRIPT }, - { file: "fact-check.js", script: FACT_CHECK_SCRIPT }, - { file: "compose.js", script: COMPOSE_SCRIPT }, - { file: "research-experiment.js", script: RESEARCH_EXPERIMENT_SCRIPT }, -] +// `bun test` strips the macro import without replacing the call, so fall back to the same +// function imported normally — the pattern skill/builtin/extract.ts established. +function safeLoadBuiltinScripts() { + try { + return loadBuiltinScripts() + } catch (e) { + if (e instanceof ReferenceError) return loadBuiltinScriptsDev() + throw e + } +} + +// Parsed ONCE at module load; `file` names the offending script if a meta is malformed. +const SCRIPTS = safeLoadBuiltinScripts() // Null-prototype so the registry is a self-evidently closed set: a lookup like // get("constructor")/get("toString") returns undefined, not an inherited diff --git a/packages/opencode/test/workflow/builtin.test.ts b/packages/opencode/test/workflow/builtin.test.ts index e922cb411..746d8d528 100644 --- a/packages/opencode/test/workflow/builtin.test.ts +++ b/packages/opencode/test/workflow/builtin.test.ts @@ -2,6 +2,16 @@ import { describe, expect, test } from "bun:test" import { BuiltinWorkflow } from "../../src/workflow/builtin" describe("BuiltinWorkflow registry", () => { + test("registers every script in the built-in directory", () => { + // The directory is the registry, so losing a script is otherwise silent. + expect(BuiltinWorkflow.list().map((w) => w.name)).toEqual([ + "compose", + "deep-research", + "fact-check", + "research-experiment", + ]) + }) + test("lists deep-research with parsed meta", () => { const list = BuiltinWorkflow.list() const dr = list.find((w) => w.name === "deep-research") From 6674db7a34053fe0ffc4813856ad43d8d91a1209 Mon Sep 17 00:00:00 2001 From: peipeiwang-xiaomi Date: Tue, 4 Aug 2026 15:03:09 +0800 Subject: [PATCH 102/135] fix(session): persist missing-model assistant errors (#2024) --- packages/opencode/src/session/prompt.ts | 31 +++++++++++-- packages/opencode/test/session/prompt.test.ts | 44 ++++++++++++++++++- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 7badf293b..7c453d21d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1956,17 +1956,40 @@ NOTE: At any point in time through this workflow you should feel free to ask the providerID: ProviderID, modelID: ModelID, sessionID: SessionID, + terminalUser?: MessageV2.User, ) { const exit = yield* provider.getModel(providerID, modelID).pipe(Effect.exit) if (Exit.isSuccess(exit)) return exit.value const err = Cause.squash(exit.cause) if (Provider.ModelNotFoundError.isInstance(err)) { const hint = err.data.suggestions?.length ? ` Did you mean: ${err.data.suggestions.join(", ")}?` : "" + const error = new NamedError.Unknown({ + message: `Model not found: ${err.data.providerID}/${err.data.modelID}.${hint}`, + }).toObject() + if (terminalUser) { + const ctx = yield* InstanceState.context + const now = Date.now() + yield* sessions.updateMessage({ + id: MessageID.ascending(), + sessionID, + parentID: terminalUser.id, + agentID: terminalUser.agentID, + role: "assistant", + mode: terminalUser.agent, + agent: terminalUser.agent, + variant: terminalUser.model.variant, + path: { cwd: ctx.directory, root: ctx.worktree }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID, + providerID, + time: { created: now, completed: now }, + error, + }) + } yield* bus.publish(Session.Event.Error, { sessionID, - error: new NamedError.Unknown({ - message: `Model not found: ${err.data.providerID}/${err.data.modelID}.${hint}`, - }).toObject(), + error, }) } return yield* Effect.failCause(exit.cause) @@ -3296,7 +3319,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the } } - const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID) + const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID, lastUser) lastModelForPrune = model lastFinishedForPrune = lastFinished const task = tasks.pop() diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 0931d581f..d836a24c8 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -2,7 +2,7 @@ import path from "path" import { describe, expect, test } from "bun:test" import { NamedError } from "@mimo-ai/shared/util/error" import { fileURLToPath } from "url" -import { Effect, Layer } from "effect" +import { Effect, Exit, Layer } from "effect" import { Instance } from "../../src/project/instance" import { ModelID, ProviderID } from "../../src/provider/schema" import { Session } from "../../src/session" @@ -129,6 +129,48 @@ function hanging(ready: () => void) { }) } +describe("session.prompt terminal model errors", () => { + test("persists an assistant error before a missing model fails the prompt", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: () => + run( + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Missing model" }) + const providerID = ProviderID.make("missing-provider") + const modelID = ModelID.make("missing-model") + + const exit = yield* prompt + .prompt({ + sessionID: session.id, + agent: "build", + model: { providerID, modelID }, + parts: [{ type: "text", text: "hello" }], + }) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + const messages = yield* sessions.messages({ sessionID: session.id, agentID: "*" }) + expect(messages).toHaveLength(2) + expect(messages[0]?.info.role).toBe("user") + const assistant = messages[1]?.info + expect(assistant?.role).toBe("assistant") + if (assistant?.role !== "assistant") return + expect(assistant.parentID).toBe(messages[0]?.info.id) + expect(assistant.providerID).toBe(providerID) + expect(assistant.modelID).toBe(modelID) + expect(assistant.error?.data.message).toContain("Model not found: missing-provider/missing-model") + expect(assistant.time.completed).toBeNumber() + }), + ), + }) + }) +}) + describe("session.prompt missing file", () => { test("does not fail the prompt when a file part is missing", async () => { await using tmp = await tmpdir({ From 7230f8a2727d449975b4f3fa129c44b885e0d1eb Mon Sep 17 00:00:00 2001 From: Cheng Liangyu Date: Tue, 4 Aug 2026 20:25:33 +0800 Subject: [PATCH 103/135] fix(provider): send function tools to OpenAI Responses with explicit strict: false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI's Responses API treats a function tool that OMITS `strict` as strict, and `@ai-sdk/openai` only emits the field when the tool sets it (`...tool.strict != null ? { strict: tool.strict } : {}`), so we were opting into constrained decoding by accident. Our tool schemas are deliberately not strict-compatible: optional parameters stay out of `required`, not every object carries `additionalProperties: false`, and discriminated unions keep an `anyOf`. Rather than reject the request, the Codex backend auto-patches such a schema — on the wire all 17 tools came back tagged `strict: true`, `bash.required` grew from 2 entries to 5, and `task.parameters.properties.operation` gained `additionalProperties: false` — and then fails to compile the resulting decoding grammar. Because that failure happens at GENERATION time the HTTP 200 is already committed, so the error can only arrive mid-stream as `event: error` (`server_error`) + `response.failed`, i.e. the answer stops half-written. Sending `strict: true` explicitly with the same schema gets a clean 502 instead, which is why this read as random upstream flakiness rather than a deterministic schema problem. State the intent explicitly instead. Scoped to the SDKs that reach an OpenAI Responses endpoint AND forward `tool.strict`: `@ai-sdk/openai` and `@ai-sdk/azure` (which builds `OpenAIResponsesLanguageModel` from `@ai-sdk/openai/internal`). The vendored Copilot Responses SDK already always emits `strict`, and other SDKs are left alone on purpose — `@ai-sdk/anthropic` warns for any non-null `strict`. An explicit per-tool `strict` is preserved. No schema changes: `anyOf` unions and optional parameters stay as they are. --- packages/opencode/src/provider/transform.ts | 38 ++++ .../test/provider/tool-strict-wire.test.ts | 203 ++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 packages/opencode/test/provider/tool-strict-wire.test.ts diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 48f8e5a00..62cf29a4a 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1009,6 +1009,38 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re return msgs } +// OpenAI's Responses API treats a function tool that OMITS `strict` as strict, +// and `@ai-sdk/openai` only emits the field when the tool sets it +// (`...tool.strict != null ? { strict: tool.strict } : {}`) — so by default we +// were opting into constrained decoding by accident. +// +// Our tool schemas are deliberately NOT strict-compatible: optional parameters +// stay out of `required`, not every object carries `additionalProperties: false`, +// and discriminated unions keep an `anyOf`. Rather than reject the request, the +// Codex backend auto-patches such a schema (observed on the wire: all 17 tools +// came back tagged `strict: true`, `bash.required` grew from 2 entries to 5, and +// `task.parameters.properties.operation` gained `additionalProperties: false`) +// and then fails to compile the resulting decoding grammar. Because the failure +// happens at GENERATION time, the 200 is already committed and the error can +// only arrive mid-stream as `event: error` (`server_error`) + `response.failed` — +// i.e. "the answer stops half-written". Sending `strict: true` explicitly with +// the same schema gets a clean 502 instead, which is why this read as random +// upstream flakiness rather than a deterministic schema problem. +// +// So state the intent explicitly. Scoped to the SDKs that reach an OpenAI +// Responses endpoint AND forward `tool.strict`: +// - `@ai-sdk/openai` +// - `@ai-sdk/azure`, which builds `OpenAIResponsesLanguageModel` from +// `@ai-sdk/openai/internal` +// The vendored Copilot Responses SDK (`provider/sdk/copilot/responses`) already +// always emits `strict`, and every other SDK is left alone on purpose: +// `@ai-sdk/anthropic` warns ("strict mode is not supported by this provider") +// for any non-null `strict`, so a blanket default would spam warnings there. +// +// An explicit per-tool `strict` is preserved, so a tool that has been made +// strict-compatible can still opt in. +const EXPLICIT_NON_STRICT_TOOL_SDKS = ["@ai-sdk/openai", "@ai-sdk/azure"] + // Place a cache breakpoint on the tool definitions. The cache hierarchy is // `tools` → `system` → `messages`, so marking the LAST tool caches the entire // tool-schema block (often several KB) as a stable prefix that sits in front of @@ -1017,6 +1049,12 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re // the SDK-keyed marker via `cacheMarkerFor`. Tool registration order is stable // (insertion order of the tools record), so "last tool" is deterministic. export function tools>(tools: T, model: Provider.Model): T { + if (EXPLICIT_NON_STRICT_TOOL_SDKS.includes(model.api.npm)) { + for (const tool of Object.values(tools)) { + if (tool && tool.strict == null) tool.strict = false + } + } + if (!supportsCacheMarkers(model)) return tools const marker = cacheMarkerFor(model) if (!marker) return tools diff --git a/packages/opencode/test/provider/tool-strict-wire.test.ts b/packages/opencode/test/provider/tool-strict-wire.test.ts new file mode 100644 index 000000000..bb1f2c598 --- /dev/null +++ b/packages/opencode/test/provider/tool-strict-wire.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, test } from "bun:test" +import { createAnthropic } from "@ai-sdk/anthropic" +import { createOpenAI } from "@ai-sdk/openai" +import { generateText, jsonSchema, tool } from "ai" +import { ProviderTransform } from "../../src/provider" + +// WIRE-LEVEL proof that function tools ship with an explicit `strict: false` to +// the OpenAI Responses API. +// +// The Responses API treats a function tool that OMITS `strict` as strict, and +// `@ai-sdk/openai` only emits the field when the tool sets it +// (`...tool.strict != null ? { strict: tool.strict } : {}`). Our schemas are not +// strict-compatible — optional parameters stay out of `required` and objects do +// not all carry `additionalProperties: false` — so the Codex backend auto-patched +// them, failed to compile the decoding grammar, and returned `server_error` +// MID-STREAM after the 200 was already committed (the answer stopped +// half-written). Explicit `strict: false` is the fix. +// +// Asserting on `ProviderTransform.tools`' return value alone is two layers short +// of the wire: `ai`'s `prepareToolsAndToolChoice` has the same +// `tool.strict != null` guard, so a field that fails to survive it never reaches +// the provider. These tests capture the real outbound HTTP body instead. + +function model(npm: string, overrides: Partial = {}) { + return { + id: `test/${npm}`, + providerID: "test", + api: { id: "gpt-5.1-codex", url: "https://api.openai.com/v1", npm }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.001, output: 0.002, cache: { read: 0.0001, write: 0.0002 } }, + limit: { context: 200_000, output: 64_000 }, + status: "active", + options: {}, + headers: {}, + release_date: "2025-01-01", + ...overrides, + } as any +} + +// `ProviderTransform.tools` mutates the record in place, so every test needs a +// fresh set. Mirrors the real shape that broke: `timeout` is optional (absent +// from `required`) and no object declares `additionalProperties: false`. +const toolset = () => ({ + bash: tool({ + description: "Run a shell command", + inputSchema: jsonSchema({ + type: "object", + properties: { + command: { type: "string", description: "The command to run" }, + timeout: { type: "number", description: "Timeout in ms" }, + }, + required: ["command"], + }), + execute: async () => "ok", + }), + read: tool({ + description: "Read a file", + inputSchema: jsonSchema({ + type: "object", + properties: { path: { type: "string" }, limit: { type: "number" } }, + required: ["path"], + }), + execute: async () => "ok", + }), +}) + +const responsesReply = { + id: "resp_1", + object: "response", + created_at: 1_755_000_000, + status: "completed", + model: "gpt-5.1-codex", + output: [ + { + id: "msg_1", + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "ok", annotations: [] }], + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + incomplete_details: null, +} + +const anthropicReply = { + id: "msg_1", + type: "message", + role: "assistant", + model: "claude-sonnet-4", + content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, +} + +// Runs the tool set through the real `ai` core + real provider and returns the +// parsed outbound HTTP body. +async function outbound(tools: Record, reply: unknown, build: (fetch: any) => any) { + let captured: any + const languageModel = build((async (_url: any, init: any) => { + captured = JSON.parse(init.body as string) + return new Response(JSON.stringify(reply), { headers: { "content-type": "application/json" } }) + }) as any) + // The reply is a text answer, so the tool loop never runs; a validation + // mismatch on the stub is irrelevant because the body is already captured. + await generateText({ model: languageModel, prompt: "hi", tools }).catch(() => {}) + return captured +} + +const openaiResponses = (tools: Record) => + outbound(tools, responsesReply, (fetch) => + createOpenAI({ apiKey: "test-key", fetch }).responses("gpt-5.1-codex"), + ) + +const anthropicMessages = (tools: Record) => + outbound(tools, anthropicReply, (fetch) => createAnthropic({ apiKey: "test-key", fetch })("claude-sonnet-4")) + +describe("function tools reach the OpenAI Responses API with an explicit strict: false", () => { + test("CONTROL: untransformed tools omit `strict` entirely (the defect)", async () => { + const body = await openaiResponses(toolset()) + expect(body.tools).toHaveLength(2) + // Proof of the mechanism, and proof this test would catch a regression: + // omitting the field is what made the backend treat these as strict. + for (const entry of body.tools) expect(entry).not.toHaveProperty("strict") + }) + + test("every tool ships `strict: false` after ProviderTransform.tools", async () => { + const body = await openaiResponses(ProviderTransform.tools(toolset(), model("@ai-sdk/openai"))) + expect(body.tools.map((entry: any) => [entry.name, entry.strict])).toEqual([ + ["bash", false], + ["read", false], + ]) + }) + + test("the schemas themselves are untouched — only `strict` is added", async () => { + const before = await openaiResponses(toolset()) + const after = await openaiResponses(ProviderTransform.tools(toolset(), model("@ai-sdk/openai"))) + expect(after.tools.map((entry: any) => entry.parameters)).toEqual( + before.tools.map((entry: any) => entry.parameters), + ) + // The shape that the backend auto-patched survives verbatim: `timeout` + // stays optional and no `additionalProperties: false` is invented. + expect(after.tools[0].parameters.required).toEqual(["command"]) + expect(after.tools[0].parameters).not.toHaveProperty("additionalProperties") + }) + + test("@ai-sdk/azure gets the same treatment — it builds OpenAI's responses model", () => { + const tools = ProviderTransform.tools(toolset(), model("@ai-sdk/azure")) + expect(Object.values(tools).map((entry: any) => entry.strict)).toEqual([false, false]) + }) + + test("an explicit per-tool `strict` is preserved, not overwritten", async () => { + const tools = toolset() + ;(tools.bash as any).strict = true + const body = await openaiResponses(ProviderTransform.tools(tools, model("@ai-sdk/openai"))) + expect(body.tools.map((entry: any) => [entry.name, entry.strict])).toEqual([ + ["bash", true], + ["read", false], + ]) + }) +}) + +describe("non-OpenAI SDKs are left alone", () => { + // @ai-sdk/anthropic emits an "unsupported feature" warning for ANY non-null + // `strict`, so defaulting it there would spam warnings on every request. + test("anthropic tools keep `strict` unset and nothing reaches the wire", async () => { + const tools = ProviderTransform.tools(toolset(), model("@ai-sdk/anthropic")) + for (const entry of Object.values(tools)) expect(entry).not.toHaveProperty("strict") + + const body = await anthropicMessages(tools) + expect(body.tools).toHaveLength(2) + for (const entry of body.tools) expect(entry).not.toHaveProperty("strict") + }) + + test("anthropic requests emit no strict-mode warning", async () => { + const warnings = await generateText({ + model: createAnthropic({ + apiKey: "test-key", + fetch: (async () => + new Response(JSON.stringify(anthropicReply), { + headers: { "content-type": "application/json" }, + })) as any, + })("claude-sonnet-4"), + prompt: "hi", + tools: ProviderTransform.tools(toolset(), model("@ai-sdk/anthropic")), + }).then((result) => result.warnings) + expect(warnings?.filter((warning: any) => warning.feature === "strict")).toEqual([]) + }) + + test("openai-compatible proxies are untouched", () => { + const tools = ProviderTransform.tools(toolset(), model("@ai-sdk/openai-compatible")) + for (const entry of Object.values(tools)) expect(entry).not.toHaveProperty("strict") + }) +}) From 63e7f49c8069bb3cd59652328c0f5013b50f7967 Mon Sep 17 00:00:00 2001 From: Cheng Liangyu Date: Tue, 4 Aug 2026 20:50:22 +0800 Subject: [PATCH 104/135] fix(session): declare strict: false for the goal judge's structured output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `response_format` sibling of the tool `strict` bug. `generateObject`/ `streamObject` ship our zod schema as a `json_schema` response format, and there the OpenAI SDKs default `strictJsonSchema` to TRUE — `@ai-sdk/openai` on both the chat and responses paths, and `@ai-sdk/openai-compatible` — so `strict: true` goes out EXPLICITLY rather than being omitted. `SessionGoal.Verdict` marks `impossible` optional, so `required` ships 2 of its 3 properties. Verified on the wire: `text.format` goes out as `strict: true` with `required: ["ok", "reason"]`, which strict mode rejects (every key in `properties` must appear in `required`). `goal.ts` judges with the SESSION's model, so the stop-condition judge was broken on every OpenAI-backed model. Unlike the tool case this fails cleanly at validation instead of mid-stream, so it is a separate, visible bug — but the root cause is the same: a strict default meeting a deliberately non-strict schema. Making the schema strict-compatible would be the wrong trade: `impossible` is optional by design (JUDGE_SYSTEM tells the judge to return `{"ok": false}` WITHOUT `impossible` when in doubt), so forcing it into `required` would change what the judge is asked to produce. `.default(false)` does not help either — it still lands outside `required`. So state `strict: false`, as for tools. The agent-config schema in agent/agent.ts is deliberately NOT opted out: it is strict-compatible (all fields required, `additionalProperties: false`), so it keeps constrained decoding. A test pins that property so adding an optional field there fails loudly instead of silently reintroducing this bug. Also folds in review feedback on the tool fix: document that `tools()` now has two responsibilities, and why mutating in place is safe (the record and every tool object in it are rebuilt per request, so a mid-turn model switch cannot carry `strict` to a provider that would reject or warn on it). --- packages/opencode/src/provider/transform.ts | 73 ++++++++++++-- packages/opencode/src/session/goal.ts | 7 ++ ...ire.test.ts => strict-schema-wire.test.ts} | 97 ++++++++++++++++++- 3 files changed, 166 insertions(+), 11 deletions(-) rename packages/opencode/test/provider/{tool-strict-wire.test.ts => strict-schema-wire.test.ts} (64%) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 62cf29a4a..c622246e4 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1039,17 +1039,37 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re // // An explicit per-tool `strict` is preserved, so a tool that has been made // strict-compatible can still opt in. +// +// Azure's `useCompletionUrls` branch sends the same tools to Chat Completions +// instead, where the field lands as `function.strict: false` — a documented +// boolean whose default is already false, so that path is unaffected. const EXPLICIT_NON_STRICT_TOOL_SDKS = ["@ai-sdk/openai", "@ai-sdk/azure"] -// Place a cache breakpoint on the tool definitions. The cache hierarchy is -// `tools` → `system` → `messages`, so marking the LAST tool caches the entire -// tool-schema block (often several KB) as a stable prefix that sits in front of -// the system + message caches. Tools are passed to the SDK separately from -// `message()` and never go through its providerID→SDK-key remap, so we resolve -// the SDK-keyed marker via `cacheMarkerFor`. Tool registration order is stable -// (insertion order of the tools record), so "last tool" is deterministic. +// The single choke point for the outbound tool set (session/llm.ts passes the +// result straight to `streamText`). Two responsibilities: +// +// 1. Pin `strict: false` for EXPLICIT_NON_STRICT_TOOL_SDKS — see above for why +// omitting the field breaks the Codex backend mid-stream. +// 2. Place a cache breakpoint on the tool definitions. The cache hierarchy is +// `tools` → `system` → `messages`, so marking the LAST tool caches the entire +// tool-schema block (often several KB) as a stable prefix that sits in front +// of the system + message caches. Tools are passed to the SDK separately from +// `message()` and never go through its providerID→SDK-key remap, so we +// resolve the SDK-keyed marker via `cacheMarkerFor`. Tool registration order +// is stable (insertion order of the tools record), so "last tool" is +// deterministic. +// +// Both mutate in place. That is safe because the record and every tool object in +// it are rebuilt per request: `resolveTools` allocates a fresh record and calls +// `tool()` per entry, and MCP entries come from `convertMcpTool`, which returns +// a new `dynamicTool()` on every `MCP.tools()` call. Nothing here outlives the +// request, so a model switch between steps cannot carry `strict` over to a +// provider that would reject or warn on it. export function tools>(tools: T, model: Provider.Model): T { if (EXPLICIT_NON_STRICT_TOOL_SDKS.includes(model.api.npm)) { + // Guarded because this walks every entry; the single `last` lookup below can + // assume a well-formed record, but a loop over N values is cheaper to make + // safe than to debug as a crash in the request path. for (const tool of Object.values(tools)) { if (tool && tool.strict == null) tool.strict = false } @@ -1066,6 +1086,45 @@ export function tools>(tools: T, model: Provider.M return tools } +// The `response_format` / `text.format` sibling of the tool `strict` problem +// above. `generateObject`/`streamObject` ship our zod schema as a `json_schema` +// response format, and there the OpenAI SDKs default `strictJsonSchema` to TRUE +// — `@ai-sdk/openai` on both the chat and responses paths, and +// `@ai-sdk/openai-compatible` — so `strict: true` goes out EXPLICITLY rather +// than being omitted. +// +// Our judge schema is not strict-compatible: `SessionGoal.Verdict` marks +// `impossible` optional, so `required` ships 2 of its 3 properties and OpenAI +// rejects the request (strict mode requires every key in `properties` to appear +// in `required`). Verified on the wire: `text.format` goes out as `strict: true` +// with `required: ["ok", "reason"]`. Because `goal.ts` judges with the SESSION's +// model, this breaks the stop-condition judge on every OpenAI-backed model. +// +// Unlike the tool case this fails cleanly at validation instead of mid-stream, so +// it is a separate, visible bug — but the root cause is the same: a strict +// default meeting a deliberately non-strict schema. +// +// Making the schema strict-compatible is the wrong trade here. `impossible` is +// optional BY DESIGN — JUDGE_SYSTEM tells the judge to return `{"ok": false}` +// WITHOUT `impossible` when in doubt — so forcing it into `required` would change +// what the judge is asked to produce. State `strict: false` instead, exactly as +// for tools. +// +// Scoped to the SDKs that read `strictJsonSchema` AND default it to true. +// `@ai-sdk/openai-compatible` is included: it looks up provider options under the +// name it was constructed with, which provider.ts sets to `model.providerID` — +// the same key `providerOptions()` falls back to when `sdkKey()` has no mapping. +// +// Schemas that ARE strict-compatible (e.g. the agent-config schema in +// agent/agent.ts) are deliberately left alone so they keep constrained decoding. +const DEFAULT_STRICT_SCHEMA_SDKS = ["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/openai-compatible"] + +// Feed through `providerOptions()` before handing to generateObject/streamObject. +export function structuredOutputOptions(model: Provider.Model) { + if (!DEFAULT_STRICT_SCHEMA_SDKS.includes(model.api.npm)) return {} + return { strictJsonSchema: false } +} + export function temperature(model: Provider.Model) { const id = model.id.toLowerCase() if (id.includes("qwen")) return 0.55 diff --git a/packages/opencode/src/session/goal.ts b/packages/opencode/src/session/goal.ts index 4bba0e3de..edec04004 100644 --- a/packages/opencode/src/session/goal.ts +++ b/packages/opencode/src/session/goal.ts @@ -205,6 +205,12 @@ export const layer = Layer.effect( ], model: language, schema: Verdict, + // `Verdict.impossible` is optional by design, which strict mode rejects. + // See ProviderTransform.structuredOutputOptions for the full reasoning. + providerOptions: ProviderTransform.providerOptions( + resolved, + ProviderTransform.structuredOutputOptions(resolved), + ), } satisfies Parameters[0] if (isOpenaiOauth) { @@ -214,6 +220,7 @@ export const layer = Layer.effect( providerOptions: ProviderTransform.providerOptions(resolved, { instructions: JUDGE_SYSTEM, store: false, + ...ProviderTransform.structuredOutputOptions(resolved), }), onError: () => {}, }) diff --git a/packages/opencode/test/provider/tool-strict-wire.test.ts b/packages/opencode/test/provider/strict-schema-wire.test.ts similarity index 64% rename from packages/opencode/test/provider/tool-strict-wire.test.ts rename to packages/opencode/test/provider/strict-schema-wire.test.ts index bb1f2c598..1c6080f94 100644 --- a/packages/opencode/test/provider/tool-strict-wire.test.ts +++ b/packages/opencode/test/provider/strict-schema-wire.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test" import { createAnthropic } from "@ai-sdk/anthropic" import { createOpenAI } from "@ai-sdk/openai" -import { generateText, jsonSchema, tool } from "ai" +import { dynamicTool, generateObject, generateText, jsonSchema, tool } from "ai" +import z from "zod" import { ProviderTransform } from "../../src/provider" // WIRE-LEVEL proof that function tools ship with an explicit `strict: false` to @@ -117,9 +118,7 @@ async function outbound(tools: Record, reply: unknown, build: (fetc } const openaiResponses = (tools: Record) => - outbound(tools, responsesReply, (fetch) => - createOpenAI({ apiKey: "test-key", fetch }).responses("gpt-5.1-codex"), - ) + outbound(tools, responsesReply, (fetch) => createOpenAI({ apiKey: "test-key", fetch }).responses("gpt-5.1-codex")) const anthropicMessages = (tools: Record) => outbound(tools, anthropicReply, (fetch) => createAnthropic({ apiKey: "test-key", fetch })("claude-sonnet-4")) @@ -167,6 +166,28 @@ describe("function tools reach the OpenAI Responses API with an explicit strict: ["read", false], ]) }) + + // MCP tools are built by `convertMcpTool` as `dynamicTool()`, i.e. + // `type: "dynamic"` rather than a plain function tool. `ai`'s + // `prepareToolsAndToolChoice` funnels `dynamic` through the same + // `case "function"` branch, so `strict` must survive for them too — MCP tool + // schemas are server-supplied and the least likely to be strict-compatible. + test("dynamic (MCP) tools also ship `strict: false`", async () => { + const tools = { + mcp_server_query: dynamicTool({ + description: "Query a server", + inputSchema: jsonSchema({ + type: "object", + properties: { q: { type: "string" }, page: { type: "number" } }, + required: ["q"], + additionalProperties: false, + }), + execute: async () => "ok", + }), + } + const body = await openaiResponses(ProviderTransform.tools(tools, model("@ai-sdk/openai"))) + expect(body.tools.map((entry: any) => [entry.name, entry.strict])).toEqual([["mcp_server_query", false]]) + }) }) describe("non-OpenAI SDKs are left alone", () => { @@ -201,3 +222,71 @@ describe("non-OpenAI SDKs are left alone", () => { for (const entry of Object.values(tools)) expect(entry).not.toHaveProperty("strict") }) }) + +// The `response_format` sibling of the above. Here the SDKs default +// `strictJsonSchema` to TRUE, so `strict: true` goes out EXPLICITLY — the +// opposite direction from the tool case, and it fails cleanly at validation +// rather than mid-stream. +describe("structured output declares strict: false for non-strict-compatible schemas", () => { + // `SessionGoal.Verdict`. `impossible` is optional BY DESIGN — JUDGE_SYSTEM + // tells the judge to omit it when in doubt. + const Verdict = z.object({ ok: z.boolean(), impossible: z.boolean().optional(), reason: z.string() }) + + async function format(schema: any, options: Record) { + let captured: any + const openai = createOpenAI({ + apiKey: "test-key", + fetch: (async (_url: any, init: any) => { + captured = JSON.parse(init.body as string) + return new Response("{}", { headers: { "content-type": "application/json" } }) + }) as any, + }) + await generateObject({ + model: openai.responses("gpt-5.1-codex"), + prompt: "hi", + schema, + providerOptions: options as any, + }).catch(() => {}) + return captured?.text?.format + } + + test("CONTROL: Verdict would ship strict: true with an incomplete `required` (the defect)", async () => { + const sent = await format(Verdict, {}) + expect(sent.strict).toBe(true) + // 3 properties, 2 required — exactly what OpenAI's strict mode rejects. + expect(Object.keys(sent.schema.properties)).toEqual(["ok", "impossible", "reason"]) + expect(sent.schema.required).toEqual(["ok", "reason"]) + }) + + test("structuredOutputOptions turns strict off for the openai SDK", async () => { + const sent = await format( + Verdict, + ProviderTransform.providerOptions(model("@ai-sdk/openai"), { + ...ProviderTransform.structuredOutputOptions(model("@ai-sdk/openai")), + }), + ) + expect(sent.strict).toBe(false) + // The schema is untouched — `impossible` stays optional. + expect(sent.schema.required).toEqual(["ok", "reason"]) + }) + + test("azure and openai-compatible are covered; anthropic is not", () => { + expect(ProviderTransform.structuredOutputOptions(model("@ai-sdk/openai"))).toEqual({ strictJsonSchema: false }) + expect(ProviderTransform.structuredOutputOptions(model("@ai-sdk/azure"))).toEqual({ strictJsonSchema: false }) + expect(ProviderTransform.structuredOutputOptions(model("@ai-sdk/openai-compatible"))).toEqual({ + strictJsonSchema: false, + }) + expect(ProviderTransform.structuredOutputOptions(model("@ai-sdk/anthropic"))).toEqual({}) + }) + + // agent.ts's schema is deliberately NOT opted out: it is strict-compatible, so + // it still gets constrained decoding. This pins that property — adding an + // optional field there would silently reintroduce the Verdict bug, and this + // test is the tripwire that points at structuredOutputOptions. + test("the agent-config schema stays strict-compatible, so strict decoding is kept", async () => { + const sent = await format(z.object({ identifier: z.string(), whenToUse: z.string(), systemPrompt: z.string() }), {}) + expect(sent.strict).toBe(true) + expect(sent.schema.required).toEqual(Object.keys(sent.schema.properties)) + expect(sent.schema.additionalProperties).toBe(false) + }) +}) From fe9e9ab6dc81f910071263edea2aa576f89b168a Mon Sep 17 00:00:00 2001 From: Yihan Yan Date: Tue, 4 Aug 2026 21:01:14 +0800 Subject: [PATCH 105/135] feat(skill): separate model reachability from authorization (#2026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(skill): separate model reachability from authorization Hiding a skill from the model and forbidding its use were the same permission rule, so `compose-next` — hidden by an exact `deny` — could not be loaded by the user's own `/compose-next` either: the mention scan that injects skill bodies resolves against `Skill.available()`, and the skill tool hard-refused it. There was no way to express "invisible to the model, still usable by the user". Split the axes. `permission.skill` now means authorization only: `deny` is unusable by anyone. A new `disable-model-invocation` frontmatter field (kebab-case, matching Claude Code and agentskills.io) carries model reachability: the skill is absent from the system-prompt catalog, the skill tool description, and skill_search, and the skill tool refuses it with a pointer to the user's slash command — while `/name` works untouched. The new `Skill.modelInvocable()` feeds every model-facing surface; `available()` and `all()` stay as the user-facing sets. The dead `Skill.Info.hidden` field, unread since PR #1725, is removed. compose-next graduates onto the new field: its `deny` rule is gone, its SKILL.md sets `disable-model-invocation: true`, and both its description and body now state that the workflow starts only on explicit user invocation. * fix(skill): drop the SDK codegen scratch file, source the not-found hint from modelInvocable Review follow-ups. `packages/sdk/js/openapi.json` is a temp file that script/build.ts writes and then removes; a failed generation left it behind and it was committed by accident. Remove it and gitignore it along with the generator's crash log. The skill tool's not-found hint enumerated `all()` with its own inline copy of the reachability predicate, which still leaked permission-denied names such as `compose:*` and contradicted the invariant that nothing model-facing reads `all()`. Source it from `modelInvocable(agent)` instead, matching the set the tool description already advertises. * docs(compose): finalize the skill-invocation-control spec Records the delivered behavior, the verification commands and their results, the journey log, and the three gaps review surfaced but this change leaves open. Documentation-only, outside the reviewed range by construction. --- .gitignore | 7 +- docs/compose/spec/compose-next.md | 20 ++ docs/compose/spec/skill-invocation-control.md | 313 ++++++++++++++++++ packages/opencode/src/agent/agent.ts | 1 - packages/opencode/src/session/system.ts | 5 +- .../builtin/.bundle/compose-next/SKILL.md | 5 +- .../builtin/.bundle/mimocode-docs/SKILL.md | 2 +- .../builtin/.bundle/skill-creator/SKILL.md | 2 + .../skill-creator/references/frontmatter.md | 14 + packages/opencode/src/skill/index.ts | 28 +- packages/opencode/src/skill/search.ts | 2 +- packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/src/tool/skill-search.ts | 2 +- packages/opencode/src/tool/skill.ts | 20 +- .../permission/compose-next-discovery.test.ts | 32 +- .../prompt-skill-command-multi.test.ts | 50 ++- packages/opencode/test/skill/search.test.ts | 23 +- packages/opencode/test/skill/skill.test.ts | 45 +++ .../opencode/test/tool/skill-search.test.ts | 25 +- packages/opencode/test/tool/skill.test.ts | 74 +++++ packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- 21 files changed, 628 insertions(+), 46 deletions(-) create mode 100644 docs/compose/spec/skill-invocation-control.md diff --git a/.gitignore b/.gitignore index 2a1a4dfa1..da8a5559d 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,9 @@ Thumbs.db .playwright-cli/ .mimocode/wiki .mimocode/wikis -.mimocode/cache \ No newline at end of file +.mimocode/cache + +# SDK codegen scratch: script/build.ts writes it, consumes it, then removes it. +# A failed generation leaves it behind, where it is easy to commit by accident. +/packages/sdk/js/openapi.json +/packages/sdk/js/openapi-ts-error-*.log \ No newline at end of file diff --git a/docs/compose/spec/compose-next.md b/docs/compose/spec/compose-next.md index ea4191e77..6d4d2f0c3 100644 --- a/docs/compose/spec/compose-next.md +++ b/docs/compose/spec/compose-next.md @@ -9,6 +9,26 @@ predecessor: compose-slim (draft PR #1850) # Compose Next +## Superseded in part (2026-07-31) + +The invisibility mechanism described below was replaced by +`docs/compose/spec/skill-invocation-control.md`, which is the current contract. +Three statements in this document no longer hold: + +1. The exact `"compose-next": "deny"` default-agent skill permission is gone. + Permission now means authorization only — a `deny` makes a skill unusable by + the user too — so keeping it would have broken the user's own + `/compose-next`. Model invisibility moved to `disable-model-invocation: true` + in the skill's own frontmatter. +2. S2's "`SkillTool.execute()` stays permissive… if a model guesses the exact + name it may invoke it" is reversed: the skill tool now refuses a + `disable-model-invocation` skill and redirects to the user's slash command. +3. `skill/search.ts` no longer special-cases the name `compose-next`; its + exclusion from `skill_search` is carried by the field. + +The rest — the skill's content, its presence in `Skill.all()` for slash +autocomplete, the deprecation touchpoints, and the i18n keys — is unchanged. + ## Report **What was built** - One self-contained builtin skill `compose-next` (grill → spec → workspace → implement → verify → review → finalize → finish), invoked from Build as `/compose-next`. Hidden from model auto-discovery via an exact `"compose-next": "deny"` default-agent skill permission plus `skill_search` sourcing from `Skill.available(agent)`; still present in `Skill.all()` so slash autocomplete works. Legacy Compose is untouched functionally and marked deprecated through three additive touchpoints: agent description line, `Compose (legacy)` input-bar label, and a compose-only home-tip display override. Side fix: tips now render for first-time users (first-session gate removed). diff --git a/docs/compose/spec/skill-invocation-control.md b/docs/compose/spec/skill-invocation-control.md new file mode 100644 index 000000000..6e1208161 --- /dev/null +++ b/docs/compose/spec/skill-invocation-control.md @@ -0,0 +1,313 @@ +--- +feature: skill-invocation-control +status: delivered +updated: 2026-07-31 +branch: feat/skill-invocation-control +commits: 6674db7a..6236515e +--- + +# Skill Invocation Control + +## Report + +**What was built** — Model reachability and authorization are now separate +axes. `permission.skill` means authorization only: a `deny` makes a skill +unusable by anyone, the user included. A new optional `disable-model-invocation` +boolean in SKILL.md frontmatter carries reachability: the skill is absent from +the system-prompt catalog, from the `skill` tool description, and from +`skill_search`, and the `skill` tool refuses to load it with an error that +points at the user's slash command instead of dead-ending. `/name` typed by the +user is untouched. The field name is kebab-case to match Claude Code and the +agentskills.io standard; internally it is `Info.disable_model_invocation`. + +Mechanically this is one new registry accessor, `Skill.modelInvocable(agent?)` += `available(agent)` minus the flag, feeding the three model-facing call sites, +while `available()` and `all()` stay as the user-facing sets. The dead +`Skill.Info.hidden` field, parsed but never read since PR #1725, is gone. +`compose-next` graduated onto the new field: its exact `deny` rule is deleted, +its SKILL.md sets the flag, and both its description and body now state that +the workflow starts only on explicit user invocation — belt and braces, so it +still behaves if the flag is ever removed. `skill-creator` and its frontmatter +reference document the field for skill authors; `mimocode-docs` records that +`/compose-next` is user-only, which is the channel through which a model learns +the skill exists at all. + +**Verification** — all from `packages/opencode` unless noted: + +- `bun typecheck` (packages/opencode) — PASS. `bun typecheck` (packages/sdk/js) — PASS. +- `bun test test/tool test/skill test/permission test/session/prompt-skill-command-multi.test.ts` + — 1123 pass, 11 skip, 0 fail (after the review follow-ups). +- `bun test test/skill test/tool test/permission test/command` — 1123 pass, 11 skip, 0 fail. +- `bun test test/session` — 899 pass, 25 skip, 1 todo, 0 fail. +- The new test in `test/session/prompt-skill-command-multi.test.ts` was + confirmed to FAIL on the base commit with the intended symptom: with `src/` + stashed, the gated skill appeared in the model's catalog + (`skill-gated` present in `available_skills`). +- `bun lint` (root oxlint) — 0 errors; 4043 warnings is the repo-wide baseline, + and the seven changed source files carry 12, all pre-existing rule classes. +- `git diff --check` — clean. +- `./packages/sdk/js/script/build.ts` — FAIL, `PRE-EXISTING-SDK-CODEGEN`. See T8. +- Independent review by a fresh subagent: all eight acceptance criteria met; one + critical finding (a stray `packages/sdk/js/openapi.json` build artifact + committed by accident) and one correctness nit (the not-found hint duplicating + the reachability predicate over `all()`), both fixed in `6236515e`. + +**Journey log** + +1. The bug was reproduced in the authoring session itself: `/compose-next` + delivered no `` block and no error. `git log -L` on the + mention scan pinned the regression to `4e2a3cb6`, which swapped `sys.all()` + for `sys.available(runtimeAgent)` and deleted the comment recording why the + bypass existed. A comment that explains a non-obvious choice is load-bearing; + deleting it is how the choice gets undone. +2. The first design kept `deny` as the hiding mechanism and special-cased the + user path. Rejected after reading Claude Code's frontmatter reference: the + upstream standard already splits this into `disable-model-invocation` and + `user-invocable`, which named the actual defect — one rule serving two + questions — rather than patching its symptom. +3. `user-invocable: false` was deliberately dropped from the port. No in-repo + skill needs a model-only skill, and shipping an unused second axis would + reintroduce exactly the ambiguity being removed. +4. An earlier draft kept a `disable-model-invocation` skill listed in the + catalog with an annotation, so the model could suggest `/compose-next`. + Rejected: obra/superpowers#345 shows what an advertised-but-unloadable skill + costs — the model retries the tool and then tells the user the skill does not + exist. Documentation skills are the right channel for "this exists, you + invoke it". +5. `git add -A` after a failed SDK generation committed a 16,934-line scratch + file. `git status` before staging would have caught it; the reviewer did. + It is now gitignored. + +## [S1] Problem + +A user typing `/compose-next` gets nothing. The visible text `/compose-next …` +reaches the model, no `` block is ever +injected, and no error is shown. A model calling `skill(name="compose-next")` +is hard-rejected instead of loading it. + +Both symptoms come from one cause: **"hide from the model" and "forbid +invocation" are expressed by the same permission rule.** `compose-next` is +hidden from model auto-discovery by an exact `skill: { "compose-next": "deny" }` +rule on the default agent (`agent/agent.ts:111`). That rule is then consulted by +four independent surfaces: + +| Surface | Code | Effect of `deny` | Intended | +| --- | --- | --- | --- | +| System-prompt catalog | `session/system.ts:181` → `Skill.available` | hidden | yes | +| `skill_search` BM25 | `tool/skill-search.ts:37` | not searchable | yes | +| `skill` tool description | `tool/registry.ts:328` `describeSkill` | hidden | yes | +| `skill` tool execution | `tool/skill.ts:42-47` `ctx.ask` | hard refusal | **no** — `compose-next.md` S2 states execution "stays permissive" | +| User slash body injection | `session/prompt.ts:864` → `Skill.available` | silent no-op | **no** — user explicitly asked for it | + +The slash surface regressed at `4e2a3cb6` ("fix(session): send skill +instructions as user reminders", 2026-07-30), which changed the mention scan +from `sys.all()` to `sys.available(runtimeAgent)` and deleted the comment that +recorded why: *"Use all() to bypass per-agent permission filtering — respect the +user's explicit /mention action"* (established by PR #1716). Since +`4e2a3cb6` there has been no way to express "invisible to the model, still +usable by the user": the only mechanism that hides a skill also disables it. + +The registry already carries a field for the visibility half — `Skill.Info.hidden` +(`skill/index.ts:35`, parsed at `:102`, assigned at `:129`) — but **no code reads +it**, and no bundled `SKILL.md` sets it. It has been dead since PR #1725. + +Separately, `compose-next` has now been through its trial period and should +graduate: it is no longer an experiment to be kept out of the way, it is the +recommended entry point for multi-step feature work. What it still must not do +is start itself. + +## [S2] Design + +Split the two axes. Permission keeps exactly one meaning; a new frontmatter +field carries the other. + +- **`permission.skill` = authorization.** `deny` means unusable, by anyone, + through any surface — model *and* user. Nothing bypasses it. +- **`disable-model-invocation` = model reachability.** The model cannot see or + invoke the skill. A user slash invocation is unaffected. + +### Field + +`disable-model-invocation`, boolean, optional, default `false`. Kebab-case in +YAML frontmatter, matching Claude Code and the +[agentskills.io](https://agentskills.io) open standard so a skill folder is +portable in both directions. Internally it is `Info.disable_model_invocation` +(repo snake_case convention); `add()` in `skill/index.ts` maps the kebab +frontmatter key onto it. + +`Skill.Info.hidden` is removed in the same change. It is dead, unset by every +bundled skill, and keeping a second half-named visibility flag beside the new +field is the exact ambiguity this feature removes. + +The counterpart field in the upstream standard, `user-invocable: false` ("only +the model may invoke"), is deliberately **not** implemented — see S3. + +### Semantics + +Behaviour matrix for one skill, given a default-agent `skill: "*": "allow"`: + +| frontmatter | model sees it | model may invoke | user `/name` works | +| --- | --- | --- | --- | +| (default) | yes | yes | yes | +| `disable-model-invocation: true` | **no** | **no** | **yes** | +| any value + `permission.skill` `deny` | no | no | **no** | + +"Model sees it" covers every list the model reads: the system-prompt catalog, +the `skill` tool description, and `skill_search` results. A +`disable-model-invocation` skill appears in none of them, so the model does not +learn the name from the harness at all — it learns that `/compose-next` exists +from documentation skills such as `mimocode-docs`, which also state that the +model must not start the workflow itself. + +### Registry contract + +`skill/index.ts` gains one accessor beside the existing `all` / `available`: + +- `all()` — unchanged. No filtering. Feeds the command registry + (`command/index.ts:264`), the app skills endpoint, and `/skill` autocomplete, + so a `disable-model-invocation` skill still autocompletes and still has a + slash command. +- `available(agent?)` — unchanged. Authorization filter only + (`Permission.evaluate("skill", name, agent.permission) !== "deny"`). This is + the **user** surface: the mention scan in `insertReminders` keeps using it, so + a user slash invocation is blocked by `deny` and by nothing else. +- `modelInvocable(agent?)` — new. `available(agent)` minus + `disable_model_invocation`. This is the **model** surface. + +Three call sites move from `available` to `modelInvocable`: +`session/system.ts:181` (catalog), `tool/registry.ts:328` (`describeSkill`), +`tool/skill-search.ts:37`. `session/system.ts:206` (`SystemPrompt.available`, +consumed only by the mention scan at `prompt.ts:864`) keeps `available`. + +### Skill tool + +`tool/skill.ts` refuses a `disable_model_invocation` skill before `ctx.ask`, +with an error that redirects rather than dead-ends: the model is told the user +must type `/name` and that retrying the tool will not help. This mirrors Claude +Code's `cannot be used with Skill tool due to disable-model-invocation`, whose +bare form is a known dead-end (obra/superpowers#345 — the model retried and then +gave up instead of telling the user). + +The not-found branch's "Available skills: …" hint (`tool/skill.ts:37-39`) is +filtered by the same predicate, so a typo near a hidden skill's name does not +leak it back to the model. + +### compose-next graduation + +- Delete `"compose-next": "deny"` from the default agent's `skill` ruleset + (`agent/agent.ts:111`). Permission stops carrying visibility for it. The + legacy `"compose:*": "deny"` rule stays exactly as is: those skills are + denied on the default agent and allowed on the Compose agent, which is an + agent-scoped decision that frontmatter cannot express. +- Set `disable-model-invocation: true` in + `skill/builtin/.bundle/compose-next/SKILL.md`. +- Add the behavioural rule in two places, so it survives a future flag flip: + in `description`, that the model must not use the skill unless the user + invoked it or asked for it by name; in the body, that it must not enter the + compose workflow without an explicit user request or invocation. +- Drop `compose-next` from `isComposeSkill` in `skill/search.ts:20-22`. Its + exclusion from search is now carried by the field at the caller, and the + helper goes back to meaning only `startsWith("compose:")`. +- `mimocode-docs` records that `/compose-next` is user-invocable only and that + the model must not start it — this is the intended channel through which the + model learns the skill exists. + +### Accepted behaviour changes + +- A `deny`'d skill can no longer be loaded by an explicit user slash + invocation. Before `4e2a3cb6` it could (PR #1716); since `4e2a3cb6` it cannot. + This design keeps the current behaviour and makes it the documented rule: + `deny` means unusable. Concretely, `/compose:brainstorm` from Build stays + inert; it works from the Compose agent, which allows `compose:*`. +- The model can no longer invoke `compose-next` by guessing its name. + `compose-next.md` S2 previously accepted guessed invocation; this feature + makes it a real gate, which is the whole point of the field. + +## [S3] Out of Scope + +- `user-invocable: false` (model-only skills, hidden from the `/` menu). No + in-repo skill needs it, and adding an unused axis reintroduces the ambiguity + this change removes. `Skill.all()` therefore remains the single user-facing + set. +- Settings-level overrides equivalent to Claude Code's `skillOverrides` + (`on` / `name-only` / `user-invocable-only` / `off`). Per-agent + `permission.skill` remains the only config-side control. +- Migrating `compose:*` off `permission.skill`. Its deny is agent-scoped and + disappears with legacy Compose removal. +- Other frontmatter fields from the upstream standard (`allowed-tools`, + `context: fork`, `argument-hint`, `paths`, `model`). +- The `MAX_AUTOLOAD = 3` budget, the mention regex, and the TUI/ACP + leading-slash routing. + +### Known gaps left open (surfaced by review, deliberately not fixed here) + +- `matchDocumentSkills` (`session/prompt.ts:843`, table at + `skill/builtin/extract.ts:75`) recommends document skills to the model from a + hardcoded list, consulting neither `available` nor `modelInvocable`. No entry + in that table is gated today, so this is latent, not live; it becomes a real + leak the day someone sets the flag on a document skill. +- The entire `tool.skill_search` describe block in + `test/tool/skill-search.test.ts` is `it.live.skip`ped on `main`, so the + compose-next invisibility assertions there — updated to the new contract in + this change — do not run. The mechanism itself is covered by running tests + over fixture skills; only the shipped-builtin wiring is inert. Un-skipping + that block needs the builtin bundle extracted in the test environment, which + is its own change. +- `./packages/sdk/js/script/build.ts` remains broken (see T8). Fixing the + `__schema0` hoisting for `ToolStateCompleted.providerOutput` is a separate + change; until then the generated SDK drifts from the API on every schema + edit, and `providerOutput` itself is still missing from `types.gen.ts`. + +## Tasks + +- [x] T1: Replace the dead `hidden` field on `Skill.Info` with + `disable_model_invocation`, parsed from the kebab-case + `disable-model-invocation` frontmatter key in `skill/index.ts` — acceptance: + a SKILL.md with `disable-model-invocation: true` loads with + `disable_model_invocation === true`; one without it loads `undefined`; no + reference to `Info.hidden` remains in `src` (covers: S2) +- [x] T2: Add `Skill.modelInvocable(agent?)` and move the three model-facing + call sites (`session/system.ts:181`, `tool/registry.ts:328`, + `tool/skill-search.ts:37`) onto it, leaving `SystemPrompt.available` and + the `prompt.ts:864` mention scan on `available` — acceptance: a + `disable-model-invocation` skill is absent from the system-prompt catalog, + the `skill` tool description, and `skill_search` results, while + `Skill.available` and `Skill.all` still return it (covers: S2; depends: T1) +- [x] T3: Refuse `disable_model_invocation` skills in `tool/skill.ts` before + `ctx.ask`, and filter the not-found "Available skills" hint by the same + predicate — acceptance: `skill({name})` on such a skill throws an error + naming `disable-model-invocation` and directing the model to have the user + type `/name`; the name does not appear in the not-found hint for a + mistyped query (covers: S2; depends: T1) +- [x] T4: Graduate `compose-next`: delete `"compose-next": "deny"` from + `agent/agent.ts`, set `disable-model-invocation: true` in its SKILL.md, + add the "only on explicit user invocation" rule to both its `description` + and body, and drop `compose-next` from `isComposeSkill` in + `skill/search.ts` — acceptance: `Permission.evaluate("skill", + "compose-next", defaultAgentRules)` is `allow`; `compose:*` still `deny` on + the default agent and `allow` on Compose; `searchSkills` no longer + special-cases the name (covers: S2; depends: T1) +- [x] T5: Add a regression test that a user slash invocation of a + `disable-model-invocation` skill injects its body, following the real-layer + harness in `test/session/prompt-skill-command-multi.test.ts` — acceptance: + the test fails on the base commit (no `` part for the + invoked skill) and passes after T1-T4 (covers: S1, S2; depends: T2) +- [x] T6: Update the tests that encode the old deny-as-visibility contract + (`test/permission/compose-next-discovery.test.ts`, + `test/skill/search.test.ts:101-115`, `test/tool/skill-search.test.ts:196+`) + and add coverage for frontmatter parsing plus `modelInvocable` filtering — + acceptance: `bun test test/skill test/tool test/permission test/session` + shows no failures attributable to this change (covers: S2; depends: T4) +- [x] T7: Record in `mimocode-docs` that `/compose-next` is user-invocable only + and the model must not start it — acceptance: the skill states both facts + where it already documents `/compose-next` (covers: S2) +- [x] T8: Bring the published `AppSkillsResponses` type in + `packages/sdk/js/src/v2/gen/types.gen.ts` in line with the new + `Skill.Info` shape — acceptance: the skills response type carries + `disable_model_invocation?: boolean` and no `hidden?: boolean`. + `./packages/sdk/js/script/build.ts` cannot be used: it has failed since + `fc74c539` (2026-07-26) because `ToolStateCompleted.providerOutput` + serializes to a dangling `$ref: #/components/schemas/__schema0`, and the + committed types.gen.ts still has no `providerOutput`, confirming the file + predates that commit. Record the field-level hand edit and leave the + generator defect to its own change (covers: S2; depends: T1) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 1099b525a..d2832b86a 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -108,7 +108,6 @@ export const layer = Layer.effect( skill: { "*": "allow", "compose:*": "deny", - "compose-next": "deny", }, plan_exit: "deny", external_directory: { diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index a6dfd861d..c30abfa7a 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -178,7 +178,7 @@ export const layer = Layer.effect( skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info, model?: SkillSearchModel) { if (Permission.disabled(["skill"], agent.permission).has("skill")) return - const list = yield* skill.available(agent) + const list = yield* skill.modelInvocable(agent) if (model && isSkillSearchDisabled(model)) { return [ @@ -202,6 +202,9 @@ export const layer = Layer.effect( ].join("\n") }), + // The user surface: authorization-filtered but NOT model-reachability + // filtered, because it backs the mention scan that loads a skill the user + // invoked explicitly. Do not switch this to modelInvocable. available: Effect.fn("SystemPrompt.available")(function* (agent?: Agent.Info) { return yield* skill.available(agent) }), diff --git a/packages/opencode/src/skill/builtin/.bundle/compose-next/SKILL.md b/packages/opencode/src/skill/builtin/.bundle/compose-next/SKILL.md index 1f6c01959..47f40935a 100644 --- a/packages/opencode/src/skill/builtin/.bundle/compose-next/SKILL.md +++ b/packages/opencode/src/skill/builtin/.bundle/compose-next/SKILL.md @@ -1,12 +1,15 @@ --- name: compose-next -description: Use for multi-step feature work, bug fixes, or refactors where requirements need to settle, a feature document should carry design + tasks + delivery evidence, and the change deserves independent review before merge. Invoked explicitly from Build as `/compose-next` when a Fable/Sol-class model is available. Not for one-shot edits, single-file tweaks, or answering questions — those need no orchestration overhead. +description: Use for multi-step feature work, bug fixes, or refactors where requirements need to settle, a feature document should carry design + tasks + delivery evidence, and the change deserves independent review before merge. Only start this when the user invoked `/compose-next` or asked for this workflow by name — never pick it up on your own, because the grill and spec phases interrupt a user who just wanted the change made. Not for one-shot edits, single-file tweaks, or answering questions — those need no orchestration overhead. +disable-model-invocation: true --- # Compose Next Compact end-to-end contract for grill → spec → workspace → implement → verify → review → finalize → finish. One skill load, no internal skill hand-offs. +Enter this workflow only on an explicit user request — they invoked `/compose-next`, or they named this workflow. Absent that, do the work directly and run none of the phases below; an unrequested grill or spec pass is an interruption, not a service. + ## Step 0 — Orient Inspect the repository, its instructions (`AGENTS.md`, `README`, existing spec files), and recent changes before asking anything. Do not ask the user for facts the environment already answers. diff --git a/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/SKILL.md b/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/SKILL.md index 90cf93315..fe9523bed 100644 --- a/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/SKILL.md +++ b/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/SKILL.md @@ -21,7 +21,7 @@ MiMoCode (CLI binary `mimo`) is an agentic coding tool with a terminal UI, built | **Context management** | Auto-checkpoints, context reconstruction near limit, budgeted injection | automatic; tune via `checkpoint`/`compaction` config | | **Task tree** | `T1`, `T1.1`… tree, integrated with checkpoints | `task` tooling | | **Goal / stop condition** | Judge model verifies a stop condition before the agent halts | `/goal` | -| **Compose mode** | Structured spec→ship lifecycle; recommended entry is the `/compose-next` skill on Build | `/compose-next` (see @reference/guide.md) | +| **Compose mode** | Structured spec→ship lifecycle; recommended entry is the `/compose-next` skill on Build. That skill sets `disable-model-invocation`, so only the user can start it — it is absent from the agent's skill catalog and from `skill_search`, and the `skill` tool refuses it. Suggest `/compose-next` to the user when the work warrants it; never enter the workflow unasked | `/compose-next` (see @reference/guide.md) | | **Voice input** | Streaming ASR (TenVAD + MiMo ASR); needs `sox` | `/voice` | | **Dream** | Consolidates recent traces into project memory | `/dream` | | **Distill** | Packages repeated manual workflows into skills/subagents/commands | `/distill` | diff --git a/packages/opencode/src/skill/builtin/.bundle/skill-creator/SKILL.md b/packages/opencode/src/skill/builtin/.bundle/skill-creator/SKILL.md index 2c3ad8b46..490afbfe4 100644 --- a/packages/opencode/src/skill/builtin/.bundle/skill-creator/SKILL.md +++ b/packages/opencode/src/skill/builtin/.bundle/skill-creator/SKILL.md @@ -69,6 +69,8 @@ Rules (hard requirements): Weak: `description: Helps with projects.` Strong: `description: Manages Linear sprint workflows including planning, task creation, and status tracking. Use when the user mentions "sprint", "Linear tasks", or asks to "create tickets".` +Optional `disable-model-invocation: true` makes the skill user-only: the agent never sees it (no skill catalog entry, no `skill_search` hit, and the `skill` tool refuses it), while `/skill-name` still works for the user. Use it for a long or interrupting workflow whose timing the user should control — a multi-phase orchestration, a deploy, anything with side effects. Leave it off by default; a skill the agent cannot see is a skill it cannot offer. Keep the "only when the user asks" rule in the `description` and body as well, so the skill still behaves if the flag is ever removed. + For all optional fields (`license`, `compatibility`, `metadata`, `allowed-tools`) and more good/bad examples, read `references/frontmatter.md`. ### Step 4: Write the instructions diff --git a/packages/opencode/src/skill/builtin/.bundle/skill-creator/references/frontmatter.md b/packages/opencode/src/skill/builtin/.bundle/skill-creator/references/frontmatter.md index c72fbcc7c..d3a194f58 100644 --- a/packages/opencode/src/skill/builtin/.bundle/skill-creator/references/frontmatter.md +++ b/packages/opencode/src/skill/builtin/.bundle/skill-creator/references/frontmatter.md @@ -29,6 +29,7 @@ Structure: `[What it does] + [When to use it] + [Key capabilities / negative tri ## Optional fields ```yaml +disable-model-invocation: true # user-only: hidden from the agent, still reachable via /skill-name license: MIT # for open-source skills compatibility: Requires network access and Python 3.10+ # 1-500 chars, environment requirements allowed-tools: "Bash(python:*) Bash(npm:*) WebFetch" # restrict tool access @@ -40,6 +41,19 @@ metadata: # any custom key-value pairs tags: [project-management, automation] ``` +### disable-model-invocation + +Controls model reachability, not authorization. With `true`: + +| Surface | Behavior | +|---------|----------| +| Skill catalog in the system prompt | omitted | +| `skill_search` | never returned, never auto-loaded | +| `skill` tool | refuses, telling the agent the user must run `/skill-name` | +| `/skill-name` typed by the user | loads normally | + +Use it when the workflow is long, interrupting, or has side effects and the user should own the timing. Default is `false` — an unseen skill is one the agent cannot offer. Authorization is separate: a `permission.skill` `deny` rule makes a skill unusable by everyone, the user included. + ## Security restrictions Frontmatter is injected into the system prompt, so: diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 55bdb92d1..4dcb943e3 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -32,11 +32,22 @@ export const Info = z.object({ aliases: z.array(z.string()).optional(), location: z.string(), content: z.string(), - hidden: z.boolean().optional(), + // Model reachability, distinct from authorization. When true the model never + // sees the skill (no system-prompt catalog entry, no skill tool description + // entry, no skill_search hit) and the skill tool refuses to load it; a user + // slash invocation still works. Authorization stays with permission.skill, + // where `deny` means unusable by anyone. + disable_model_invocation: z.boolean().optional(), bundled: z.boolean().optional(), }) export type Info = z.infer +// Kebab-case in frontmatter to match Claude Code and the agentskills.io open +// standard, so a skill folder stays portable in both directions. +const Frontmatter = Info.pick({ name: true, description: true, aliases: true }).extend({ + "disable-model-invocation": z.boolean().optional(), +}) + export const InvalidError = NamedError.create( "SkillInvalidError", z.object({ @@ -76,6 +87,7 @@ export interface Interface { readonly all: () => Effect.Effect readonly dirs: () => Effect.Effect readonly available: (agent?: Agent.Info) => Effect.Effect + readonly modelInvocable: (agent?: Agent.Info) => Effect.Effect readonly reload: () => Effect.Effect } @@ -99,7 +111,7 @@ const add = Effect.fnUntraced(function* (state: State, match: string, bundledRoo if (!md) return - const parsed = Info.pick({ name: true, description: true, aliases: true, hidden: true }).safeParse(md.data) + const parsed = Frontmatter.safeParse(md.data) if (!parsed.success) return const isBundled = bundledRoots.some((root) => match.startsWith(root)) @@ -126,7 +138,7 @@ const add = Effect.fnUntraced(function* (state: State, match: string, bundledRoo aliases: parsed.data.aliases, location: match, content: md.content, - hidden: parsed.data.hidden, + disable_model_invocation: parsed.data["disable-model-invocation"], bundled: isBundled || undefined, } }) @@ -304,6 +316,8 @@ export const layer = Layer.effect( return (yield* InstanceState.get(discovered)).dirs }) + // Authorization only: `deny` means unusable by anyone, so this is also the + // set a user slash invocation resolves against. const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) { const s = yield* InstanceState.get(state) let list: Info[] = Object.values(s.skills) @@ -313,12 +327,18 @@ export const layer = Layer.effect( return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny") }) + // Everything the model is allowed to see or act on. Anything the model can + // reach must come from here, never from `available` or `all`. + const modelInvocable = Effect.fn("Skill.modelInvocable")(function* (agent?: Agent.Info) { + return (yield* available(agent)).filter((skill) => !skill.disable_model_invocation) + }) + const reload = Effect.fn("Skill.reload")(function* () { yield* InstanceState.invalidate(discovered) yield* InstanceState.invalidate(state) }) - return Service.of({ get, all, dirs, available, reload }) + return Service.of({ get, all, dirs, available, modelInvocable, reload }) }), ) diff --git a/packages/opencode/src/skill/search.ts b/packages/opencode/src/skill/search.ts index 8b6b147e7..633f3423c 100644 --- a/packages/opencode/src/skill/search.ts +++ b/packages/opencode/src/skill/search.ts @@ -18,7 +18,7 @@ export type SkillSearchModel = { } function isComposeSkill(skill: Pick) { - return skill.name === "compose-next" || skill.name.startsWith("compose:") + return skill.name.startsWith("compose:") } export function isSkillSearchDisabled(model: SkillSearchModel) { diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 45132edd1..e7885d11a 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -325,7 +325,7 @@ export const layer = Layer.effect( }) const describeSkill = Effect.fn("ToolRegistry.describeSkill")(function* (agent: Agent.Info) { - const list = yield* skill.available(agent) + const list = yield* skill.modelInvocable(agent) if (list.length === 0) return "No skills are currently available." return [ "Load a specialized skill that provides domain-specific instructions and workflows.", diff --git a/packages/opencode/src/tool/skill-search.ts b/packages/opencode/src/tool/skill-search.ts index c402ad608..0ef78cfec 100644 --- a/packages/opencode/src/tool/skill-search.ts +++ b/packages/opencode/src/tool/skill-search.ts @@ -34,7 +34,7 @@ export const SkillSearchTool = Tool.define( execute: (params: z.infer, ctx: Tool.Context) => Effect.gen(function* () { const agent = yield* agents.get(ctx.agent) - const available = yield* skill.available(agent) + const available = yield* skill.modelInvocable(agent) const results = searchSkills(params.query, available) if (results.length === 0) { return { diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index c9ae2bed0..3f331d66c 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -1,5 +1,6 @@ import z from "zod" import { Effect } from "effect" +import { Agent } from "../agent/agent" import { Ripgrep } from "../file/ripgrep" import { Skill } from "../skill" import { BuiltinWorkflow } from "../workflow/builtin" @@ -16,6 +17,7 @@ export const SkillTool = Tool.define( Effect.gen(function* () { const skill = yield* Skill.Service const rg = yield* Ripgrep.Service + const agents = yield* Agent.Service return { description: DESCRIPTION, @@ -34,11 +36,25 @@ export const SkillTool = Tool.define( `workflow({ operation: "run", name: "${params.name}", args: { ... } }). Do NOT use the skill tool for it.`, ) } - const all = yield* skill.all() - const available = all.map((item) => item.name).join(", ") + // Same set the tool description advertises, so a near miss cannot + // reveal a skill the model is not allowed to see. + const available = (yield* skill.modelInvocable(yield* agents.get(ctx.agent))) + .map((item) => item.name) + .join(", ") throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) } + // Model reachability gate. The user can still load this skill by + // typing /name; redirect there instead of dead-ending, so the model + // reports the option rather than retrying and giving up. + if (info.disable_model_invocation) { + throw new Error( + `Skill "${info.name}" sets disable-model-invocation, so it cannot be loaded with the skill tool. ` + + `Only the user can start it by typing /${info.name}. Do not retry this tool — tell the user to run ` + + `/${info.name} if that is the workflow they want.`, + ) + } + yield* ctx.ask({ permission: "skill", patterns: [params.name], diff --git a/packages/opencode/test/permission/compose-next-discovery.test.ts b/packages/opencode/test/permission/compose-next-discovery.test.ts index 007f8ff1a..df6066b23 100644 --- a/packages/opencode/test/permission/compose-next-discovery.test.ts +++ b/packages/opencode/test/permission/compose-next-discovery.test.ts @@ -1,9 +1,10 @@ import { test, expect } from "bun:test" +import path from "path" import { Permission } from "../../src/permission" // Mirrors the ruleset actually built in agent.ts for the default agent's skill // permission, so we test the exact rule shape that ships. Compose agent adds -// `compose:*: allow` on top of these defaults; nothing overrides compose-next. +// `compose:*: allow` on top of these defaults. function defaultAgentSkillRules() { return Permission.fromConfig({ "*": "allow", @@ -11,7 +12,6 @@ function defaultAgentSkillRules() { skill: { "*": "allow", "compose:*": "deny", - "compose-next": "deny", }, }) } @@ -25,9 +25,22 @@ function composeAgentSkillRules() { ) } -test("default agent denies compose-next skill", () => { +// compose-next graduated: permission no longer carries its invisibility. Hiding +// it from the model is now the SKILL.md `disable-model-invocation` field, and +// permission means authorization only — a `deny` makes a skill unusable by +// everyone, the user included. Denying compose-next here would break the user's +// own `/compose-next`, since the slash body injection resolves against +// Skill.available(). +test("default agent allows compose-next so user slash invocation still resolves", () => { const rule = Permission.evaluate("skill", "compose-next", defaultAgentSkillRules()) - expect(rule.action).toBe("deny") + expect(rule.action).toBe("allow") +}) + +test("compose-next is hidden from the model by frontmatter, not by permission", async () => { + const skill = await Bun.file( + path.join(import.meta.dir, "../../src/skill/builtin/.bundle/compose-next/SKILL.md"), + ).text() + expect(skill).toContain("\ndisable-model-invocation: true\n") }) test("default agent still denies legacy compose:* skills", () => { @@ -40,20 +53,15 @@ test("default agent allows an ordinary skill", () => { expect(rule.action).toBe("allow") }) -test("compose agent still denies compose-next (not a compose-mode internal)", () => { - const rule = Permission.evaluate("skill", "compose-next", composeAgentSkillRules()) - expect(rule.action).toBe("deny") -}) - test("compose agent allows compose:* skills through its override", () => { const rule = Permission.evaluate("skill", "compose:plan", composeAgentSkillRules()) expect(rule.action).toBe("allow") }) -test("exact compose-next deny does not shadow ordinary skills starting with compose", () => { +test("compose:* pattern does not shadow ordinary skills starting with compose", () => { // Sanity: a user could hypothetically install a skill literally named - // "compose" (no colon, no dash). It must not be denied by our compose-next - // exact rule or the compose:* pattern. + // "compose" (no colon, no dash). It must not be denied by the compose:* + // pattern. const rule = Permission.evaluate("skill", "compose", defaultAgentSkillRules()) expect(rule.action).toBe("allow") }) diff --git a/packages/opencode/test/session/prompt-skill-command-multi.test.ts b/packages/opencode/test/session/prompt-skill-command-multi.test.ts index 8a91d12f1..463f60dfc 100644 --- a/packages/opencode/test/session/prompt-skill-command-multi.test.ts +++ b/packages/opencode/test/session/prompt-skill-command-multi.test.ts @@ -21,11 +21,11 @@ afterEach(async () => { const it = testEffect(makeLayer()) -function writeSkill(dir: string, name: string, marker: string, description?: string) { +function writeSkill(dir: string, name: string, marker: string, description?: string, extraFrontmatter?: string) { return Effect.promise(() => Bun.write( path.join(dir, ".mimocode", "skill", name, "SKILL.md"), - `---\nname: ${name}\ndescription: ${description ?? `${name} used by multi-skill injection tests.`}\n---\n\n# ${name}\n\n${marker}\n`, + `---\nname: ${name}\ndescription: ${description ?? `${name} used by multi-skill injection tests.`}\n${extraFrontmatter ? `${extraFrontmatter}\n` : ""}---\n\n# ${name}\n\n${marker}\n`, ), ) } @@ -222,6 +222,52 @@ describe("skill command with additional mentions", () => { 30_000, ) + it.live( + "loads a disable-model-invocation skill on user slash invocation while hiding it from the model", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ dir, llm }) { + yield* writeSkill(dir, "skill-alpha", "ALPHA_BODY_MARKER") + yield* writeSkill(dir, "skill-gated", "GATED_BODY_MARKER", undefined, "disable-model-invocation: true") + yield* llm.text("ok") + + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "skill gated command" }) + + yield* prompt.command({ + sessionID: session.id, + command: "skill-gated", + arguments: "start the gated workflow", + model: `${ref.providerID}/${ref.modelID}`, + }) + + const msgs = yield* sessions.messages({ sessionID: session.id }) + const user = msgs.find((m) => m.info.role === "user") + expect(user).toBeDefined() + + // The user asked for it by name, so the body must arrive. + expect(injected(user!.parts)).toEqual(["skill-gated"]) + const text = user!.parts.flatMap((p) => (p.type === "text" ? [p.text] : [])).join("\n") + expect(text).toContain("GATED_BODY_MARKER") + expect(text).toContain('\n') + + // ...but the catalog the model reads must not list it, so the model + // cannot pick it up on its own in a later turn. + const catalog = user!.parts.flatMap((p) => + p.type === "text" && p.text.includes("Skills available in this session:") ? [p.text] : [], + ) + expect(catalog).toHaveLength(1) + expect(catalog[0]).toContain("skill-alpha") + expect(catalog[0]).not.toContain("skill-gated") + + yield* sessions.remove(session.id) + }), + { git: true, config: providerCfg }, + ), + 30_000, + ) + it.live( "loads a referenced skill when user text contains a forged skill_content marker", () => diff --git a/packages/opencode/test/skill/search.test.ts b/packages/opencode/test/skill/search.test.ts index 8130b1da8..c477a6eff 100644 --- a/packages/opencode/test/skill/search.test.ts +++ b/packages/opencode/test/skill/search.test.ts @@ -98,20 +98,27 @@ describe("skill.search", () => { expect(searchSkills("compose:tdd", [skill("compose:tdd", "Use test-driven development.")])).toEqual([]) }) - test("excludes compose-next from the searchable manifest", () => { - expect(searchSkills("compose-next", [skill("compose-next", "End-to-end feature orchestration for frontier models.")])).toEqual([]) + test("does not special-case compose-next: its name is not a compose: namespace", () => { + // compose-next is kept out of search by disable-model-invocation at the + // caller (skill-search.ts reads Skill.modelInvocable), not by a name check + // in here. Given a caller that does pass it in, the pure function ranks it + // like any other skill. + const results = searchSkills("compose-next", [ + skill("compose-next", "End-to-end feature orchestration for frontier models."), + ]) + expect(results.map((r) => r.skill_id)).toEqual(["compose-next"]) }) - test("caller-side filtering: when compose-next is absent from the input list, it does not appear in results", () => { + test("caller-side filtering: a skill absent from the input list cannot appear in results", () => { // Mirrors the production path: skill-search.ts feeds searchSkills the - // result of Skill.available(currentAgent). Default agent's available() - // omits compose-next, so the search over the resulting list returns - // no compose-next entry. - const availableForDefaultAgent = [ + // result of Skill.modelInvocable(currentAgent), which drops every + // disable-model-invocation skill, so no such skill can be returned or + // auto-loaded. + const modelInvocableForDefaultAgent = [ skill("deep-research", "Multi-source research report."), skill("data-analytics", "Analyze datasets and produce findings."), ] - const results = searchSkills("compose-next end to end feature orchestration", availableForDefaultAgent) + const results = searchSkills("compose-next end to end feature orchestration", modelInvocableForDefaultAgent) expect(results.every((r) => r.skill_id !== "compose-next")).toBe(true) }) }) diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 4f92c7ccd..726840a36 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -463,4 +463,49 @@ description: A skill in the .mimocode/skills directory. { git: true }, ), ) + + // Model reachability is carried by the SKILL.md field, not by permission: + // all() and available() keep such a skill so the command registry and the + // user's slash invocation still resolve it, while modelInvocable() drops it. + it.live("separates model reachability from the user-facing skill sets", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Promise.all([ + Bun.write( + path.join(dir, ".mimocode", "skill", "gated-skill", "SKILL.md"), + `--- +name: gated-skill +description: Only the user may start this one. +disable-model-invocation: true +--- + +# Gated Skill +`, + ), + Bun.write( + path.join(dir, ".mimocode", "skill", "open-skill", "SKILL.md"), + `--- +name: open-skill +description: Anyone may start this one. +--- + +# Open Skill +`, + ), + ]), + ) + + const skill = yield* Skill.Service + expect((yield* skill.get("gated-skill"))?.disable_model_invocation).toBe(true) + expect((yield* skill.get("open-skill"))?.disable_model_invocation).toBeUndefined() + + expect((yield* skill.all()).map((item) => item.name).toSorted()).toEqual(["gated-skill", "open-skill"]) + expect((yield* skill.available()).map((item) => item.name)).toEqual(["gated-skill", "open-skill"]) + expect((yield* skill.modelInvocable()).map((item) => item.name)).toEqual(["open-skill"]) + }), + { git: true }, + ), + ) }) diff --git a/packages/opencode/test/tool/skill-search.test.ts b/packages/opencode/test/tool/skill-search.test.ts index 10d54fd85..d4faa8ee2 100644 --- a/packages/opencode/test/tool/skill-search.test.ts +++ b/packages/opencode/test/tool/skill-search.test.ts @@ -193,12 +193,12 @@ description: Analyze quasar telemetry and operational metrics. ), ) - // Regression: compose-next is a builtin skill that ships in Skill.all() so - // the /compose-next slash command works, but the default agent's - // "compose-next: deny" skill permission must keep it out of - // Skill.available(agent) — and skill_search reads from available(), not all(). - // A model asking a query that would otherwise match compose-next must get - // no hit under Build, Plan, or Compose. + // Regression: compose-next is a builtin skill that ships in Skill.all() and + // is permission-allowed so the /compose-next slash command works, but its + // SKILL.md sets disable-model-invocation, which must keep it out of + // Skill.modelInvocable(agent) — and skill_search reads from modelInvocable, + // not available() or all(). A model asking a query that would otherwise match + // compose-next must get no hit under Build, Plan, or Compose. it.live.skip("does not surface compose-next to any primary agent's skill_search", () => provideTmpdirInstance( () => @@ -222,11 +222,18 @@ description: Analyze quasar telemetry and operational metrics. const agent = yield* agents.get(agentName) expect(agent).toBeDefined() - // Sanity: compose-next is filtered out of the agent's available skills. + // The user surface keeps it: a slash invocation must still resolve. const available = yield* skills.available(agent!) expect( - available.every((s) => s.name !== "compose-next"), - `compose-next must be absent from Skill.available(${agentName}) via the default agent's exact-name deny rule`, + available.some((s) => s.name === "compose-next"), + `compose-next must stay in Skill.available(${agentName}) so /compose-next injects its body`, + ).toBe(true) + + // The model surface drops it, via disable-model-invocation. + const modelInvocable = yield* skills.modelInvocable(agent!) + expect( + modelInvocable.every((s) => s.name !== "compose-next"), + `compose-next must be absent from Skill.modelInvocable(${agentName}) via disable-model-invocation`, ).toBe(true) const tool = (yield* registry.tools({ diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index e1b34a9b6..d562831f6 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -120,4 +120,78 @@ Use this skill. { git: true }, ), ) + + it.live("refuses a disable-model-invocation skill and points at the user's slash command", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Promise.all([ + Bun.write( + path.join(dir, ".mimocode", "skill", "gated-skill", "SKILL.md"), + `--- +name: gated-skill +description: Only the user may start this one. +disable-model-invocation: true +--- + +# Gated Skill + +GATED_BODY_MARKER +`, + ), + Bun.write( + path.join(dir, ".mimocode", "skill", "open-skill", "SKILL.md"), + `--- +name: open-skill +description: Anyone may start this one. +--- + +# Open Skill +`, + ), + ]), + ) + + const registry = yield* ToolRegistry.Service + const agent = { name: "build", mode: "primary" as const, permission: [], options: {} } + const tool = (yield* registry.tools({ + providerID: "opencode" as any, + modelID: "gpt-5" as any, + agent, + })).find((tool) => tool.id === SkillTool.id) + if (!tool) throw new Error("Skill tool not found") + + const requests: Array> = [] + const ctx: Tool.Context = { + ...baseCtx, + ask: (req) => + Effect.sync(() => { + requests.push(req) + }), + } + + const exit = yield* Effect.exit(tool.execute({ name: "gated-skill" }, ctx)) + expect(exit._tag).toBe("Failure") + const msg = exit._tag === "Failure" ? Cause.pretty(exit.cause) : "" + expect(msg).toContain("disable-model-invocation") + expect(msg).toContain("/gated-skill") + expect(msg).not.toContain("GATED_BODY_MARKER") + // Refused before the permission ask, so no approval is requested for a + // call that can never succeed. + expect(requests).toEqual([]) + + // The tool description must not advertise it either, and a mistyped + // name must not leak it back through the not-found hint. + expect(tool.description).not.toContain("gated-skill") + expect(tool.description).toContain("open-skill") + const miss = yield* Effect.exit(tool.execute({ name: "gated-skil" }, ctx)) + const missMsg = miss._tag === "Failure" ? Cause.pretty(miss.cause) : "" + expect(missMsg).toContain("not found") + expect(missMsg).toContain("open-skill") + expect(missMsg).not.toContain("gated-skill") + }), + { git: true }, + ), + ) }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9bf438437..f1a5e207c 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -6784,7 +6784,7 @@ export type AppSkillsResponses = { aliases?: Array location: string content: string - hidden?: boolean + disable_model_invocation?: boolean bundled?: boolean }> } From f1e99640da903b22c1147b9fee15d5fdf5691c21 Mon Sep 17 00:00:00 2001 From: Yihan Yan Date: Tue, 4 Aug 2026 21:01:30 +0800 Subject: [PATCH 106/135] fix(server): stop emitting a dangling $ref, unbreaking SDK codegen (#2027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `./packages/sdk/js/script/build.ts` has failed since fc74c539 with `Missing $ref pointer "#/components/schemas/__schema0"`, so the generated SDK has been frozen for four days — `types.gen.ts` still had no `providerOutput`, the very field that broke it. Cause: `providerOutput: z.json()` is a recursive schema. zod names its anonymous definition `__schema0` under a local `$defs`, and zod-openapi rewrites every `#/$defs/` reference to `#/components/schemas/` while only hoisting the definitions it knows by name (components-DiNDbisK.mjs:369-428). The definition stays nested in seven places, the reference points at a component that was never written, and openapi-ts refuses to parse the document. Naming the schema does not help — it just produces a second dangling ref. `z.json()` is the only one in src, so drop it rather than build hoisting machinery: `z.unknown()` carries the same "opaque JSON blob" intent without the recursion. No validation is lost, because the only producer already round-trips through JSON.stringify/JSON.parse (processor.ts:53-56) and maps anything non-serializable to null. The new test resolves every JSON Pointer in the generated document, so this class of defect fails in CI instead of waiting for someone to regenerate the SDK. The regenerated SDK picks up four days of drift: providerOutput, providerMetadata, McpSamplingPolicy, max_context, lastActivityTime. --- packages/opencode/src/session/message-v2.ts | 2 +- .../opencode/test/server/openapi-refs.test.ts | 40 +++++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 26 ++++++++++-- 3 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/test/server/openapi-refs.test.ts diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index a548cd054..949bae61f 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -325,7 +325,7 @@ export const ToolStateCompleted = z status: z.literal("completed"), input: z.record(z.string(), z.any()), output: z.string(), - providerOutput: z.json().optional(), + providerOutput: z.unknown().optional(), providerMetadata: z.record(z.string(), z.any()).optional(), title: z.string(), metadata: z.record(z.string(), z.any()), diff --git a/packages/opencode/test/server/openapi-refs.test.ts b/packages/opencode/test/server/openapi-refs.test.ts new file mode 100644 index 000000000..98505c957 --- /dev/null +++ b/packages/opencode/test/server/openapi-refs.test.ts @@ -0,0 +1,40 @@ +import { test, expect } from "bun:test" +import { Server } from "../../src/server/server" + +const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null + +// zod-openapi rewrites every local `#/$defs/` reference to +// `#/components/schemas/` but only hoists the definitions it knows by +// name, so a recursive zod schema — `z.json()`, `z.lazy()`, any self-reference — +// emits a $ref to a component that was never written. Nothing in the running +// server notices; the failure surfaces only when someone regenerates the SDK, +// where openapi-ts dies with `Missing $ref pointer`. That is how the spec stayed +// broken for four days after `fc74c539` shipped `providerOutput: z.json()`. +// Resolving every pointer here turns that into a test failure instead. +test("every $ref in the generated OpenAPI document resolves", async () => { + const doc = await Server.openapi() + + const refs = new Set() + const collect = (node: unknown) => { + if (Array.isArray(node)) return node.forEach(collect) + if (!isRecord(node)) return + for (const [key, value] of Object.entries(node)) { + if (key === "$ref" && typeof value === "string") refs.add(value) + collect(value) + } + } + collect(doc) + expect(refs.size).toBeGreaterThan(0) + + // JSON Pointer walk, with RFC 6901 token unescaping. + const resolve = (node: unknown, tokens: string[]): unknown => { + if (tokens.length === 0) return node + if (!isRecord(node)) return undefined + return resolve(node[tokens[0].replaceAll("~1", "/").replaceAll("~0", "~")], tokens.slice(1)) + } + + const dangling = [...refs].filter( + (ref) => !ref.startsWith("#/") || resolve(doc, ref.slice(2).split("/")) === undefined, + ) + expect(dangling).toEqual([]) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index f1a5e207c..4b68324aa 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -125,7 +125,7 @@ export type EventActorStalled = { sessionID: string actorID: string description: string - lastTurnTime: number + lastActivityTime: number stalledDuration: number } } @@ -1195,6 +1195,10 @@ export type ToolStateCompleted = { [key: string]: unknown } output: string + providerOutput?: unknown + providerMetadata?: { + [key: string]: unknown + } title: string metadata: { [key: string]: unknown @@ -1883,6 +1887,11 @@ export type ProviderConfig = { only_configured_models?: boolean } +/** + * Policy for MCP client-side sampling (`sampling/createMessage`) from this server: deny, ask (default), or allow. + */ +export type McpSamplingPolicy = "deny" | "ask" | "allow" + export type McpLocalConfig = { /** * Type of MCP server connection @@ -1906,6 +1915,7 @@ export type McpLocalConfig = { * Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified. */ timeout?: number + sampling?: McpSamplingPolicy } export type McpOAuthConfig = { @@ -1954,6 +1964,7 @@ export type McpRemoteConfig = { * Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified. */ timeout?: number + sampling?: McpSamplingPolicy } /** @@ -2193,6 +2204,15 @@ export type Config = { * Token buffer for compaction. Leaves enough window to avoid overflow during compaction. */ reserved?: number + /** + * Compact earlier than the model window. A token count (300000), a shorthand string ("300K", "1M", "50%"), or a map keyed by "/" with wildcards ("openai/gpt-5*"). Always clamped to the model's real window — it can only lower the compaction trigger, never raise it. 0 means no budget. + */ + max_context?: + | number + | string + | { + [key: string]: number | string + } } checkpoint?: { /** @@ -2294,7 +2314,7 @@ export type Config = { } dream?: { /** - * Auto-trigger dream memory consolidation on new session start. Default: true. + * Auto-trigger dream memory consolidation on new session start. Default: false. */ auto?: boolean /** @@ -2304,7 +2324,7 @@ export type Config = { } distill?: { /** - * Auto-trigger distill workflow packaging on new session start. Default: true. + * Auto-trigger distill workflow packaging on new session start. Default: false. */ auto?: boolean /** From 807ff9e2d9a07f17e1463867c1643cb7ab1f5461 Mon Sep 17 00:00:00 2001 From: Cheng Liangyu Date: Tue, 4 Aug 2026 21:03:36 +0800 Subject: [PATCH 107/135] fix(provider): document the Responses-API provider set, stop empty options bag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on #2028. EXPLICIT_NON_STRICT_TOOL_SDKS is keyed by npm package, but the Responses-vs-Chat decision is made per PROVIDER in `provider.ts` `getModel`, so the two can drift silently. Enumerate every `sdk.responses()` call site with the verdict for each, so the next reader does not have to re-derive it: openai (:323) @ai-sdk/openai → listed azure (:359) @ai-sdk/azure → listed azure-cognitive-services (:379) @ai-sdk/azure → covered (catalog pins the provider npm to @ai-sdk/azure) github-copilot (:340) npm id resolves to the VENDORED ./sdk/copilot, which already always emits `strict` xai (:331) @ai-sdk/xai → deliberately NOT listed xai looks like it belongs: it reaches a Responses endpoint and forwards `tool.strict` behind the same omit-when-null guard. But its prepare-tools runs every schema through `removeAdditionalPropertiesFalse` (xai/dist:319), and strict mode REQUIRES `additionalProperties: false` — a strict-by-default xAI would reject every tool call the SDK makes, so it cannot be strict by default. Adding it would assert a constraint xAI has not been shown to honour. Pinned by a test so the exclusion reads as a decision, not an oversight. Also: `structuredOutputOptions` now returns undefined rather than `{}` for SDKs that do not default json_schema strict on. It previously made goal.ts attach `providerOptions: { [providerID]: {} }` to EVERY judge call, including on models the option does not apply to — harmless, but an unintended new field on providers this change has no business touching. --- packages/opencode/src/provider/transform.ts | 40 ++++++++++++++----- packages/opencode/src/session/goal.ts | 15 +++---- .../test/provider/strict-schema-wire.test.ts | 23 ++++++++++- 3 files changed, 60 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index c622246e4..26b39bcb0 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1027,15 +1027,32 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re // the same schema gets a clean 502 instead, which is why this read as random // upstream flakiness rather than a deterministic schema problem. // -// So state the intent explicitly. Scoped to the SDKs that reach an OpenAI -// Responses endpoint AND forward `tool.strict`: -// - `@ai-sdk/openai` -// - `@ai-sdk/azure`, which builds `OpenAIResponsesLanguageModel` from -// `@ai-sdk/openai/internal` -// The vendored Copilot Responses SDK (`provider/sdk/copilot/responses`) already -// always emits `strict`, and every other SDK is left alone on purpose: -// `@ai-sdk/anthropic` warns ("strict mode is not supported by this provider") -// for any non-null `strict`, so a blanket default would spam warnings there. +// So state the intent explicitly. +// +// This list is keyed by npm package, but the Responses-vs-Chat decision is made +// per PROVIDER in `provider.ts` `getModel`. Those two can drift, so here is the +// full set of `sdk.responses()` call sites and why each is or is not listed: +// +// provider.ts:323 openai @ai-sdk/openai → LISTED +// provider.ts:359 azure @ai-sdk/azure → LISTED, builds +// `OpenAIResponsesLanguageModel` from `@ai-sdk/openai/internal` +// provider.ts:379 azure-cognitive-services @ai-sdk/azure → covered by the above +// (catalog pins the provider's npm to `@ai-sdk/azure`) +// provider.ts:340 github-copilot → the npm id resolves to the VENDORED +// `./sdk/copilot`, whose prepare-tools already always emits +// `strict`, so it is immune and must stay out of this list +// provider.ts:331 xai @ai-sdk/xai → NOT listed. It +// forwards `tool.strict` with the same omit-when-null guard, but +// its prepare-tools runs every tool schema through +// `removeAdditionalPropertiesFalse` (xai/dist:319). Strict mode +// REQUIRES `additionalProperties: false`, so a strict-by-default +// xAI would reject every tool call the SDK makes. It therefore +// cannot be strict by default, and forcing the field here would +// assert a constraint xAI has not been shown to honour. +// +// Everything else is left alone on purpose: `@ai-sdk/anthropic` warns ("strict mode +// is not supported by this provider") for any non-null `strict`, so a blanket +// default would spam warnings on every Anthropic request. // // An explicit per-tool `strict` is preserved, so a tool that has been made // strict-compatible can still opt in. @@ -1120,8 +1137,11 @@ export function tools>(tools: T, model: Provider.M const DEFAULT_STRICT_SCHEMA_SDKS = ["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/openai-compatible"] // Feed through `providerOptions()` before handing to generateObject/streamObject. +// Returns undefined — not `{}` — for SDKs that do not default strict on, so +// callers can skip attaching a provider-options bag entirely rather than sending +// an empty one to every other provider. export function structuredOutputOptions(model: Provider.Model) { - if (!DEFAULT_STRICT_SCHEMA_SDKS.includes(model.api.npm)) return {} + if (!DEFAULT_STRICT_SCHEMA_SDKS.includes(model.api.npm)) return undefined return { strictJsonSchema: false } } diff --git a/packages/opencode/src/session/goal.ts b/packages/opencode/src/session/goal.ts index edec04004..32f9cf16f 100644 --- a/packages/opencode/src/session/goal.ts +++ b/packages/opencode/src/session/goal.ts @@ -188,6 +188,12 @@ export const layer = Layer.effect( messages: JSON.stringify(fullMessages, clip), }) + // `Verdict.impossible` is optional by design, which strict mode rejects. + // See ProviderTransform.structuredOutputOptions for the full reasoning. + // undefined for SDKs that don't default json_schema strict on, so those + // models keep sending no provider options at all. + const structuredOutput = ProviderTransform.structuredOutputOptions(resolved) + const params = { experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry, @@ -205,12 +211,7 @@ export const layer = Layer.effect( ], model: language, schema: Verdict, - // `Verdict.impossible` is optional by design, which strict mode rejects. - // See ProviderTransform.structuredOutputOptions for the full reasoning. - providerOptions: ProviderTransform.providerOptions( - resolved, - ProviderTransform.structuredOutputOptions(resolved), - ), + providerOptions: structuredOutput && ProviderTransform.providerOptions(resolved, structuredOutput), } satisfies Parameters[0] if (isOpenaiOauth) { @@ -220,7 +221,7 @@ export const layer = Layer.effect( providerOptions: ProviderTransform.providerOptions(resolved, { instructions: JUDGE_SYSTEM, store: false, - ...ProviderTransform.structuredOutputOptions(resolved), + ...structuredOutput, }), onError: () => {}, }) diff --git a/packages/opencode/test/provider/strict-schema-wire.test.ts b/packages/opencode/test/provider/strict-schema-wire.test.ts index 1c6080f94..a86ac73c5 100644 --- a/packages/opencode/test/provider/strict-schema-wire.test.ts +++ b/packages/opencode/test/provider/strict-schema-wire.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { createAnthropic } from "@ai-sdk/anthropic" import { createOpenAI } from "@ai-sdk/openai" +import { createXai } from "@ai-sdk/xai" import { dynamicTool, generateObject, generateText, jsonSchema, tool } from "ai" import z from "zod" import { ProviderTransform } from "../../src/provider" @@ -221,6 +222,23 @@ describe("non-OpenAI SDKs are left alone", () => { const tools = ProviderTransform.tools(toolset(), model("@ai-sdk/openai-compatible")) for (const entry of Object.values(tools)) expect(entry).not.toHaveProperty("strict") }) + + // xai also reaches a Responses endpoint (provider.ts:331) and forwards + // `tool.strict` with the same omit-when-null guard, so it looks like it belongs + // in the list. It does not: its prepare-tools strips `additionalProperties: + // false` from every schema, which strict mode REQUIRES — a strict-by-default xAI + // would reject every tool call the SDK makes, so it cannot be strict by default. + // Pinned so the exclusion reads as a decision rather than an oversight. + test("xai is excluded — its SDK strips additionalProperties, so it cannot be strict by default", async () => { + const tools = ProviderTransform.tools(toolset(), model("@ai-sdk/xai")) + for (const entry of Object.values(tools)) expect(entry).not.toHaveProperty("strict") + + const stripped = await outbound(tools, responsesReply, (fetch) => + createXai({ apiKey: "test-key", fetch }).responses("grok-4"), + ) + expect(stripped.tools).toHaveLength(2) + for (const entry of stripped.tools) expect(entry).not.toHaveProperty("strict") + }) }) // The `response_format` sibling of the above. Here the SDKs default @@ -276,7 +294,10 @@ describe("structured output declares strict: false for non-strict-compatible sch expect(ProviderTransform.structuredOutputOptions(model("@ai-sdk/openai-compatible"))).toEqual({ strictJsonSchema: false, }) - expect(ProviderTransform.structuredOutputOptions(model("@ai-sdk/anthropic"))).toEqual({}) + // undefined, not {} — so goal.ts attaches no provider-options bag at all for + // SDKs that don't default json_schema strict on. + expect(ProviderTransform.structuredOutputOptions(model("@ai-sdk/anthropic"))).toBeUndefined() + expect(ProviderTransform.structuredOutputOptions(model("@ai-sdk/xai"))).toBeUndefined() }) // agent.ts's schema is deliberately NOT opted out: it is strict-compatible, so From e77356b799663ecc1c0a88c9474af8690b249ab5 Mon Sep 17 00:00:00 2001 From: Cheng Liangyu Date: Tue, 4 Aug 2026 21:10:53 +0800 Subject: [PATCH 108/135] test(provider): assert xai's additionalProperties stripping instead of citing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: the xai test was named for the stripping behaviour but only asserted that no `strict` reached the wire — the `toolset()` fixture carries no `additionalProperties: false`, so it could not have observed the stripping. The name promised more than the test verified. The stripping is the load-bearing premise of the xai exclusion, so assert it rather than resting on the SDK-source citation in the comment. Split into two tests: one pinning that xai tools go out untouched, one sending a schema that DOES carry `additionalProperties: false` and checking xai drops it, with the OpenAI SDK as a contrast to show the stripping is xai-specific and not something `ai` core does upstream. If xai ever stops stripping the field, that test now fails and the exclusion is due for re-evaluation — which is the point of asserting it. --- .../test/provider/strict-schema-wire.test.ts | 50 ++++++++++++++++--- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/opencode/test/provider/strict-schema-wire.test.ts b/packages/opencode/test/provider/strict-schema-wire.test.ts index a86ac73c5..0fb96b6ed 100644 --- a/packages/opencode/test/provider/strict-schema-wire.test.ts +++ b/packages/opencode/test/provider/strict-schema-wire.test.ts @@ -225,19 +225,53 @@ describe("non-OpenAI SDKs are left alone", () => { // xai also reaches a Responses endpoint (provider.ts:331) and forwards // `tool.strict` with the same omit-when-null guard, so it looks like it belongs - // in the list. It does not: its prepare-tools strips `additionalProperties: - // false` from every schema, which strict mode REQUIRES — a strict-by-default xAI - // would reject every tool call the SDK makes, so it cannot be strict by default. - // Pinned so the exclusion reads as a decision rather than an oversight. - test("xai is excluded — its SDK strips additionalProperties, so it cannot be strict by default", async () => { + // in the list. It does not — see the next test for the reason. Pinned so the + // exclusion reads as a decision rather than an oversight. + test("xai tools are left untouched, so nothing reaches the wire", async () => { const tools = ProviderTransform.tools(toolset(), model("@ai-sdk/xai")) for (const entry of Object.values(tools)) expect(entry).not.toHaveProperty("strict") - const stripped = await outbound(tools, responsesReply, (fetch) => + const body = await outbound(tools, responsesReply, (fetch) => createXai({ apiKey: "test-key", fetch }).responses("grok-4"), ) - expect(stripped.tools).toHaveLength(2) - for (const entry of stripped.tools) expect(entry).not.toHaveProperty("strict") + expect(body.tools).toHaveLength(2) + for (const entry of body.tools) expect(entry).not.toHaveProperty("strict") + }) + + // The premise the exclusion above rests on, asserted rather than cited. + // + // @ai-sdk/xai runs every tool schema through `removeAdditionalPropertiesFalse` + // (xai/dist:319, called from prepareResponsesTools). OpenAI strict mode REQUIRES + // `additionalProperties: false` on every object, so a strict-by-default xAI + // would reject every tool call its own SDK makes — self-contradictory. That is + // why forcing `strict: false` there would assert a constraint xAI has not been + // shown to honour. + // + // If xai ever stops stripping the field, this test fails and the exclusion is + // due for re-evaluation — which is the whole point of asserting it here. + test("xai strips additionalProperties: false, which strict mode requires", async () => { + const withAdditionalProperties = () => ({ + query: dynamicTool({ + description: "Query a server", + inputSchema: jsonSchema({ + type: "object", + properties: { q: { type: "string" } }, + required: ["q"], + additionalProperties: false, + }), + execute: async () => "ok", + }), + }) + + const viaXai = await outbound(withAdditionalProperties(), responsesReply, (fetch) => + createXai({ apiKey: "test-key", fetch }).responses("grok-4"), + ) + expect(viaXai.tools[0].parameters).not.toHaveProperty("additionalProperties") + + // CONTRAST: the OpenAI SDK ships the same schema with the field intact, so the + // stripping is xai-specific and not something `ai` core does upstream. + const viaOpenai = await openaiResponses(withAdditionalProperties()) + expect(viaOpenai.tools[0].parameters.additionalProperties).toBe(false) }) }) From 68405e27d1cb4c5e602cd0361fe585318e702029 Mon Sep 17 00:00:00 2001 From: Cheng Liangyu Date: Tue, 4 Aug 2026 21:17:36 +0800 Subject: [PATCH 109/135] test(provider): pin the xai version the additionalProperties claim was verified against A reviewer read the stripping citation against @ai-sdk/xai 3.0.82 and correctly found no removeAdditionalPropertiesFalse there, concluding the test must fail. The direct dependency is 3.0.102 (package.json:111), which does strip; 3.0.82 is the TRANSITIVE copy bun.lock records under ai-gateway-provider, and "3.0.82" is also the @ai-sdk/anthropic pin two lines up. Name the version in the comment so the claim is checkable without that ambiguity. --- .../test/provider/strict-schema-wire.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/opencode/test/provider/strict-schema-wire.test.ts b/packages/opencode/test/provider/strict-schema-wire.test.ts index 0fb96b6ed..86cac5ae2 100644 --- a/packages/opencode/test/provider/strict-schema-wire.test.ts +++ b/packages/opencode/test/provider/strict-schema-wire.test.ts @@ -241,11 +241,13 @@ describe("non-OpenAI SDKs are left alone", () => { // The premise the exclusion above rests on, asserted rather than cited. // // @ai-sdk/xai runs every tool schema through `removeAdditionalPropertiesFalse` - // (xai/dist:319, called from prepareResponsesTools). OpenAI strict mode REQUIRES - // `additionalProperties: false` on every object, so a strict-by-default xAI - // would reject every tool call its own SDK makes — self-contradictory. That is - // why forcing `strict: false` there would assert a constraint xAI has not been - // shown to honour. + // (xai/dist:319, called from prepareResponsesTools; verified against the pinned + // @ai-sdk/xai 3.0.102 — note bun.lock also records a TRANSITIVE xai 3.0.82 under + // ai-gateway-provider that predates the stripping, so check the direct dependency + // when re-verifying). OpenAI strict mode REQUIRES `additionalProperties: false` + // on every object, so a strict-by-default xAI would reject every tool call its + // own SDK makes — self-contradictory. That is why forcing `strict: false` there + // would assert a constraint xAI has not been shown to honour. // // If xai ever stops stripping the field, this test fails and the exclusion is // due for re-evaluation — which is the whole point of asserting it here. From 803835f21885ee50a1213fe6afb9b1cc807e893b Mon Sep 17 00:00:00 2001 From: fanhuanjie Date: Tue, 4 Aug 2026 21:28:41 +0800 Subject: [PATCH 110/135] fix(session): retry OpenAI stream server errors --- packages/opencode/src/provider/error.ts | 9 ++++++- packages/opencode/test/provider/error.test.ts | 24 ++++++++++++++++++- packages/opencode/test/session/retry.test.ts | 24 +++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 49dec3f51..7318d8022 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -134,7 +134,7 @@ export type ParsedStreamError = | { type: "api_error" message: string - isRetryable: false + isRetryable: boolean responseBody: string } @@ -146,6 +146,13 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined if (body.type !== "error") return switch (body?.error?.code) { + case "server_error": + return { + type: "api_error", + message: typeof body?.error?.message === "string" ? body.error.message : "OpenAI server error", + isRetryable: true, + responseBody, + } case "context_length_exceeded": return { type: "context_overflow", diff --git a/packages/opencode/test/provider/error.test.ts b/packages/opencode/test/provider/error.test.ts index ed10964d0..7716453e9 100644 --- a/packages/opencode/test/provider/error.test.ts +++ b/packages/opencode/test/provider/error.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { APICallError } from "ai" -import { parseAPICallError } from "../../src/provider/error" +import { parseAPICallError, parseStreamError } from "../../src/provider/error" import { ProviderID } from "../../src/provider/schema" const xiaomi = ProviderID.make("xiaomi") @@ -158,3 +158,25 @@ describe("provider error message", () => { expect(parsed.message).toBe("Insufficient account balance") }) }) + +describe("provider stream error", () => { + test("marks OpenAI server_error events as retryable", () => { + const input = { + type: "error", + sequence_number: 3, + error: { + type: "server_error", + code: "server_error", + message: "An error occurred while processing your request. You can retry your request.", + param: null, + }, + } + + expect(parseStreamError(input)).toStrictEqual({ + type: "api_error", + message: input.error.message, + isRetryable: true, + responseBody: JSON.stringify(input), + }) + }) +}) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 290f1482c..9ed7f2646 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -124,6 +124,30 @@ describe("session.retry.delay", () => { }) describe("session.retry.retryable", () => { + test("retries OpenAI server_error stream events", () => { + const input = { + type: "error", + sequence_number: 3, + error: { + type: "server_error", + code: "server_error", + message: "An error occurred while processing your request. You can retry your request.", + param: null, + }, + } + const error = MessageV2.fromError(input, { providerID }) + + expect(error).toStrictEqual({ + name: "APIError", + data: { + message: input.error.message, + isRetryable: true, + responseBody: JSON.stringify(input), + }, + }) + expect(SessionRetry.retryable(error)).toBe(input.error.message) + }) + test("maps too_many_requests json messages", () => { const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } })) expect(SessionRetry.retryable(error)).toBe("Too Many Requests") From 9e5bc0421d87992c290a34c4b75b675d34a850c6 Mon Sep 17 00:00:00 2001 From: yanyihan Date: Wed, 5 Aug 2026 12:13:36 +0800 Subject: [PATCH 111/135] feat(tui): default to minimal visuals --- docs/compose/spec/tui-quiet-mode.md | 43 +++++++++++++ packages/opencode/src/cli/cmd/tui/app.tsx | 17 ++++++ .../cmd/tui/component/background-image.tsx | 11 +++- .../src/cli/cmd/tui/component/logo.tsx | 47 ++++++++++---- .../cli/cmd/tui/component/prompt/index.tsx | 6 +- .../src/cli/cmd/tui/component/spinner.tsx | 6 +- .../cmd/tui/component/starry-background.tsx | 61 ++++++++++++------- .../src/cli/cmd/tui/component/task-item.tsx | 6 +- .../src/cli/cmd/tui/context/visual.ts | 23 +++++++ packages/opencode/src/cli/cmd/tui/i18n/en.ts | 2 + packages/opencode/src/cli/cmd/tui/i18n/es.ts | 2 + packages/opencode/src/cli/cmd/tui/i18n/fr.ts | 2 + packages/opencode/src/cli/cmd/tui/i18n/ja.ts | 2 + packages/opencode/src/cli/cmd/tui/i18n/ru.ts | 2 + packages/opencode/src/cli/cmd/tui/i18n/zh.ts | 2 + packages/opencode/src/cli/cmd/tui/i18n/zht.ts | 2 + .../opencode/src/cli/cmd/tui/routes/home.tsx | 15 +++-- .../src/cli/cmd/tui/routes/session/index.tsx | 2 +- .../opencode/test/cli/tui/visual-mode.test.ts | 20 ++++++ 19 files changed, 224 insertions(+), 47 deletions(-) create mode 100644 docs/compose/spec/tui-quiet-mode.md create mode 100644 packages/opencode/src/cli/cmd/tui/context/visual.ts create mode 100644 packages/opencode/test/cli/tui/visual-mode.test.ts diff --git a/docs/compose/spec/tui-quiet-mode.md b/docs/compose/spec/tui-quiet-mode.md new file mode 100644 index 000000000..913795e00 --- /dev/null +++ b/docs/compose/spec/tui-quiet-mode.md @@ -0,0 +1,43 @@ +--- +feature: tui-quiet-mode +status: in-progress +updated: 2026-08-05 +branch: feature/tui-quiet-mode +commits: +--- + +# TUI Quiet Mode + +## Report + +## [S1] Problem + +The current vivid presentation redraws the home screen for stars, meteors, and logo sweeps and uses elaborate animated progress indicators. The persisted "Disable animations" option only stops some shared spinners, so it cannot represent a quiet default visual style or reliably stop high-frequency cosmetic refreshes. + +## [S2] Design + +Add an independent KV-backed `visual_mode` preference with `minimal` and `vivid` values. It is switched from the command palette, persists across launches, and defaults to `minimal`. The existing `animations_enabled` preference remains a separate accessibility and performance override. + +In `minimal` mode: + +- The default home background is empty: no star field and no meteors. A user-selected static background image remains visible. +- The home logo does not start automatic sweep or interaction animation timers. +- Prompt busy state uses a compact static status bar derived from the original opencode-style indicator, with no UFO glyph or timer. +- In-progress tasks, workflows, and agents use stable status glyphs rather than spinners. + +In `vivid` mode, current visuals remain available. When animations are also disabled, vivid visuals become static: the star field may remain, but twinkling, meteors, logo motion, and animated progress indicators stop. Low-frequency functional updates such as home tip rotation and retry countdowns remain active in every combination. Streaming message updates and streaming-only telemetry may continue to redraw while model output is arriving. + +The implementation must use the existing theme colors, dimensions, and layout; this is an existing-codebase motion change, not a new visual language. + +## [S3] Out of Scope + +- Adding a CLI startup flag, a `tui.json` setting, or changing the default value of the existing animation preference. +- Disabling bounded interaction feedback, home tip rotation, retry behavior, autocomplete polling, or other functional timers. +- Redesigning the home layout, logo artwork, theme, or sidebar structure. + +## Tasks + +- [ ] T1: Add the persisted visual mode command — acceptance: the command palette switches between `minimal` and `vivid`, persists the choice, and an unset value resolves to `minimal` (covers: S2) +- [ ] T2: Apply visual and animation preferences to passive home motion — acceptance: minimal mode has no default celestial background or logo motion; vivid mode preserves current visuals; disabling animations leaves vivid visuals static and preserves functional tip rotation (covers: S2; depends: T1) +- [ ] T3: Stabilize every in-progress indicator — acceptance: prompt, task, workflow, and agent running states render fixed-width static markers unless both vivid mode and animations are enabled (covers: S2; depends: T1) +- [ ] T4: Add focused regression coverage and verify TUI behavior — acceptance: tests cover preference resolution and relevant package tests and typecheck pass (covers: S2; depends: T1, T2, T3) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 6416f1941..4f3a5b236 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -56,6 +56,7 @@ import { Session as SessionApi } from "@/session" import { orchestratorDir } from "@/global" import { TuiEvent } from "./event" import { KVProvider, useKV } from "./context/kv" +import { resolveVisualMode } from "./context/visual" import { LanguageProvider, UiI18nBridge, useLanguage } from "./context/language" import type { Locale } from "./i18n/locales" import { LOCALES } from "./i18n/locales" @@ -919,6 +920,22 @@ function App(props: { onSnapshot?: () => Promise }) { }, category: "system", }, + { + title: t( + resolveVisualMode(kv.get("visual_mode", "minimal")) === "minimal" + ? "tui.command.visual_mode.vivid" + : "tui.command.visual_mode.minimal", + ), + value: "app.toggle.visual_mode", + category: "system", + onSelect: (dialog) => { + kv.set( + "visual_mode", + resolveVisualMode(kv.get("visual_mode", "minimal")) === "minimal" ? "vivid" : "minimal", + ) + dialog.clear() + }, + }, { title: t("tui.command.theme.switch_mode.to_dark"), value: "theme.switch_mode.dark", diff --git a/packages/opencode/src/cli/cmd/tui/component/background-image.tsx b/packages/opencode/src/cli/cmd/tui/component/background-image.tsx index 428bdeb57..3cc0c99d7 100644 --- a/packages/opencode/src/cli/cmd/tui/component/background-image.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/background-image.tsx @@ -7,6 +7,7 @@ import { PNG } from "pngjs" import jpeg from "jpeg-js" import path from "path" import { allocImageId, detectImageProtocol, kittyClear, kittyDisplay } from "../util/image-protocol" +import { useVisualMode } from "../context/visual" const HALF_BLOCK = "▀" const PROTOCOL = detectImageProtocol() @@ -99,6 +100,7 @@ function BackgroundImageKitty(props: { path: string }) { } function BackgroundImageHalfBlock(props: { path: string }) { + const visual = useVisualMode() const dimensions = useTerminalDimensions() const { theme } = useTheme() const [pixels] = createResource( @@ -141,7 +143,14 @@ function BackgroundImageHalfBlock(props: { path: string }) { }) return ( - }> + + + + } + > diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx index ca8a29b6c..9a406aece 100644 --- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -1,5 +1,5 @@ import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core" -import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js" +import { For, createEffect, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js" import { useTheme, tint } from "@tui/context/theme" import * as Sound from "@tui/util/sound" import { go, logo } from "@/cli/logo" @@ -576,7 +576,7 @@ function buildIdleState(t: number, ctx: LogoContext): IdleState { return { cfg, reach, rings, active } } -export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean; sweep?: boolean } = {}) { +export function Logo(props: { shape?: LogoShape; ink?: RGBA; animated?: boolean; idle?: boolean; sweep?: boolean } = {}) { const ctx = props.shape ? build(props.shape) : DEFAULT const { theme } = useTheme() const [rings, setRings] = createSignal([]) @@ -638,8 +638,7 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean; swe timer = setInterval(tick, 16) } - onCleanup(() => { - stop() + const stopSweep = () => { if (sweepStart) { clearTimeout(sweepStart) sweepStart = undefined @@ -648,21 +647,46 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean; swe clearInterval(sweepTimer) sweepTimer = undefined } + setSweep(undefined) + } + + createEffect(() => { + if (!props.sweep) { + stopSweep() + return + } + if (sweepStart || sweepTimer) return + sweepStart = setTimeout(() => { + sweepStart = undefined + if (!props.sweep) return + fireSweep() + sweepTimer = setInterval(fireSweep, SWEEP_INTERVAL) + }, 1500) + }) + + createEffect(() => { + if (props.animated !== false) return + setRings([]) + setHold(undefined) + setRelease(undefined) + setGlow(undefined) + stop() + hum = false + Sound.dispose() + }) + + onCleanup(() => { + stop() + stopSweep() hum = false Sound.dispose() }) onMount(() => { - if (props.idle) { + if (props.idle && props.animated !== false) { setNow(performance.now()) start() } - if (props.sweep) { - sweepStart = setTimeout(() => { - fireSweep() - sweepTimer = setInterval(fireSweep, SWEEP_INTERVAL) - }, 1500) - } }) const hit = (x: number, y: number) => { @@ -883,6 +907,7 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean; swe } const mouse = (evt: MouseEvent) => { + if (props.animated === false) return if (!box) return if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) { const x = evt.x - box.x diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 4ac37a958..e79dad5fd 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -43,6 +43,7 @@ import { DialogPrompt } from "../../ui/dialog-prompt" import { useToast } from "../../ui/toast" import { createPress } from "../../ui/press" import { useKV } from "../../context/kv" +import { useVisualMode } from "../../context/visual" import { createFadeIn } from "../../util/signal" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" @@ -135,7 +136,8 @@ export function Prompt(props: PromptProps) { const renderer = useRenderer() const { theme, syntax } = useTheme() const kv = useKV() - const animationsEnabled = createMemo(() => kv.get("animations_enabled", true)) + const visual = useVisualMode() + const animationsEnabled = visual.motion const voiceEnabled = createMemo(() => kv.get("voice_enabled", false)) const voiceSendEnabled = createMemo(() => kv.get("voice_send_command", false)) const voiceControlEnabled = createMemo(() => kv.get("voice_control_enabled", false)) @@ -1898,7 +1900,7 @@ export function Prompt(props: PromptProps) { > - [⋯]}> + ⋯}> diff --git a/packages/opencode/src/cli/cmd/tui/component/spinner.tsx b/packages/opencode/src/cli/cmd/tui/component/spinner.tsx index 8dc545550..8ca2198de 100644 --- a/packages/opencode/src/cli/cmd/tui/component/spinner.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/spinner.tsx @@ -1,6 +1,6 @@ import { Show } from "solid-js" import { useTheme } from "../context/theme" -import { useKV } from "../context/kv" +import { useVisualMode } from "../context/visual" import type { JSX } from "@opentui/solid" import type { RGBA } from "@opentui/core" import "opentui-spinner/solid" @@ -9,10 +9,10 @@ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", " export function Spinner(props: { children?: JSX.Element; color?: RGBA }) { const { theme } = useTheme() - const kv = useKV() + const visual = useVisualMode() const color = () => props.color ?? theme.textMuted return ( - ⋯ {props.children}}> + ⋯ {props.children}}> diff --git a/packages/opencode/src/cli/cmd/tui/component/starry-background.tsx b/packages/opencode/src/cli/cmd/tui/component/starry-background.tsx index 730f31e91..4e39daa8c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/starry-background.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/starry-background.tsx @@ -66,7 +66,7 @@ function brailleBit(col: number, row: number): number { return row === 3 ? 7 : 3 + row } -export function StarryBackground(props: { meteor?: () => boolean } = {}) { +export function StarryBackground(props: { animated?: () => boolean; meteor?: () => boolean } = {}) { const { theme } = useTheme() const [field, setField] = createSignal({ grid: [], brightness: [] }) const [size, setSize] = createSignal({ w: 80, h: 24 }) @@ -77,7 +77,7 @@ export function StarryBackground(props: { meteor?: () => boolean } = {}) { let frameTimer: ReturnType | undefined let box: BoxRenderable | undefined let text: TextRenderable | undefined - let mounted = false + const [mounted, setMounted] = createSignal(false) const sync = () => { if (!box) return @@ -88,12 +88,26 @@ export function StarryBackground(props: { meteor?: () => boolean } = {}) { setField(generateField(next.w, next.h)) } - onMount(() => { - mounted = true - sync() - box?.on("resize", sync) + const stopMotion = () => { + if (timer) { + clearInterval(timer) + timer = undefined + } + if (meteorTimer) { + clearInterval(meteorTimer) + meteorTimer = undefined + } + if (frameTimer) { + clearInterval(frameTimer) + frameTimer = undefined + } + setMeteor(undefined) + } + + const startMotion = () => { + if (timer || meteorTimer) return timer = setInterval(() => { - if (!mounted) return + if (!mounted()) return const { w, h } = size() setField((prev) => { const next = { grid: prev.grid, brightness: [...prev.brightness.map((r) => [...r])] } @@ -112,7 +126,7 @@ export function StarryBackground(props: { meteor?: () => boolean } = {}) { }) }, TWINKLE_INTERVAL) meteorTimer = setInterval(() => { - if (!mounted) return + if (!mounted()) return if (props.meteor && !props.meteor()) return const { w, h } = size() const startY = Math.floor(Math.random() * 2) @@ -125,7 +139,7 @@ export function StarryBackground(props: { meteor?: () => boolean } = {}) { }) if (frameTimer) clearInterval(frameTimer) frameTimer = setInterval(() => { - if (!mounted) { + if (!mounted()) { if (frameTimer) clearInterval(frameTimer) frameTimer = undefined return @@ -141,23 +155,26 @@ export function StarryBackground(props: { meteor?: () => boolean } = {}) { } }, METEOR_FRAME_INTERVAL) }, METEOR_INTERVAL) + } + + createEffect(() => { + if (!mounted() || (props.animated && !props.animated())) { + stopMotion() + return + } + startMotion() + }) + + onMount(() => { + sync() + box?.on("resize", sync) + setMounted(true) }) onCleanup(() => { - mounted = false + setMounted(false) box?.off("resize", sync) - if (timer) { - clearInterval(timer) - timer = undefined - } - if (meteorTimer) { - clearInterval(meteorTimer) - meteorTimer = undefined - } - if (frameTimer) { - clearInterval(frameTimer) - frameTimer = undefined - } + stopMotion() }) const isDark = createMemo(() => { diff --git a/packages/opencode/src/cli/cmd/tui/component/task-item.tsx b/packages/opencode/src/cli/cmd/tui/component/task-item.tsx index f1daa8b50..bd1d0f071 100644 --- a/packages/opencode/src/cli/cmd/tui/component/task-item.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/task-item.tsx @@ -1,6 +1,6 @@ import { Show } from "solid-js" import { useTheme } from "../context/theme" -import { useKV } from "../context/kv" +import { useVisualMode } from "../context/visual" import "opentui-spinner/solid" // Inlined (not the shared ) so the animated glyph occupies exactly @@ -18,7 +18,7 @@ export interface TaskItemProps { export function TaskItem(props: TaskItemProps) { const { theme } = useTheme() - const kv = useKV() + const visual = useVisualMode() const running = () => props.status === "in_progress" const glyph = props.status === "done" @@ -47,7 +47,7 @@ export function TaskItem(props: TaskItemProps) { [ •} > diff --git a/packages/opencode/src/cli/cmd/tui/context/visual.ts b/packages/opencode/src/cli/cmd/tui/context/visual.ts new file mode 100644 index 000000000..cd408f867 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/visual.ts @@ -0,0 +1,23 @@ +import { createMemo } from "solid-js" +import { useKV } from "./kv" + +export type VisualMode = "minimal" | "vivid" + +export function resolveVisualMode(value: unknown): VisualMode { + return value === "vivid" ? "vivid" : "minimal" +} + +export function visualMotionEnabled(mode: VisualMode, animationsEnabled: boolean) { + return mode === "vivid" && animationsEnabled +} + +export function useVisualMode() { + const kv = useKV() + const mode = createMemo(() => resolveVisualMode(kv.get("visual_mode", "minimal"))) + const animationsEnabled = createMemo(() => kv.get("animations_enabled", true) === true) + return { + mode, + vivid: createMemo(() => mode() === "vivid"), + motion: createMemo(() => visualMotionEnabled(mode(), animationsEnabled())), + } +} diff --git a/packages/opencode/src/cli/cmd/tui/i18n/en.ts b/packages/opencode/src/cli/cmd/tui/i18n/en.ts index dd3aeb4a7..8cd53b516 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/en.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/en.ts @@ -329,6 +329,8 @@ export const dict: Record = { "tui.dialog.image.import.success": "Background image imported", "tui.dialog.image.none": "None (use starry background)", "tui.command.logo.switch.title": "Switch logo design", + "tui.command.visual_mode.minimal": "Use minimal visuals", + "tui.command.visual_mode.vivid": "Use vivid visuals", "tui.dialog.logo.title": "Logo design", "tui.dialog.logo.option.classic": "Classic (bold)", "tui.dialog.logo.option.thin": "Thin (half-block)", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/es.ts b/packages/opencode/src/cli/cmd/tui/i18n/es.ts index bfe5b2979..50b87ca3b 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/es.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/es.ts @@ -372,6 +372,8 @@ export const dict = { "tui.command.opencode.status.title": "Ver estado", "tui.command.theme.switch.title": "Cambiar tema", "tui.command.logo.switch.title": "Cambiar diseño de logo", + "tui.command.visual_mode.minimal": "Usar visuales mínimos", + "tui.command.visual_mode.vivid": "Usar visuales intensos", "tui.dialog.logo.title": "Diseño de logo", "tui.dialog.logo.option.classic": "Clásico (negrita)", "tui.dialog.logo.option.thin": "Fino (medio bloque)", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/fr.ts b/packages/opencode/src/cli/cmd/tui/i18n/fr.ts index a8f345a4b..1a91c334e 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/fr.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/fr.ts @@ -360,6 +360,8 @@ export const dict = { "tui.command.opencode.status.title": "Voir l'état", "tui.command.theme.switch.title": "Changer de thème", "tui.command.logo.switch.title": "Changer le design du logo", + "tui.command.visual_mode.minimal": "Utiliser les visuels minimalistes", + "tui.command.visual_mode.vivid": "Utiliser les visuels riches", "tui.dialog.logo.title": "Design du logo", "tui.dialog.logo.option.classic": "Classique (gras)", "tui.dialog.logo.option.thin": "Fin (demi-bloc)", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/ja.ts b/packages/opencode/src/cli/cmd/tui/i18n/ja.ts index 5836de70e..cebc6ae39 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/ja.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/ja.ts @@ -304,6 +304,8 @@ export const dict = { "tui.command.opencode.status.title": "ステータスを表示", "tui.command.theme.switch.title": "テーマを切り替え", "tui.command.logo.switch.title": "ロゴデザインを切り替え", + "tui.command.visual_mode.minimal": "ミニマル表示を使用", + "tui.command.visual_mode.vivid": "リッチ表示を使用", "tui.dialog.logo.title": "ロゴデザイン", "tui.dialog.logo.option.classic": "クラシック(太字)", "tui.dialog.logo.option.thin": "細字(ハーフブロック)", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/ru.ts b/packages/opencode/src/cli/cmd/tui/i18n/ru.ts index 106faacff..53342c181 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/ru.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/ru.ts @@ -375,6 +375,8 @@ export const dict = { "tui.command.opencode.status.title": "Посмотреть статус", "tui.command.theme.switch.title": "Сменить тему", "tui.command.logo.switch.title": "Сменить дизайн логотипа", + "tui.command.visual_mode.minimal": "Использовать минимальное оформление", + "tui.command.visual_mode.vivid": "Использовать яркое оформление", "tui.dialog.logo.title": "Дизайн логотипа", "tui.dialog.logo.option.classic": "Классический (жирный)", "tui.dialog.logo.option.thin": "Тонкий (полублок)", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/zh.ts b/packages/opencode/src/cli/cmd/tui/i18n/zh.ts index e0fa584fc..5fa913aca 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/zh.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/zh.ts @@ -352,6 +352,8 @@ export const dict = { "tui.dialog.image.import.success": "背景图片已导入", "tui.dialog.image.none": "无(使用星空背景)", "tui.command.logo.switch.title": "切换 Logo 样式", + "tui.command.visual_mode.minimal": "使用极简视觉", + "tui.command.visual_mode.vivid": "使用丰富视觉", "tui.dialog.logo.title": "Logo 样式", "tui.dialog.logo.option.classic": "经典(粗体)", "tui.dialog.logo.option.thin": "纤细(半块)", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/zht.ts b/packages/opencode/src/cli/cmd/tui/i18n/zht.ts index fd81c78a9..61ff7a343 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/zht.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/zht.ts @@ -352,6 +352,8 @@ export const dict = { "tui.dialog.image.import.success": "背景圖片已匯入", "tui.dialog.image.none": "無(使用星空背景)", "tui.command.logo.switch.title": "切換 Logo 樣式", + "tui.command.visual_mode.minimal": "使用極簡視覺", + "tui.command.visual_mode.vivid": "使用豐富視覺", "tui.dialog.logo.title": "Logo 樣式", "tui.dialog.logo.option.classic": "經典(粗體)", "tui.dialog.logo.option.thin": "纖細(半塊)", diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 74f618ffa..ee86fee98 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -17,6 +17,7 @@ import { useLanguage } from "@tui/context/language" import { TuiPluginRuntime } from "../plugin" import { Global } from "@/global" import { isPlainTerminal } from "../util/terminal" +import { useVisualMode } from "../context/visual" let once = false @@ -31,6 +32,7 @@ export function Home() { const kv = useKV() const t = useLanguage().t const plainTerminal = isPlainTerminal() + const visual = useVisualMode() const bgImagePath = createMemo(() => { const filename = kv.get("background_image") if (!filename || typeof filename !== "string") return undefined @@ -40,8 +42,6 @@ export function Home() { const key = kv.get("logo_design") return typeof key === "string" && key in logos ? (key as LogoKey) : "thin" }) - // 所有 logo 变体(含默认的 thin 纤细半块)都显示流星特效。 - const showMeteor = () => true const placeholder = { get normal() { return [ @@ -83,7 +83,14 @@ export function Home() { return ( <> - }> + + + + } + > {(p) => } @@ -96,7 +103,7 @@ export function Home() { fallback={ - {(k) => } + {(k) => } } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index ee22a523c..788132fc2 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -2569,7 +2569,7 @@ function WorkflowPanel(props: { > ⚡}> - + {props.name} diff --git a/packages/opencode/test/cli/tui/visual-mode.test.ts b/packages/opencode/test/cli/tui/visual-mode.test.ts new file mode 100644 index 000000000..1df388e48 --- /dev/null +++ b/packages/opencode/test/cli/tui/visual-mode.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test" +import { resolveVisualMode, visualMotionEnabled } from "@/cli/cmd/tui/context/visual" + +describe("TUI visual mode", () => { + test("defaults missing and invalid state to minimal", () => { + expect(resolveVisualMode(undefined)).toBe("minimal") + expect(resolveVisualMode("unknown")).toBe("minimal") + }) + + test("preserves the vivid preference", () => { + expect(resolveVisualMode("vivid")).toBe("vivid") + }) + + test("only enables cosmetic motion for vivid visuals with animations enabled", () => { + expect(visualMotionEnabled("minimal", true)).toBe(false) + expect(visualMotionEnabled("minimal", false)).toBe(false) + expect(visualMotionEnabled("vivid", false)).toBe(false) + expect(visualMotionEnabled("vivid", true)).toBe(true) + }) +}) From d70fb5a59cb3fbf994a0d71cbbce8b1e52c22382 Mon Sep 17 00:00:00 2001 From: yanyihan Date: Wed, 5 Aug 2026 12:20:11 +0800 Subject: [PATCH 112/135] fix(tui): honor quiet mode for idle logos --- .../src/cli/cmd/tui/component/logo.tsx | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx index 9a406aece..0c8549ae5 100644 --- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -1,6 +1,7 @@ import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core" -import { For, createEffect, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js" +import { For, createEffect, createMemo, createSignal, onCleanup, type JSX } from "solid-js" import { useTheme, tint } from "@tui/context/theme" +import { useVisualMode } from "@tui/context/visual" import * as Sound from "@tui/util/sound" import { go, logo } from "@/cli/logo" @@ -651,7 +652,7 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; animated?: boolean; } createEffect(() => { - if (!props.sweep) { + if (props.animated === false || !props.sweep) { stopSweep() return } @@ -665,7 +666,14 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; animated?: boolean; }) createEffect(() => { - if (props.animated !== false) return + if (props.animated !== false) { + if (props.idle) { + setNow(performance.now()) + start() + } + return + } + stopSweep() setRings([]) setHold(undefined) setRelease(undefined) @@ -682,13 +690,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; animated?: boolean; Sound.dispose() }) - onMount(() => { - if (props.idle && props.animated !== false) { - setNow(performance.now()) - start() - } - }) - const hit = (x: number, y: number) => { const char = ctx.FULL[y]?.[x] return char !== undefined && char !== " " @@ -981,6 +982,7 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; animated?: boolean; export function GoLogo() { const { theme } = useTheme() + const visual = useVisualMode() const base = tint(theme.background, theme.text, 0.62) - return + return } From 9bb6559fd05c0b4cf76c96ffe963bf246331b39b Mon Sep 17 00:00:00 2001 From: yanyihan Date: Wed, 5 Aug 2026 12:22:19 +0800 Subject: [PATCH 113/135] docs(compose): finalize TUI quiet mode --- docs/compose/spec/tui-quiet-mode.md | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/compose/spec/tui-quiet-mode.md b/docs/compose/spec/tui-quiet-mode.md index 913795e00..80fd83cc2 100644 --- a/docs/compose/spec/tui-quiet-mode.md +++ b/docs/compose/spec/tui-quiet-mode.md @@ -1,15 +1,27 @@ --- feature: tui-quiet-mode -status: in-progress +status: delivered updated: 2026-08-05 branch: feature/tui-quiet-mode -commits: +commits: 91dc9d14c263d76f7e843eaf6cce3f112ee1ddda..d70fb5a5 --- # TUI Quiet Mode ## Report +**What was built** — Added a persisted `minimal` / `vivid` visual mode with `minimal` as the default and a command-palette switch. Minimal mode removes the default celestial background and uses stable progress markers; vivid mode preserves the existing presentation. The separate animation preference now stops high-frequency stars, meteors, Logo motion, and spinners without disabling low-frequency functional updates. + +Logo, star field, prompt, task, workflow, and agent states share the same `vivid && animations_enabled` motion contract. Runtime preference changes clean up and restart eligible timers without requiring a TUI restart. + +**Verification** — `bun test test/cli/tui/visual-mode.test.ts` passed 3 tests; `bun test test/cli/tui test/cli/cmd/tui` passed 265 tests and 725 assertions; `bun typecheck` passed; `git diff --check` passed. An isolated development TUI confirmed the default minimal screen is stable, command switching restores vivid stars, vivid mode with animations disabled retains a static star field, and both preferences persist to KV state. + +**Journey log** + +- Kept home tip rotation because it is a low-frequency functional update, not decorative high-frequency motion. +- Split visual style from animation accessibility after deciding minimal should become the product default. +- A targeted review found and closed an idle Logo timer outside the home route. + ## [S1] Problem The current vivid presentation redraws the home screen for stars, meteors, and logo sweeps and uses elaborate animated progress indicators. The persisted "Disable animations" option only stops some shared spinners, so it cannot represent a quiet default visual style or reliably stop high-frequency cosmetic refreshes. @@ -37,7 +49,7 @@ The implementation must use the existing theme colors, dimensions, and layout; t ## Tasks -- [ ] T1: Add the persisted visual mode command — acceptance: the command palette switches between `minimal` and `vivid`, persists the choice, and an unset value resolves to `minimal` (covers: S2) -- [ ] T2: Apply visual and animation preferences to passive home motion — acceptance: minimal mode has no default celestial background or logo motion; vivid mode preserves current visuals; disabling animations leaves vivid visuals static and preserves functional tip rotation (covers: S2; depends: T1) -- [ ] T3: Stabilize every in-progress indicator — acceptance: prompt, task, workflow, and agent running states render fixed-width static markers unless both vivid mode and animations are enabled (covers: S2; depends: T1) -- [ ] T4: Add focused regression coverage and verify TUI behavior — acceptance: tests cover preference resolution and relevant package tests and typecheck pass (covers: S2; depends: T1, T2, T3) +- [x] T1: Add the persisted visual mode command — acceptance: the command palette switches between `minimal` and `vivid`, persists the choice, and an unset value resolves to `minimal` (covers: S2) +- [x] T2: Apply visual and animation preferences to passive home motion — acceptance: minimal mode has no default celestial background or logo motion; vivid mode preserves current visuals; disabling animations leaves vivid visuals static and preserves functional tip rotation (covers: S2; depends: T1) +- [x] T3: Stabilize every in-progress indicator — acceptance: prompt, task, workflow, and agent running states render fixed-width static markers unless both vivid mode and animations are enabled (covers: S2; depends: T1) +- [x] T4: Add focused regression coverage and verify TUI behavior — acceptance: tests cover preference resolution and relevant package tests and typecheck pass (covers: S2; depends: T1, T2, T3) From 89068dd298681533b259518d12ef0a9a846b97e7 Mon Sep 17 00:00:00 2001 From: yanyihan Date: Wed, 5 Aug 2026 12:32:06 +0800 Subject: [PATCH 114/135] fix(tui): keep vivid visuals as default --- docs/compose/spec/tui-quiet-mode.md | 16 ++++++++-------- packages/opencode/src/cli/cmd/tui/app.tsx | 4 ++-- .../opencode/src/cli/cmd/tui/context/visual.ts | 4 ++-- .../opencode/test/cli/tui/visual-mode.test.ts | 10 +++++----- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/compose/spec/tui-quiet-mode.md b/docs/compose/spec/tui-quiet-mode.md index 80fd83cc2..c076d75e4 100644 --- a/docs/compose/spec/tui-quiet-mode.md +++ b/docs/compose/spec/tui-quiet-mode.md @@ -1,25 +1,25 @@ --- feature: tui-quiet-mode -status: delivered +status: in-progress updated: 2026-08-05 branch: feature/tui-quiet-mode -commits: 91dc9d14c263d76f7e843eaf6cce3f112ee1ddda..d70fb5a5 +commits: --- # TUI Quiet Mode ## Report -**What was built** — Added a persisted `minimal` / `vivid` visual mode with `minimal` as the default and a command-palette switch. Minimal mode removes the default celestial background and uses stable progress markers; vivid mode preserves the existing presentation. The separate animation preference now stops high-frequency stars, meteors, Logo motion, and spinners without disabling low-frequency functional updates. +**What was built** — Added a persisted `minimal` / `vivid` visual mode with `vivid` as the default and a command-palette switch. Minimal mode removes the default celestial background and uses stable progress markers; vivid mode preserves the existing presentation. The separate animation preference now stops high-frequency stars, meteors, Logo motion, and spinners without disabling low-frequency functional updates. Logo, star field, prompt, task, workflow, and agent states share the same `vivid && animations_enabled` motion contract. Runtime preference changes clean up and restart eligible timers without requiring a TUI restart. -**Verification** — `bun test test/cli/tui/visual-mode.test.ts` passed 3 tests; `bun test test/cli/tui test/cli/cmd/tui` passed 265 tests and 725 assertions; `bun typecheck` passed; `git diff --check` passed. An isolated development TUI confirmed the default minimal screen is stable, command switching restores vivid stars, vivid mode with animations disabled retains a static star field, and both preferences persist to KV state. +**Verification** — Pending amendment verification. **Journey log** - Kept home tip rotation because it is a low-frequency functional update, not decorative high-frequency motion. -- Split visual style from animation accessibility after deciding minimal should become the product default. +- Split visual style from animation accessibility so either presentation can use the independent animation override. - A targeted review found and closed an idle Logo timer outside the home route. ## [S1] Problem @@ -28,7 +28,7 @@ The current vivid presentation redraws the home screen for stars, meteors, and l ## [S2] Design -Add an independent KV-backed `visual_mode` preference with `minimal` and `vivid` values. It is switched from the command palette, persists across launches, and defaults to `minimal`. The existing `animations_enabled` preference remains a separate accessibility and performance override. +Add an independent KV-backed `visual_mode` preference with `minimal` and `vivid` values. It is switched from the command palette, persists across launches, and defaults to `vivid`. The existing `animations_enabled` preference remains a separate accessibility and performance override. In `minimal` mode: @@ -49,7 +49,7 @@ The implementation must use the existing theme colors, dimensions, and layout; t ## Tasks -- [x] T1: Add the persisted visual mode command — acceptance: the command palette switches between `minimal` and `vivid`, persists the choice, and an unset value resolves to `minimal` (covers: S2) +- [ ] T1: Add the persisted visual mode command — acceptance: the command palette switches between `minimal` and `vivid`, persists the choice, and an unset value resolves to `vivid` (covers: S2) - [x] T2: Apply visual and animation preferences to passive home motion — acceptance: minimal mode has no default celestial background or logo motion; vivid mode preserves current visuals; disabling animations leaves vivid visuals static and preserves functional tip rotation (covers: S2; depends: T1) - [x] T3: Stabilize every in-progress indicator — acceptance: prompt, task, workflow, and agent running states render fixed-width static markers unless both vivid mode and animations are enabled (covers: S2; depends: T1) -- [x] T4: Add focused regression coverage and verify TUI behavior — acceptance: tests cover preference resolution and relevant package tests and typecheck pass (covers: S2; depends: T1, T2, T3) +- [ ] T4: Add focused regression coverage and verify TUI behavior — acceptance: tests cover preference resolution and relevant package tests and typecheck pass (covers: S2; depends: T1, T2, T3) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 4f3a5b236..41c8ad820 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -922,7 +922,7 @@ function App(props: { onSnapshot?: () => Promise }) { }, { title: t( - resolveVisualMode(kv.get("visual_mode", "minimal")) === "minimal" + resolveVisualMode(kv.get("visual_mode", "vivid")) === "minimal" ? "tui.command.visual_mode.vivid" : "tui.command.visual_mode.minimal", ), @@ -931,7 +931,7 @@ function App(props: { onSnapshot?: () => Promise }) { onSelect: (dialog) => { kv.set( "visual_mode", - resolveVisualMode(kv.get("visual_mode", "minimal")) === "minimal" ? "vivid" : "minimal", + resolveVisualMode(kv.get("visual_mode", "vivid")) === "minimal" ? "vivid" : "minimal", ) dialog.clear() }, diff --git a/packages/opencode/src/cli/cmd/tui/context/visual.ts b/packages/opencode/src/cli/cmd/tui/context/visual.ts index cd408f867..3d58529fb 100644 --- a/packages/opencode/src/cli/cmd/tui/context/visual.ts +++ b/packages/opencode/src/cli/cmd/tui/context/visual.ts @@ -4,7 +4,7 @@ import { useKV } from "./kv" export type VisualMode = "minimal" | "vivid" export function resolveVisualMode(value: unknown): VisualMode { - return value === "vivid" ? "vivid" : "minimal" + return value === "minimal" ? "minimal" : "vivid" } export function visualMotionEnabled(mode: VisualMode, animationsEnabled: boolean) { @@ -13,7 +13,7 @@ export function visualMotionEnabled(mode: VisualMode, animationsEnabled: boolean export function useVisualMode() { const kv = useKV() - const mode = createMemo(() => resolveVisualMode(kv.get("visual_mode", "minimal"))) + const mode = createMemo(() => resolveVisualMode(kv.get("visual_mode", "vivid"))) const animationsEnabled = createMemo(() => kv.get("animations_enabled", true) === true) return { mode, diff --git a/packages/opencode/test/cli/tui/visual-mode.test.ts b/packages/opencode/test/cli/tui/visual-mode.test.ts index 1df388e48..dc489ca27 100644 --- a/packages/opencode/test/cli/tui/visual-mode.test.ts +++ b/packages/opencode/test/cli/tui/visual-mode.test.ts @@ -2,13 +2,13 @@ import { describe, expect, test } from "bun:test" import { resolveVisualMode, visualMotionEnabled } from "@/cli/cmd/tui/context/visual" describe("TUI visual mode", () => { - test("defaults missing and invalid state to minimal", () => { - expect(resolveVisualMode(undefined)).toBe("minimal") - expect(resolveVisualMode("unknown")).toBe("minimal") + test("defaults missing and invalid state to vivid", () => { + expect(resolveVisualMode(undefined)).toBe("vivid") + expect(resolveVisualMode("unknown")).toBe("vivid") }) - test("preserves the vivid preference", () => { - expect(resolveVisualMode("vivid")).toBe("vivid") + test("preserves the minimal preference", () => { + expect(resolveVisualMode("minimal")).toBe("minimal") }) test("only enables cosmetic motion for vivid visuals with animations enabled", () => { From 5931f01570f39eb53fa9e55cc75adbe851890a8d Mon Sep 17 00:00:00 2001 From: yanyihan Date: Wed, 5 Aug 2026 12:46:41 +0800 Subject: [PATCH 115/135] feat(tui): add vivid mode slash toggle --- README.md | 7 +++++ README.zh.md | 7 +++++ docs/compose/spec/tui-quiet-mode.md | 5 ++-- packages/opencode/src/cli/cmd/tui/app.tsx | 27 +++++++++++++------ .../src/cli/cmd/tui/context/visual.ts | 4 +++ packages/opencode/src/cli/cmd/tui/i18n/en.ts | 8 ++++-- packages/opencode/src/cli/cmd/tui/i18n/es.ts | 8 ++++-- packages/opencode/src/cli/cmd/tui/i18n/fr.ts | 8 ++++-- packages/opencode/src/cli/cmd/tui/i18n/ja.ts | 8 ++++-- packages/opencode/src/cli/cmd/tui/i18n/ru.ts | 8 ++++-- packages/opencode/src/cli/cmd/tui/i18n/zh.ts | 8 ++++-- packages/opencode/src/cli/cmd/tui/i18n/zht.ts | 8 ++++-- .../builtin/.bundle/mimocode-docs/SKILL.md | 3 ++- .../mimocode-docs/reference/commands.md | 1 + .../opencode/test/cli/tui/visual-mode.test.ts | 8 +++++- 15 files changed, 92 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 8354434b6..1e042bb44 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,13 @@ The first two options remove the corresponding skills from the agent's available +
+Vivid and Minimal visuals + +MiMoCode starts in Vivid mode, with the star field, meteors, logo effects, and animated activity indicators enabled. Run `/vivid` to switch between Vivid and Minimal visuals, or use the **Vivid mode** setting from the `ctrl+p` command palette. Minimal mode removes decorative motion and uses stable activity indicators. The separate **Disable animations** setting can stop high-frequency motion without changing the selected visual mode. + +
+ ### Voice Input Real-time streaming voice input powered by TenVAD and MiMo ASR. Activate with `/voice`, then speak — audio is segmented by pauses and transcribed incrementally into the input. Available for MiMo logged-in users. Requires `sox` (`brew install sox` on macOS, other platforms similar). diff --git a/README.zh.md b/README.zh.md index 72c3b36af..4e1ce71f6 100644 --- a/README.zh.md +++ b/README.zh.md @@ -229,6 +229,13 @@ MiMoCode 打包了以下内置技能: +
+Vivid 与极简视觉 + +MiMoCode 默认使用 Vivid 模式,显示星空、流星、Logo 特效和动态进行中标记。运行 `/vivid` 可在 Vivid 与极简视觉之间切换,也可以在 `ctrl+p` 命令面板中使用 **Vivid 模式** 设置。极简模式会移除装饰性动态效果,并使用稳定的进行中标记。独立的 **禁用动画** 设置可以停止高频动态刷新,而不改变当前选择的视觉模式。 + +
+ ### 语音输入 基于 TenVAD 和 MiMo ASR 的实时流式语音输入。通过 `/voice` 激活,按停顿分片转写,文本逐段追加到输入框。仅对 MiMo 登录用户可用。需要安装 `sox`(macOS 上 `brew install sox`,其他平台类似)。 diff --git a/docs/compose/spec/tui-quiet-mode.md b/docs/compose/spec/tui-quiet-mode.md index c076d75e4..61dcdbea5 100644 --- a/docs/compose/spec/tui-quiet-mode.md +++ b/docs/compose/spec/tui-quiet-mode.md @@ -28,7 +28,7 @@ The current vivid presentation redraws the home screen for stars, meteors, and l ## [S2] Design -Add an independent KV-backed `visual_mode` preference with `minimal` and `vivid` values. It is switched from the command palette, persists across launches, and defaults to `vivid`. The existing `animations_enabled` preference remains a separate accessibility and performance override. +Add an independent KV-backed `visual_mode` preference with `minimal` and `vivid` values. It is switched by the same command from the command palette or `/vivid`, persists across launches, and defaults to `vivid`. The command title, description, and completion toast distinguish the enabled and disabled states in every supported locale. The existing `animations_enabled` preference remains a separate accessibility and performance override. In `minimal` mode: @@ -49,7 +49,8 @@ The implementation must use the existing theme colors, dimensions, and layout; t ## Tasks -- [ ] T1: Add the persisted visual mode command — acceptance: the command palette switches between `minimal` and `vivid`, persists the choice, and an unset value resolves to `vivid` (covers: S2) +- [ ] T1: Add the persisted visual mode command — acceptance: the command palette and `/vivid` map to the same toggle, show localized enabled/disabled state, persist the choice, and an unset value resolves to `vivid` (covers: S2) - [x] T2: Apply visual and animation preferences to passive home motion — acceptance: minimal mode has no default celestial background or logo motion; vivid mode preserves current visuals; disabling animations leaves vivid visuals static and preserves functional tip rotation (covers: S2; depends: T1) - [x] T3: Stabilize every in-progress indicator — acceptance: prompt, task, workflow, and agent running states render fixed-width static markers unless both vivid mode and animations are enabled (covers: S2; depends: T1) - [ ] T4: Add focused regression coverage and verify TUI behavior — acceptance: tests cover preference resolution and relevant package tests and typecheck pass (covers: S2; depends: T1, T2, T3) +- [ ] T5: Document visual mode controls — acceptance: English and Chinese READMEs and the bundled `mimocode-docs` skill describe `/vivid`, the command palette setting, the default, and the independent animation override (covers: S2; depends: T1) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 41c8ad820..62d05d0c1 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -56,7 +56,7 @@ import { Session as SessionApi } from "@/session" import { orchestratorDir } from "@/global" import { TuiEvent } from "./event" import { KVProvider, useKV } from "./context/kv" -import { resolveVisualMode } from "./context/visual" +import { resolveVisualMode, toggleVisualMode } from "./context/visual" import { LanguageProvider, UiI18nBridge, useLanguage } from "./context/language" import type { Locale } from "./i18n/locales" import { LOCALES } from "./i18n/locales" @@ -922,17 +922,28 @@ function App(props: { onSnapshot?: () => Promise }) { }, { title: t( - resolveVisualMode(kv.get("visual_mode", "vivid")) === "minimal" - ? "tui.command.visual_mode.vivid" - : "tui.command.visual_mode.minimal", + resolveVisualMode(kv.get("visual_mode", "vivid")) === "vivid" + ? "tui.command.visual_mode.title_on" + : "tui.command.visual_mode.title_off", + ), + description: t( + resolveVisualMode(kv.get("visual_mode", "vivid")) === "vivid" + ? "tui.command.visual_mode.description_on" + : "tui.command.visual_mode.description_off", ), value: "app.toggle.visual_mode", + slash: { + name: "vivid", + }, category: "system", onSelect: (dialog) => { - kv.set( - "visual_mode", - resolveVisualMode(kv.get("visual_mode", "vivid")) === "minimal" ? "vivid" : "minimal", - ) + const next = toggleVisualMode(kv.get("visual_mode", "vivid")) + kv.set("visual_mode", next) + toast.show({ + message: t(next === "vivid" ? "tui.visual_mode.enabled" : "tui.visual_mode.disabled"), + variant: "info", + duration: 3000, + }) dialog.clear() }, }, diff --git a/packages/opencode/src/cli/cmd/tui/context/visual.ts b/packages/opencode/src/cli/cmd/tui/context/visual.ts index 3d58529fb..0e88b24cd 100644 --- a/packages/opencode/src/cli/cmd/tui/context/visual.ts +++ b/packages/opencode/src/cli/cmd/tui/context/visual.ts @@ -7,6 +7,10 @@ export function resolveVisualMode(value: unknown): VisualMode { return value === "minimal" ? "minimal" : "vivid" } +export function toggleVisualMode(value: unknown): VisualMode { + return resolveVisualMode(value) === "vivid" ? "minimal" : "vivid" +} + export function visualMotionEnabled(mode: VisualMode, animationsEnabled: boolean) { return mode === "vivid" && animationsEnabled } diff --git a/packages/opencode/src/cli/cmd/tui/i18n/en.ts b/packages/opencode/src/cli/cmd/tui/i18n/en.ts index 8cd53b516..db779fea1 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/en.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/en.ts @@ -329,8 +329,12 @@ export const dict: Record = { "tui.dialog.image.import.success": "Background image imported", "tui.dialog.image.none": "None (use starry background)", "tui.command.logo.switch.title": "Switch logo design", - "tui.command.visual_mode.minimal": "Use minimal visuals", - "tui.command.visual_mode.vivid": "Use vivid visuals", + "tui.command.visual_mode.title_on": "Vivid mode: ON — click to use minimal visuals", + "tui.command.visual_mode.title_off": "Vivid mode: OFF — click to use vivid visuals", + "tui.command.visual_mode.description_on": "Stars, meteors, logo effects, and animated activity indicators", + "tui.command.visual_mode.description_off": "Minimal visuals with stable activity indicators", + "tui.visual_mode.enabled": "Vivid mode enabled", + "tui.visual_mode.disabled": "Vivid mode disabled — using minimal visuals", "tui.dialog.logo.title": "Logo design", "tui.dialog.logo.option.classic": "Classic (bold)", "tui.dialog.logo.option.thin": "Thin (half-block)", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/es.ts b/packages/opencode/src/cli/cmd/tui/i18n/es.ts index 50b87ca3b..0b99af6cd 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/es.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/es.ts @@ -372,8 +372,12 @@ export const dict = { "tui.command.opencode.status.title": "Ver estado", "tui.command.theme.switch.title": "Cambiar tema", "tui.command.logo.switch.title": "Cambiar diseño de logo", - "tui.command.visual_mode.minimal": "Usar visuales mínimos", - "tui.command.visual_mode.vivid": "Usar visuales intensos", + "tui.command.visual_mode.title_on": "Modo Vivid: activado — clic para usar visuales mínimos", + "tui.command.visual_mode.title_off": "Modo Vivid: desactivado — clic para usar visuales intensos", + "tui.command.visual_mode.description_on": "Estrellas, meteoros, efectos del logo e indicadores animados", + "tui.command.visual_mode.description_off": "Visuales mínimos con indicadores de actividad estables", + "tui.visual_mode.enabled": "Modo Vivid activado", + "tui.visual_mode.disabled": "Modo Vivid desactivado — usando visuales mínimos", "tui.dialog.logo.title": "Diseño de logo", "tui.dialog.logo.option.classic": "Clásico (negrita)", "tui.dialog.logo.option.thin": "Fino (medio bloque)", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/fr.ts b/packages/opencode/src/cli/cmd/tui/i18n/fr.ts index 1a91c334e..aac96b3bd 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/fr.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/fr.ts @@ -360,8 +360,12 @@ export const dict = { "tui.command.opencode.status.title": "Voir l'état", "tui.command.theme.switch.title": "Changer de thème", "tui.command.logo.switch.title": "Changer le design du logo", - "tui.command.visual_mode.minimal": "Utiliser les visuels minimalistes", - "tui.command.visual_mode.vivid": "Utiliser les visuels riches", + "tui.command.visual_mode.title_on": "Mode Vivid : activé — cliquer pour des visuels minimalistes", + "tui.command.visual_mode.title_off": "Mode Vivid : désactivé — cliquer pour des visuels riches", + "tui.command.visual_mode.description_on": "Étoiles, météores, effets du logo et indicateurs animés", + "tui.command.visual_mode.description_off": "Visuels minimalistes avec indicateurs d’activité stables", + "tui.visual_mode.enabled": "Mode Vivid activé", + "tui.visual_mode.disabled": "Mode Vivid désactivé — visuels minimalistes utilisés", "tui.dialog.logo.title": "Design du logo", "tui.dialog.logo.option.classic": "Classique (gras)", "tui.dialog.logo.option.thin": "Fin (demi-bloc)", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/ja.ts b/packages/opencode/src/cli/cmd/tui/i18n/ja.ts index cebc6ae39..0100912af 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/ja.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/ja.ts @@ -304,8 +304,12 @@ export const dict = { "tui.command.opencode.status.title": "ステータスを表示", "tui.command.theme.switch.title": "テーマを切り替え", "tui.command.logo.switch.title": "ロゴデザインを切り替え", - "tui.command.visual_mode.minimal": "ミニマル表示を使用", - "tui.command.visual_mode.vivid": "リッチ表示を使用", + "tui.command.visual_mode.title_on": "Vividモード:オン — クリックしてミニマル表示へ", + "tui.command.visual_mode.title_off": "Vividモード:オフ — クリックしてリッチ表示へ", + "tui.command.visual_mode.description_on": "星空、流星、ロゴ効果、動く進行状況を表示", + "tui.command.visual_mode.description_off": "安定した進行表示を使うミニマルな外観", + "tui.visual_mode.enabled": "Vividモードを有効にしました", + "tui.visual_mode.disabled": "Vividモードを無効にしました — ミニマル表示を使用中", "tui.dialog.logo.title": "ロゴデザイン", "tui.dialog.logo.option.classic": "クラシック(太字)", "tui.dialog.logo.option.thin": "細字(ハーフブロック)", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/ru.ts b/packages/opencode/src/cli/cmd/tui/i18n/ru.ts index 53342c181..25246f237 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/ru.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/ru.ts @@ -375,8 +375,12 @@ export const dict = { "tui.command.opencode.status.title": "Посмотреть статус", "tui.command.theme.switch.title": "Сменить тему", "tui.command.logo.switch.title": "Сменить дизайн логотипа", - "tui.command.visual_mode.minimal": "Использовать минимальное оформление", - "tui.command.visual_mode.vivid": "Использовать яркое оформление", + "tui.command.visual_mode.title_on": "Режим Vivid: включён — нажмите для минимального оформления", + "tui.command.visual_mode.title_off": "Режим Vivid: выключен — нажмите для яркого оформления", + "tui.command.visual_mode.description_on": "Звёзды, метеоры, эффекты логотипа и анимированные индикаторы", + "tui.command.visual_mode.description_off": "Минимальное оформление со стабильными индикаторами", + "tui.visual_mode.enabled": "Режим Vivid включён", + "tui.visual_mode.disabled": "Режим Vivid выключен — используется минимальное оформление", "tui.dialog.logo.title": "Дизайн логотипа", "tui.dialog.logo.option.classic": "Классический (жирный)", "tui.dialog.logo.option.thin": "Тонкий (полублок)", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/zh.ts b/packages/opencode/src/cli/cmd/tui/i18n/zh.ts index 5fa913aca..b92f4ad0a 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/zh.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/zh.ts @@ -352,8 +352,12 @@ export const dict = { "tui.dialog.image.import.success": "背景图片已导入", "tui.dialog.image.none": "无(使用星空背景)", "tui.command.logo.switch.title": "切换 Logo 样式", - "tui.command.visual_mode.minimal": "使用极简视觉", - "tui.command.visual_mode.vivid": "使用丰富视觉", + "tui.command.visual_mode.title_on": "Vivid 模式:已开启 — 点击使用极简视觉", + "tui.command.visual_mode.title_off": "Vivid 模式:已关闭 — 点击使用丰富视觉", + "tui.command.visual_mode.description_on": "显示星空、流星、Logo 特效和动态进行中标记", + "tui.command.visual_mode.description_off": "使用极简视觉和稳定的进行中标记", + "tui.visual_mode.enabled": "Vivid 模式已开启", + "tui.visual_mode.disabled": "Vivid 模式已关闭 — 正在使用极简视觉", "tui.dialog.logo.title": "Logo 样式", "tui.dialog.logo.option.classic": "经典(粗体)", "tui.dialog.logo.option.thin": "纤细(半块)", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/zht.ts b/packages/opencode/src/cli/cmd/tui/i18n/zht.ts index 61ff7a343..2c19a0a38 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/zht.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/zht.ts @@ -352,8 +352,12 @@ export const dict = { "tui.dialog.image.import.success": "背景圖片已匯入", "tui.dialog.image.none": "無(使用星空背景)", "tui.command.logo.switch.title": "切換 Logo 樣式", - "tui.command.visual_mode.minimal": "使用極簡視覺", - "tui.command.visual_mode.vivid": "使用豐富視覺", + "tui.command.visual_mode.title_on": "Vivid 模式:已開啟 — 點擊使用極簡視覺", + "tui.command.visual_mode.title_off": "Vivid 模式:已關閉 — 點擊使用豐富視覺", + "tui.command.visual_mode.description_on": "顯示星空、流星、Logo 特效和動態進行中標記", + "tui.command.visual_mode.description_off": "使用極簡視覺和穩定的進行中標記", + "tui.visual_mode.enabled": "Vivid 模式已開啟", + "tui.visual_mode.disabled": "Vivid 模式已關閉 — 正在使用極簡視覺", "tui.dialog.logo.title": "Logo 樣式", "tui.dialog.logo.option.classic": "經典(粗體)", "tui.dialog.logo.option.thin": "纖細(半塊)", diff --git a/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/SKILL.md b/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/SKILL.md index fe9523bed..95c3851c8 100644 --- a/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/SKILL.md +++ b/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/SKILL.md @@ -22,6 +22,7 @@ MiMoCode (CLI binary `mimo`) is an agentic coding tool with a terminal UI, built | **Task tree** | `T1`, `T1.1`… tree, integrated with checkpoints | `task` tooling | | **Goal / stop condition** | Judge model verifies a stop condition before the agent halts | `/goal` | | **Compose mode** | Structured spec→ship lifecycle; recommended entry is the `/compose-next` skill on Build. That skill sets `disable-model-invocation`, so only the user can start it — it is absent from the agent's skill catalog and from `skill_search`, and the `skill` tool refuses it. Suggest `/compose-next` to the user when the work warrants it; never enter the workflow unasked | `/compose-next` (see @reference/guide.md) | +| **Visual modes** | `vivid` (default: star field, meteors, logo effects, animated activity) and `minimal` (quiet visuals, stable activity indicators); independent from the animation override | `/vivid` or the `ctrl+p` Vivid mode setting | | **Voice input** | Streaming ASR (TenVAD + MiMo ASR); needs `sox` | `/voice` | | **Dream** | Consolidates recent traces into project memory | `/dream` | | **Distill** | Packages repeated manual workflows into skills/subagents/commands | `/distill` | @@ -75,7 +76,7 @@ Base dirs follow `MIMOCODE_HOME` (if set, absolute) else XDG. Data typically liv ## Commands -`mimo` subcommands (`mcp`, `run`, `agent`, `models`, `providers`, `upgrade`, `stats`, `export`/`import`, `github`/`pr`, `serve`, …) and slash commands (`/goal`, `/dream`, `/distill`, `/voice`, `/loop`, `/connect`, `/`) are documented in @reference/commands.md. +`mimo` subcommands (`mcp`, `run`, `agent`, `models`, `providers`, `upgrade`, `stats`, `export`/`import`, `github`/`pr`, `serve`, …) and slash commands (`/goal`, `/dream`, `/distill`, `/vivid`, `/voice`, `/loop`, `/connect`, `/`) are documented in @reference/commands.md. ## Helping the User Configure diff --git a/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/commands.md b/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/commands.md index d840f30ba..6b1cbd8a1 100644 --- a/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/commands.md +++ b/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/commands.md @@ -60,6 +60,7 @@ Most client commands run only when the whole input is the command. `/btw { test("defaults missing and invalid state to vivid", () => { @@ -11,6 +11,12 @@ describe("TUI visual mode", () => { expect(resolveVisualMode("minimal")).toBe("minimal") }) + test("toggles the persisted visual mode", () => { + expect(toggleVisualMode(undefined)).toBe("minimal") + expect(toggleVisualMode("vivid")).toBe("minimal") + expect(toggleVisualMode("minimal")).toBe("vivid") + }) + test("only enables cosmetic motion for vivid visuals with animations enabled", () => { expect(visualMotionEnabled("minimal", true)).toBe(false) expect(visualMotionEnabled("minimal", false)).toBe(false) From 8ae03b5f5fefa319d04174675bdb3adaa6617779 Mon Sep 17 00:00:00 2001 From: yanyihan Date: Wed, 5 Aug 2026 12:49:46 +0800 Subject: [PATCH 116/135] fix(session): prevent duplicate context rebuilds --- docs/compose/spec/context-budget-control.md | 22 +++- packages/opencode/src/session/prompt.ts | 20 +++- .../auto-overflow-writer-first.test.ts | 105 +++++++++++++++++- 3 files changed, 140 insertions(+), 7 deletions(-) diff --git a/docs/compose/spec/context-budget-control.md b/docs/compose/spec/context-budget-control.md index e327e0317..146c1fbe3 100644 --- a/docs/compose/spec/context-budget-control.md +++ b/docs/compose/spec/context-budget-control.md @@ -1,8 +1,8 @@ --- feature: context-budget-control -status: delivered -updated: 2026-07-31 -branch: fix/context-limit-threshold-rebuild +status: in-progress +updated: 2026-08-05 +branch: investigate/context-limit-double-rebuild commits: 028f3178..3b15062d --- @@ -105,6 +105,12 @@ Checkpoint percentages use `usable()` as their denominator, and the final 80%/90 3. Discoverable from the TUI without editing JSON, and the resulting number must be printable ("what is my current context window, and where will it compact?"). 4. The provider-layer Codex bug fixed correctly and independently of (1)–(3). +### S1.6 A completed high-usage turn is rebuilt twice + +`SessionProcessor` marks a successfully completed model turn as `"overflow"` when its reported usage reaches `Overflow.usable()`. The post-process overflow handler rebuilds immediately, but the same completed assistant usage remains visible to later prompt loops. The next user turn can therefore consume that usage again in the preflight overflow check, insert a second checkpoint boundary, re-arm checkpoint thresholds, and run tail microcompaction again. + +For a configured 372K budget with the default 20K reserve, the first trigger is 352K (`94.6%` of the configured limit). This is the intended trigger. The defect is processing that one high-water usage record twice, not the trigger percentage. + ## [S2] Design — route-independent core ### S2.1 Vocabulary @@ -215,6 +221,14 @@ The TUI may import `Overflow.window` directly — TUI modules already import fro Note this intentionally changes an existing user-visible number: the footer `%` will read higher than before for models whose reserves are large, because it is now measured against the value that actually triggers compaction. That is the point of the change and must be called out in the PR description. +### S2.7 One recovery per assistant usage record + +Every overflow recovery path that successfully frees context in the post-process phase sets `skipOverflowCheck` before continuing the current run loop. Across run loops, a checkpoint or compaction boundary with an ascending message ID newer than the completed assistant marks that usage as already recovered. Boundary timestamps are backdated to the checkpoint watermark, so this comparison uses message IDs rather than timestamps. + +The next iteration or user turn may call the model on the rebuilt or compacted context, but must not run checkpoint scheduling, preflight overflow, or exit-time pruning against the same completed assistant usage. This applies to main-agent checkpoint rebuilds and subagent/fork compaction paths. If recovery inserts nothing (`insert-failed`), no marker exists and the usage remains eligible because no context was freed. + +The invariant is behavioral, not time-based: no cooldown or percentage margin is introduced. A later assistant turn with newly measured high usage may still trigger its own recovery. + ## [S3] Routes — decision required Storage location for the user's budget. All routes share S2.1–S2.2 and S2.6; they differ in where the value lives and therefore in scope, persistence, and cost. @@ -297,3 +311,5 @@ Display (needed by any route): - [x] T11: Surface the context window in the `models` CLI command without `--verbose` — acceptance: `mimocode models openai` prints each model's provider window and compact-at (covers: S2.6; depends: T5) - [x] T12: Decouple the final checkpoint threshold from the prompt-loop rebuild condition — acceptance: crossing the final checkpoint threshold below `usable()` writes a checkpoint but inserts no rebuild or compaction boundary; reaching `usable()` still follows the existing rebuild path (covers: S1.3, S2.1; depends: T5) - [x] T13: Show the configured active limit relative to the provider hard cap in the sidebar — acceptance: a 300K budget on a 922K model renders `limit 300K of 922K`, while the reserve-adjusted trigger remains internal and available in `/status` (covers: S2.6; depends: T5) +- [x] T14: Consume each assistant usage at most once during overflow recovery — acceptance: post-process recovery sets the current-loop skip guard; across user turns, a newer boundary prevents the recovered assistant from driving checkpoint scheduling, preflight overflow, or exit-time pruning; equivalent subagent/fork recovery paths set the same guard (covers: S1.6, S2.7; depends: T12) +- [x] T15: Add regression coverage for duplicate recovery — acceptance: a low-usage initialization turn, a successful high-usage turn, and a following user turn produce two distinct checkpoint boundaries on current `main`, but exactly one boundary and one writer after T14; existing preflight and provider-overflow fallback tests remain green (covers: S1.6, S2.7; depends: T14) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 7c453d21d..8f9ca04db 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -3163,6 +3163,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the } if (!lastUser) throw new Error("No user message found in stream. This should never happen.") + const usageRecovered = + !!lastFinished && + msgs.some( + (msg) => + msg.info.id > lastFinished.id && + msg.parts.some((part) => part.type === "checkpoint" || part.type === "compaction"), + ) // Per-user-message active recall reminder. Once the session has // any memory artifacts (memory dir populated OR tasks recorded), @@ -3321,7 +3328,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID, lastUser) lastModelForPrune = model - lastFinishedForPrune = lastFinished + lastFinishedForPrune = usageRecovered ? undefined : lastFinished const task = tasks.pop() if (task?.type === "subtask") { @@ -3412,7 +3419,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the // based on the latest completed assistant message's tokens. These // thresholds only keep the checkpoint fresh; `overflowCheck` below is // the single trigger for rebuilding the active context. - if (!skipOverflowCheck && !isBoundedComputation && lastFinished && lastFinished.tokens) { + if (!skipOverflowCheck && !usageRecovered && !isBoundedComputation && lastFinished && lastFinished.tokens) { const fireOps = yield* ops() yield* prune .fireCheckpoints({ @@ -3427,6 +3434,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the if ( !skipOverflowCheck && + !usageRecovered && !isBoundedComputation && lastFinished && lastFinished.summary !== true && @@ -3817,6 +3825,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the agentID: lastUser.agentID, }) .pipe(Effect.ignore) + skipOverflowCheck = true } return "continue" as const } @@ -4037,6 +4046,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the agentID: lastUser.agentID, }) .pipe(Effect.ignore) + skipOverflowCheck = true return "continue" as const } @@ -4055,7 +4065,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the .set(sessionID, { type: "busy", message: "Writing checkpoint\u2026" }) .pipe(Effect.catch(() => Effect.void)), }) - if (attempt2 === "rebuilt") return "continue" as const + if (attempt2 === "rebuilt") { + skipOverflowCheck = true + return "continue" as const + } // Same as above: the writer ran and failed — not "no checkpoint". if (attempt2 === "writer-failed") { @@ -4070,6 +4083,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the agentID: lastUser.agentID, }) .pipe(Effect.ignore) + skipOverflowCheck = true } // "insert-failed" → a checkpoint exists; must not compact. } diff --git a/packages/opencode/test/session/auto-overflow-writer-first.test.ts b/packages/opencode/test/session/auto-overflow-writer-first.test.ts index 46f2ff950..26cc8d3a7 100644 --- a/packages/opencode/test/session/auto-overflow-writer-first.test.ts +++ b/packages/opencode/test/session/auto-overflow-writer-first.test.ts @@ -33,7 +33,7 @@ function run(fx: Effect.Effect { +function chat(text: string, promptTokens?: number): ReadableStream { const payload = [ `data: ${JSON.stringify({ @@ -50,6 +50,10 @@ function chat(text: string): ReadableStream { id: "chatcmpl-1", object: "chat.completion.chunk", choices: [{ delta: {}, finish_reason: "stop" }], + usage: + promptTokens === undefined + ? undefined + : { prompt_tokens: promptTokens, completion_tokens: 1, total_tokens: promptTokens + 1 }, })}`, "data: [DONE]", ].join("\n\n") + "\n\n" @@ -62,6 +66,30 @@ function chat(text: string): ReadableStream { }) } +function startUsageLLM(replies: Array<{ text: string; promptTokens: number }>) { + let calls = 0 + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url) + if (!url.pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 }) + const reply = replies[Math.min(calls, replies.length - 1)]! + calls++ + return new Response(chat(reply.text, reply.promptTokens), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }) + }, + }) + return { + origin: server.url.origin, + get calls() { + return calls + }, + stop: () => server.stop(true), + } +} + function startLLM(reply: string) { let calls = 0 const server = Bun.serve({ @@ -273,6 +301,81 @@ async function seedFinishedAssistant(sessionID: SessionID, parentID: MessageID, // with no summary at all. Degrading is therefore a real loss, not a cheaper // summary. describe("Auto context overflow: write a checkpoint before degrading to compaction", () => { + test( + "a completed high-usage turn is rebuilt exactly once", + async () => { + const llm = startUsageLLM([ + { text: "initialized", promptTokens: 1_000 }, + { text: "high-usage reply", promptTokens: 25_000 }, + { text: "reply after rebuild", promptTokens: 1_000 }, + ]) + let writerCalls = 0 + const writer = writerThatWritesCheckpointAfter("HIGH_USAGE_CHECKPOINT", 400, () => writerCalls++) + try { + await using tmp = await tmpdir({ + git: true, + init: (dir) => Bun.write(path.join(dir, "mimocode.json"), mimocodeConfig(llm.origin)), + }) + + await Instance.provide({ + directory: tmp.path, + fn: () => + run( + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const info = yield* sessions.create({ title: "high-usage-single-rebuild" }) + + // The first prompt resolves the late-bound actor layer, which + // installs its real spawn implementation. + yield* prompt.prompt({ + sessionID: info.id, + parts: [{ type: "text", text: "initialize the actor layer" }], + agent: "build", + }) + + // Bind the deterministic writer after layer initialization. + const previous = spawnRef.current + spawnRef.current = writer + yield* prompt + .prompt({ + sessionID: info.id, + parts: [{ type: "text", text: "produce one high-usage turn" }], + agent: "build", + }) + .pipe( + Effect.ensuring( + Effect.sync(() => { + spawnRef.current = previous + }), + ), + ) + + yield* prompt.prompt({ + sessionID: info.id, + parts: [{ type: "text", text: "continue after the automatic rebuild" }], + agent: "build", + }) + + const after = yield* sessions.messages({ sessionID: info.id }) + const checkpoints = after.filter((m) => m.parts.some((p) => p.type === "checkpoint")) + expect(checkpoints).toHaveLength(1) + expect(new Set(checkpoints.map((m) => m.info.id)).size).toBe(1) + expect(writerCalls).toBe(1) + expect(llm.calls).toBe(3) + expect( + after.some((m) => m.parts.some((p) => p.type === "text" && p.text === "reply after rebuild")), + ).toBe(true) + }), + ), + }) + } finally { + await llm.stop() + } + }, + { timeout: 60_000 }, + ) + test( "crossing the final checkpoint threshold below the configured context trigger does not rebuild", async () => { From 11b8a08300f9c240c9aa5828c127f2856255606b Mon Sep 17 00:00:00 2001 From: yanyihan Date: Wed, 5 Aug 2026 12:52:23 +0800 Subject: [PATCH 117/135] docs(compose): finalize vivid mode controls --- docs/compose/spec/tui-quiet-mode.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/compose/spec/tui-quiet-mode.md b/docs/compose/spec/tui-quiet-mode.md index 61dcdbea5..5624b8675 100644 --- a/docs/compose/spec/tui-quiet-mode.md +++ b/docs/compose/spec/tui-quiet-mode.md @@ -1,26 +1,27 @@ --- feature: tui-quiet-mode -status: in-progress +status: delivered updated: 2026-08-05 branch: feature/tui-quiet-mode -commits: +commits: 91dc9d14c263d76f7e843eaf6cce3f112ee1ddda..5931f015 --- # TUI Quiet Mode ## Report -**What was built** — Added a persisted `minimal` / `vivid` visual mode with `vivid` as the default and a command-palette switch. Minimal mode removes the default celestial background and uses stable progress markers; vivid mode preserves the existing presentation. The separate animation preference now stops high-frequency stars, meteors, Logo motion, and spinners without disabling low-frequency functional updates. +**What was built** — Added a persisted `minimal` / `vivid` visual mode with `vivid` as the default. The command palette and `/vivid` share one localized toggle. Minimal mode removes the default celestial background and uses stable progress markers; vivid mode preserves the existing presentation. The separate animation preference stops high-frequency stars, meteors, Logo motion, and spinners without disabling low-frequency functional updates. Logo, star field, prompt, task, workflow, and agent states share the same `vivid && animations_enabled` motion contract. Runtime preference changes clean up and restart eligible timers without requiring a TUI restart. -**Verification** — Pending amendment verification. +**Verification** — `bun test test/cli/tui/visual-mode.test.ts` passed 4 tests; `bun test test/cli/tui test/cli/cmd/tui` passed 266 tests and 728 assertions; bundled skill tests passed 8 tests and 41 assertions; `bun typecheck` passed; `git diff --check` passed. An isolated development TUI confirmed the vivid default, `/vivid` switching to minimal, localized ON/OFF palette states and toasts, switching back through `ctrl+p`, and KV persistence. **Journey log** - Kept home tip rotation because it is a low-frequency functional update, not decorative high-frequency motion. - Split visual style from animation accessibility so either presentation can use the independent animation override. - A targeted review found and closed an idle Logo timer outside the home route. +- Kept `/vivid` and `ctrl+p` on one command entry so state, persistence, and feedback cannot diverge. ## [S1] Problem @@ -28,7 +29,7 @@ The current vivid presentation redraws the home screen for stars, meteors, and l ## [S2] Design -Add an independent KV-backed `visual_mode` preference with `minimal` and `vivid` values. It is switched by the same command from the command palette or `/vivid`, persists across launches, and defaults to `vivid`. The command title, description, and completion toast distinguish the enabled and disabled states in every supported locale. The existing `animations_enabled` preference remains a separate accessibility and performance override. +Add an independent KV-backed `visual_mode` preference with `minimal` and `vivid` values. It is switched by the same command from the command palette or `/vivid`, persists across launches, and defaults to `vivid`. The command title, description, and completion toast distinguish the enabled and disabled states in every existing TUI-specific locale dictionary; locales without a TUI dictionary use the standard English fallback, matching `/voice`. The existing `animations_enabled` preference remains a separate accessibility and performance override. In `minimal` mode: @@ -49,8 +50,8 @@ The implementation must use the existing theme colors, dimensions, and layout; t ## Tasks -- [ ] T1: Add the persisted visual mode command — acceptance: the command palette and `/vivid` map to the same toggle, show localized enabled/disabled state, persist the choice, and an unset value resolves to `vivid` (covers: S2) +- [x] T1: Add the persisted visual mode command — acceptance: the command palette and `/vivid` map to the same toggle, show localized enabled/disabled state, persist the choice, and an unset value resolves to `vivid` (covers: S2) - [x] T2: Apply visual and animation preferences to passive home motion — acceptance: minimal mode has no default celestial background or logo motion; vivid mode preserves current visuals; disabling animations leaves vivid visuals static and preserves functional tip rotation (covers: S2; depends: T1) - [x] T3: Stabilize every in-progress indicator — acceptance: prompt, task, workflow, and agent running states render fixed-width static markers unless both vivid mode and animations are enabled (covers: S2; depends: T1) -- [ ] T4: Add focused regression coverage and verify TUI behavior — acceptance: tests cover preference resolution and relevant package tests and typecheck pass (covers: S2; depends: T1, T2, T3) -- [ ] T5: Document visual mode controls — acceptance: English and Chinese READMEs and the bundled `mimocode-docs` skill describe `/vivid`, the command palette setting, the default, and the independent animation override (covers: S2; depends: T1) +- [x] T4: Add focused regression coverage and verify TUI behavior — acceptance: tests cover preference resolution and relevant package tests and typecheck pass (covers: S2; depends: T1, T2, T3) +- [x] T5: Document visual mode controls — acceptance: English and Chinese READMEs and the bundled `mimocode-docs` skill describe `/vivid`, the command palette setting, the default, and the independent animation override (covers: S2; depends: T1) From 0af699136837a8f1dffd73625f9008cbb6d1c0ca Mon Sep 17 00:00:00 2001 From: yanyihan Date: Wed, 5 Aug 2026 13:47:15 +0800 Subject: [PATCH 118/135] fix(tui): simplify vivid toggle copy --- docs/compose/spec/tui-quiet-mode.md | 8 ++++---- packages/opencode/src/cli/cmd/tui/app.tsx | 5 ----- packages/opencode/src/cli/cmd/tui/i18n/en.ts | 10 ++++------ packages/opencode/src/cli/cmd/tui/i18n/es.ts | 10 ++++------ packages/opencode/src/cli/cmd/tui/i18n/fr.ts | 10 ++++------ packages/opencode/src/cli/cmd/tui/i18n/ja.ts | 10 ++++------ packages/opencode/src/cli/cmd/tui/i18n/ru.ts | 10 ++++------ packages/opencode/src/cli/cmd/tui/i18n/zh.ts | 10 ++++------ packages/opencode/src/cli/cmd/tui/i18n/zht.ts | 10 ++++------ 9 files changed, 32 insertions(+), 51 deletions(-) diff --git a/docs/compose/spec/tui-quiet-mode.md b/docs/compose/spec/tui-quiet-mode.md index 5624b8675..ea11d8bfe 100644 --- a/docs/compose/spec/tui-quiet-mode.md +++ b/docs/compose/spec/tui-quiet-mode.md @@ -1,9 +1,9 @@ --- feature: tui-quiet-mode -status: delivered +status: in-progress updated: 2026-08-05 branch: feature/tui-quiet-mode -commits: 91dc9d14c263d76f7e843eaf6cce3f112ee1ddda..5931f015 +commits: --- # TUI Quiet Mode @@ -14,7 +14,7 @@ commits: 91dc9d14c263d76f7e843eaf6cce3f112ee1ddda..5931f015 Logo, star field, prompt, task, workflow, and agent states share the same `vivid && animations_enabled` motion contract. Runtime preference changes clean up and restart eligible timers without requiring a TUI restart. -**Verification** — `bun test test/cli/tui/visual-mode.test.ts` passed 4 tests; `bun test test/cli/tui test/cli/cmd/tui` passed 266 tests and 728 assertions; bundled skill tests passed 8 tests and 41 assertions; `bun typecheck` passed; `git diff --check` passed. An isolated development TUI confirmed the vivid default, `/vivid` switching to minimal, localized ON/OFF palette states and toasts, switching back through `ctrl+p`, and KV persistence. +**Verification** — Pending copy simplification verification. **Journey log** @@ -29,7 +29,7 @@ The current vivid presentation redraws the home screen for stars, meteors, and l ## [S2] Design -Add an independent KV-backed `visual_mode` preference with `minimal` and `vivid` values. It is switched by the same command from the command palette or `/vivid`, persists across launches, and defaults to `vivid`. The command title, description, and completion toast distinguish the enabled and disabled states in every existing TUI-specific locale dictionary; locales without a TUI dictionary use the standard English fallback, matching `/voice`. The existing `animations_enabled` preference remains a separate accessibility and performance override. +Add an independent KV-backed `visual_mode` preference with `minimal` and `vivid` values. It is switched by the same command from the command palette or `/vivid`, persists across launches, and defaults to `vivid`. A concise command title and completion toast distinguish the enabled and disabled states in every existing TUI-specific locale dictionary; locales without a TUI dictionary use the standard English fallback, matching `/voice`. The existing `animations_enabled` preference remains a separate accessibility and performance override. In `minimal` mode: diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 62d05d0c1..8c74b4e7e 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -926,11 +926,6 @@ function App(props: { onSnapshot?: () => Promise }) { ? "tui.command.visual_mode.title_on" : "tui.command.visual_mode.title_off", ), - description: t( - resolveVisualMode(kv.get("visual_mode", "vivid")) === "vivid" - ? "tui.command.visual_mode.description_on" - : "tui.command.visual_mode.description_off", - ), value: "app.toggle.visual_mode", slash: { name: "vivid", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/en.ts b/packages/opencode/src/cli/cmd/tui/i18n/en.ts index db779fea1..4154db4cb 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/en.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/en.ts @@ -329,12 +329,10 @@ export const dict: Record = { "tui.dialog.image.import.success": "Background image imported", "tui.dialog.image.none": "None (use starry background)", "tui.command.logo.switch.title": "Switch logo design", - "tui.command.visual_mode.title_on": "Vivid mode: ON — click to use minimal visuals", - "tui.command.visual_mode.title_off": "Vivid mode: OFF — click to use vivid visuals", - "tui.command.visual_mode.description_on": "Stars, meteors, logo effects, and animated activity indicators", - "tui.command.visual_mode.description_off": "Minimal visuals with stable activity indicators", - "tui.visual_mode.enabled": "Vivid mode enabled", - "tui.visual_mode.disabled": "Vivid mode disabled — using minimal visuals", + "tui.command.visual_mode.title_on": "Vivid visuals - switch to Minimal", + "tui.command.visual_mode.title_off": "Minimal visuals - switch to Vivid", + "tui.visual_mode.enabled": "Vivid display enabled: star field and logo effects restored; meteors and animated activity follow the animation setting", + "tui.visual_mode.disabled": "Vivid display disabled: stars, meteors, and logo effects hidden; activity indicators remain stable", "tui.dialog.logo.title": "Logo design", "tui.dialog.logo.option.classic": "Classic (bold)", "tui.dialog.logo.option.thin": "Thin (half-block)", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/es.ts b/packages/opencode/src/cli/cmd/tui/i18n/es.ts index 0b99af6cd..169ab9863 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/es.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/es.ts @@ -372,12 +372,10 @@ export const dict = { "tui.command.opencode.status.title": "Ver estado", "tui.command.theme.switch.title": "Cambiar tema", "tui.command.logo.switch.title": "Cambiar diseño de logo", - "tui.command.visual_mode.title_on": "Modo Vivid: activado — clic para usar visuales mínimos", - "tui.command.visual_mode.title_off": "Modo Vivid: desactivado — clic para usar visuales intensos", - "tui.command.visual_mode.description_on": "Estrellas, meteoros, efectos del logo e indicadores animados", - "tui.command.visual_mode.description_off": "Visuales mínimos con indicadores de actividad estables", - "tui.visual_mode.enabled": "Modo Vivid activado", - "tui.visual_mode.disabled": "Modo Vivid desactivado — usando visuales mínimos", + "tui.command.visual_mode.title_on": "Vista enriquecida activa - cambiar a mínima", + "tui.command.visual_mode.title_off": "Vista mínima activa - cambiar a enriquecida", + "tui.visual_mode.enabled": "Vista enriquecida activada: se restauraron el cielo estrellado y los efectos del logo; los meteoros y los indicadores animados dependen del ajuste de animación", + "tui.visual_mode.disabled": "Vista enriquecida desactivada: se ocultaron estrellas, meteoros y efectos del logo; los indicadores permanecen estables", "tui.dialog.logo.title": "Diseño de logo", "tui.dialog.logo.option.classic": "Clásico (negrita)", "tui.dialog.logo.option.thin": "Fino (medio bloque)", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/fr.ts b/packages/opencode/src/cli/cmd/tui/i18n/fr.ts index aac96b3bd..f5f383be3 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/fr.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/fr.ts @@ -360,12 +360,10 @@ export const dict = { "tui.command.opencode.status.title": "Voir l'état", "tui.command.theme.switch.title": "Changer de thème", "tui.command.logo.switch.title": "Changer le design du logo", - "tui.command.visual_mode.title_on": "Mode Vivid : activé — cliquer pour des visuels minimalistes", - "tui.command.visual_mode.title_off": "Mode Vivid : désactivé — cliquer pour des visuels riches", - "tui.command.visual_mode.description_on": "Étoiles, météores, effets du logo et indicateurs animés", - "tui.command.visual_mode.description_off": "Visuels minimalistes avec indicateurs d’activité stables", - "tui.visual_mode.enabled": "Mode Vivid activé", - "tui.visual_mode.disabled": "Mode Vivid désactivé — visuels minimalistes utilisés", + "tui.command.visual_mode.title_on": "Affichage enrichi - passer en mode minimal", + "tui.command.visual_mode.title_off": "Affichage minimal - passer en mode enrichi", + "tui.visual_mode.enabled": "Affichage enrichi activé : ciel étoilé et effets du logo restaurés ; météores et indicateurs animés suivent le réglage des animations", + "tui.visual_mode.disabled": "Affichage enrichi désactivé : étoiles, météores et effets du logo masqués ; indicateurs stabilisés", "tui.dialog.logo.title": "Design du logo", "tui.dialog.logo.option.classic": "Classique (gras)", "tui.dialog.logo.option.thin": "Fin (demi-bloc)", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/ja.ts b/packages/opencode/src/cli/cmd/tui/i18n/ja.ts index 0100912af..db3501fc9 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/ja.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/ja.ts @@ -304,12 +304,10 @@ export const dict = { "tui.command.opencode.status.title": "ステータスを表示", "tui.command.theme.switch.title": "テーマを切り替え", "tui.command.logo.switch.title": "ロゴデザインを切り替え", - "tui.command.visual_mode.title_on": "Vividモード:オン — クリックしてミニマル表示へ", - "tui.command.visual_mode.title_off": "Vividモード:オフ — クリックしてリッチ表示へ", - "tui.command.visual_mode.description_on": "星空、流星、ロゴ効果、動く進行状況を表示", - "tui.command.visual_mode.description_off": "安定した進行表示を使うミニマルな外観", - "tui.visual_mode.enabled": "Vividモードを有効にしました", - "tui.visual_mode.disabled": "Vividモードを無効にしました — ミニマル表示を使用中", + "tui.command.visual_mode.title_on": "リッチ表示中 - ミニマル表示に切り替え", + "tui.command.visual_mode.title_off": "ミニマル表示中 - リッチ表示に切り替え", + "tui.visual_mode.enabled": "リッチ表示を有効化:星空とロゴ効果を復元しました。流星と進行状況のアニメーションはアニメーション設定に従います", + "tui.visual_mode.disabled": "リッチ表示を無効化:星空、流星、ロゴ効果を非表示にし、進行状況表示を固定しました", "tui.dialog.logo.title": "ロゴデザイン", "tui.dialog.logo.option.classic": "クラシック(太字)", "tui.dialog.logo.option.thin": "細字(ハーフブロック)", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/ru.ts b/packages/opencode/src/cli/cmd/tui/i18n/ru.ts index 25246f237..c65d8aa36 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/ru.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/ru.ts @@ -375,12 +375,10 @@ export const dict = { "tui.command.opencode.status.title": "Посмотреть статус", "tui.command.theme.switch.title": "Сменить тему", "tui.command.logo.switch.title": "Сменить дизайн логотипа", - "tui.command.visual_mode.title_on": "Режим Vivid: включён — нажмите для минимального оформления", - "tui.command.visual_mode.title_off": "Режим Vivid: выключен — нажмите для яркого оформления", - "tui.command.visual_mode.description_on": "Звёзды, метеоры, эффекты логотипа и анимированные индикаторы", - "tui.command.visual_mode.description_off": "Минимальное оформление со стабильными индикаторами", - "tui.visual_mode.enabled": "Режим Vivid включён", - "tui.visual_mode.disabled": "Режим Vivid выключен — используется минимальное оформление", + "tui.command.visual_mode.title_on": "Расширенное оформление - перейти к минимальному", + "tui.command.visual_mode.title_off": "Минимальное оформление - перейти к расширенному", + "tui.visual_mode.enabled": "Расширенное оформление включено: звёздный фон и эффекты логотипа восстановлены; метеоры и анимация индикаторов зависят от настройки анимации", + "tui.visual_mode.disabled": "Расширенное оформление выключено: звёзды, метеоры и эффекты логотипа скрыты; индикаторы остаются неподвижными", "tui.dialog.logo.title": "Дизайн логотипа", "tui.dialog.logo.option.classic": "Классический (жирный)", "tui.dialog.logo.option.thin": "Тонкий (полублок)", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/zh.ts b/packages/opencode/src/cli/cmd/tui/i18n/zh.ts index b92f4ad0a..9bda3e13f 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/zh.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/zh.ts @@ -352,12 +352,10 @@ export const dict = { "tui.dialog.image.import.success": "背景图片已导入", "tui.dialog.image.none": "无(使用星空背景)", "tui.command.logo.switch.title": "切换 Logo 样式", - "tui.command.visual_mode.title_on": "Vivid 模式:已开启 — 点击使用极简视觉", - "tui.command.visual_mode.title_off": "Vivid 模式:已关闭 — 点击使用丰富视觉", - "tui.command.visual_mode.description_on": "显示星空、流星、Logo 特效和动态进行中标记", - "tui.command.visual_mode.description_off": "使用极简视觉和稳定的进行中标记", - "tui.visual_mode.enabled": "Vivid 模式已开启", - "tui.visual_mode.disabled": "Vivid 模式已关闭 — 正在使用极简视觉", + "tui.command.visual_mode.title_on": "丰富显示中 - 点击使用极简模式", + "tui.command.visual_mode.title_off": "极简显示中 - 点击使用丰富模式", + "tui.visual_mode.enabled": "已开启丰富显示:星空和标志特效已恢复,流星与动态进行中标记仍受动画设置控制", + "tui.visual_mode.disabled": "已关闭丰富显示:星空、流星和标志特效已隐藏,进行中标记将保持稳定", "tui.dialog.logo.title": "Logo 样式", "tui.dialog.logo.option.classic": "经典(粗体)", "tui.dialog.logo.option.thin": "纤细(半块)", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/zht.ts b/packages/opencode/src/cli/cmd/tui/i18n/zht.ts index 2c19a0a38..f2eeb42ee 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/zht.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/zht.ts @@ -352,12 +352,10 @@ export const dict = { "tui.dialog.image.import.success": "背景圖片已匯入", "tui.dialog.image.none": "無(使用星空背景)", "tui.command.logo.switch.title": "切換 Logo 樣式", - "tui.command.visual_mode.title_on": "Vivid 模式:已開啟 — 點擊使用極簡視覺", - "tui.command.visual_mode.title_off": "Vivid 模式:已關閉 — 點擊使用豐富視覺", - "tui.command.visual_mode.description_on": "顯示星空、流星、Logo 特效和動態進行中標記", - "tui.command.visual_mode.description_off": "使用極簡視覺和穩定的進行中標記", - "tui.visual_mode.enabled": "Vivid 模式已開啟", - "tui.visual_mode.disabled": "Vivid 模式已關閉 — 正在使用極簡視覺", + "tui.command.visual_mode.title_on": "豐富顯示中 - 點擊使用極簡模式", + "tui.command.visual_mode.title_off": "極簡顯示中 - 點擊使用豐富模式", + "tui.visual_mode.enabled": "已開啟豐富顯示:星空和標誌特效已恢復,流星與動態進行中標記仍受動畫設定控制", + "tui.visual_mode.disabled": "已關閉豐富顯示:星空、流星和標誌特效已隱藏,進行中標記將保持穩定", "tui.dialog.logo.title": "Logo 樣式", "tui.dialog.logo.option.classic": "經典(粗體)", "tui.dialog.logo.option.thin": "纖細(半塊)", From 641c0d1d0122b9425cd5a505bf9bd538f5b1de56 Mon Sep 17 00:00:00 2001 From: yanyihan Date: Wed, 5 Aug 2026 13:47:37 +0800 Subject: [PATCH 119/135] docs(compose): finalize vivid copy refinement --- docs/compose/spec/tui-quiet-mode.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/compose/spec/tui-quiet-mode.md b/docs/compose/spec/tui-quiet-mode.md index ea11d8bfe..7e637c17e 100644 --- a/docs/compose/spec/tui-quiet-mode.md +++ b/docs/compose/spec/tui-quiet-mode.md @@ -1,9 +1,9 @@ --- feature: tui-quiet-mode -status: in-progress +status: delivered updated: 2026-08-05 branch: feature/tui-quiet-mode -commits: +commits: 91dc9d14c263d76f7e843eaf6cce3f112ee1ddda..0af69913 --- # TUI Quiet Mode @@ -14,7 +14,7 @@ commits: Logo, star field, prompt, task, workflow, and agent states share the same `vivid && animations_enabled` motion contract. Runtime preference changes clean up and restart eligible timers without requiring a TUI restart. -**Verification** — Pending copy simplification verification. +**Verification** — `bun test test/cli/tui/visual-mode.test.ts` passed 4 tests; `bun test test/cli/tui test/cli/cmd/tui` passed 266 tests and 728 assertions; bundled skill tests passed 8 tests and 41 assertions; `bun typecheck` passed; `git diff --check` passed. Isolated development TUI runs confirmed the vivid default, concise localized `/vivid` and `ctrl+p` state/action labels, detailed two-line ON/OFF toasts, switching through both entry points, and KV persistence. **Journey log** @@ -22,6 +22,7 @@ Logo, star field, prompt, task, workflow, and agent states share the same `vivid - Split visual style from animation accessibility so either presentation can use the independent animation override. - A targeted review found and closed an idle Logo timer outside the home route. - Kept `/vivid` and `ctrl+p` on one command entry so state, persistence, and feedback cannot diverge. +- Kept command rows concise by combining current state and next action in one title, while reserving detailed visual-effect explanations for the toast. ## [S1] Problem From cbe44c16f6262424b7dbd4a0cdac9b2ed8f8492c Mon Sep 17 00:00:00 2001 From: yanyihan Date: Wed, 5 Aug 2026 14:57:07 +0800 Subject: [PATCH 120/135] docs: add TUI rendering troubleshooting --- README.md | 37 ++++++++++++++++++++++++++++++------- README.zh.md | 37 ++++++++++++++++++++++++++++++------- 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 1e042bb44..082c51e82 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,36 @@ sudo apt install xsel ``` +
+macOS: rendering issues in the default terminal + +MiMoCode does not support the built-in macOS Terminal (Terminal.app). If the interface is misaligned, flickers, or has other rendering issues, use [iTerm2](https://iterm2.com/) or the VS Code integrated terminal instead: + +```bash +brew install --cask iterm2 +``` +
+ +
+TUI lag and visual animation issues + +If the TUI lags when run directly over SSH, render it locally and run only the MiMoCode server on the remote host. Start the server from the remote project directory: + +```bash +# Remote host +mimo serve --port 4096 + +# Local host: create the SSH port forward +ssh -N -L 4096:127.0.0.1:4096 user@remote-host + +# Local host: connect from another terminal +mimo attach http://127.0.0.1:4096 +``` + +If decorative animation is causing the lag, run `/vivid`, or configure **Vivid visuals** in the `ctrl+p` command palette, to switch between Vivid and Minimal visuals as needed. + +
+
Windows: garbled CJK (Chinese/Japanese/Korean) output in the shell @@ -238,13 +268,6 @@ The first two options remove the corresponding skills from the agent's available
-
-Vivid and Minimal visuals - -MiMoCode starts in Vivid mode, with the star field, meteors, logo effects, and animated activity indicators enabled. Run `/vivid` to switch between Vivid and Minimal visuals, or use the **Vivid mode** setting from the `ctrl+p` command palette. Minimal mode removes decorative motion and uses stable activity indicators. The separate **Disable animations** setting can stop high-frequency motion without changing the selected visual mode. - -
- ### Voice Input Real-time streaming voice input powered by TenVAD and MiMo ASR. Activate with `/voice`, then speak — audio is segmented by pauses and transcribed incrementally into the input. Available for MiMo logged-in users. Requires `sox` (`brew install sox` on macOS, other platforms similar). diff --git a/README.zh.md b/README.zh.md index 4e1ce71f6..347843cd5 100644 --- a/README.zh.md +++ b/README.zh.md @@ -55,6 +55,36 @@ sudo apt install xsel ``` +
+macOS:默认终端渲染异常 + +MiMoCode 不支持 macOS 自带的“终端”(Terminal.app)。如果界面出现错位、闪烁或其他渲染异常,请改用 [iTerm2](https://iterm2.com/) 或 VS Code 集成终端: + +```bash +brew install --cask iterm2 +``` +
+ +
+TUI 卡顿与视觉动画问题 + +如果通过 SSH 直接运行 TUI 时卡顿,可以让 TUI 在本地渲染,远端只运行 MiMoCode 服务。先在远端项目目录中启动服务: + +```bash +# 远端主机 +mimo serve --port 4096 + +# 本地主机:建立 SSH 端口转发 +ssh -N -L 4096:127.0.0.1:4096 user@remote-host + +# 本地主机:在另一个终端连接远端 MiMoCode +mimo attach http://127.0.0.1:4096 +``` + +如果卡顿来自装饰性动画,可以运行 `/vivid`,或在 `ctrl+p` 命令面板中设置“丰富显示”,根据实际情况在丰富视觉模式和简洁模式间切换。 + +
+
Windows:shell 输出中文(CJK)乱码 @@ -229,13 +259,6 @@ MiMoCode 打包了以下内置技能:
-
-Vivid 与极简视觉 - -MiMoCode 默认使用 Vivid 模式,显示星空、流星、Logo 特效和动态进行中标记。运行 `/vivid` 可在 Vivid 与极简视觉之间切换,也可以在 `ctrl+p` 命令面板中使用 **Vivid 模式** 设置。极简模式会移除装饰性动态效果,并使用稳定的进行中标记。独立的 **禁用动画** 设置可以停止高频动态刷新,而不改变当前选择的视觉模式。 - -
- ### 语音输入 基于 TenVAD 和 MiMo ASR 的实时流式语音输入。通过 `/voice` 激活,按停顿分片转写,文本逐段追加到输入框。仅对 MiMo 登录用户可用。需要安装 `sox`(macOS 上 `brew install sox`,其他平台类似)。 From ea49fd30cb8184d23cdebb19abd2edbca9c8a631 Mon Sep 17 00:00:00 2001 From: yanyihan Date: Wed, 5 Aug 2026 15:07:20 +0800 Subject: [PATCH 121/135] docs(skill): add TUI rendering troubleshooting --- .../builtin/.bundle/mimocode-docs/SKILL.md | 7 +++--- .../mimocode-docs/reference/commands.md | 2 ++ .../.bundle/mimocode-docs/reference/guide.md | 23 +++++++++++++++++++ .../opencode/test/skill/mimocode-docs.test.ts | 22 ++++++++++++++++++ 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/SKILL.md b/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/SKILL.md index 95c3851c8..d912bc015 100644 --- a/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/SKILL.md +++ b/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/SKILL.md @@ -1,6 +1,6 @@ --- name: mimocode-docs -description: "Use whenever the user asks about MiMoCode itself: features, TUI or CLI commands, keybindings, agent modes (build / plan / compose) and how to switch between them, configuration, file locations, providers, models, authentication, or custom OpenAI-compatible or Anthropic-compatible API endpoints. Especially trigger when a prompt supplies or asks to configure a base URL/baseURL, API key/apiKey, model name or ID, provider, Anthropic Messages API, or global/project mimocode.json/jsonc, or when the user asks how to enter or leave plan mode. Use this skill to inspect existing config safely, make minimal changes, and verify them without guessing schema fields or model capabilities." +description: "Use whenever the user asks about MiMoCode itself: features, TUI or CLI commands, keybindings, terminal compatibility, rendering glitches, TUI lag, SSH or remote rendering, agent modes (build / plan / compose) and how to switch between them, configuration, file locations, providers, models, authentication, or custom OpenAI-compatible or Anthropic-compatible API endpoints. Especially trigger when a prompt supplies or asks to configure a base URL/baseURL, API key/apiKey, model name or ID, provider, Anthropic Messages API, or global/project mimocode.json/jsonc, or when the user asks how to enter or leave plan mode. Use this skill to inspect existing config safely, make minimal changes, and verify them without guessing schema fields or model capabilities." --- # MiMoCode @@ -22,7 +22,7 @@ MiMoCode (CLI binary `mimo`) is an agentic coding tool with a terminal UI, built | **Task tree** | `T1`, `T1.1`… tree, integrated with checkpoints | `task` tooling | | **Goal / stop condition** | Judge model verifies a stop condition before the agent halts | `/goal` | | **Compose mode** | Structured spec→ship lifecycle; recommended entry is the `/compose-next` skill on Build. That skill sets `disable-model-invocation`, so only the user can start it — it is absent from the agent's skill catalog and from `skill_search`, and the `skill` tool refuses it. Suggest `/compose-next` to the user when the work warrants it; never enter the workflow unasked | `/compose-next` (see @reference/guide.md) | -| **Visual modes** | `vivid` (default: star field, meteors, logo effects, animated activity) and `minimal` (quiet visuals, stable activity indicators); independent from the animation override | `/vivid` or the `ctrl+p` Vivid mode setting | +| **Visual modes** | `vivid` (default: star field, meteors, logo effects, animated activity) and `minimal` (quiet visuals, stable activity indicators); independent from the animation override | `/vivid` or the visual-mode option in `ctrl+p` | | **Voice input** | Streaming ASR (TenVAD + MiMo ASR); needs `sox` | `/voice` | | **Dream** | Consolidates recent traces into project memory | `/dream` | | **Distill** | Packages repeated manual workflows into skills/subagents/commands | `/distill` | @@ -55,6 +55,7 @@ Read only the reference needed for the request, but read it before changing file - Models, providers, API keys, base URLs, or OpenAI-/Anthropic-compatible endpoints: @reference/providers.md - Other config keys and on-disk locations: @reference/config.md - Task-oriented usage and setup: @reference/guide.md +- Terminal compatibility, TUI rendering or lag, and SSH remote use: @reference/guide.md - CLI and slash commands: @reference/commands.md - Permission rules: @reference/permissions.md - MCP client-side sampling (servers borrowing your model, audio transcription): @reference/mcp-sampling.md @@ -62,7 +63,7 @@ Read only the reference needed for the request, but read it before changing file ## How-To Guide -For task-oriented walkthroughs — signing in & choosing a model, making memory remember project rules, writing custom slash commands, remapping keybinds, adding MCP servers, scheduling prompts (cron/loop), and using compose mode — see @reference/guide.md. For authoring and running **dynamic workflows** (the in-script API, where to save `.js` workflow files, and the `workflow` tool) see @reference/workflows.md. +For task-oriented walkthroughs — signing in & choosing a model, troubleshooting TUI rendering or lag, using MiMoCode over SSH, making memory remember project rules, writing custom slash commands, remapping keybinds, adding MCP servers, scheduling prompts (cron/loop), and using compose mode — see @reference/guide.md. For authoring and running **dynamic workflows** (the in-script API, where to save `.js` workflow files, and the `workflow` tool) see @reference/workflows.md. **Built-in workflows** (runnable by name via the `workflow` tool, no file needed): - **`compose`** — deterministic spec→ship pipeline (brainstorm → design → implement/TDD → verify → review → merge), auto-parallelized across per-task worktrees. Pass `args.task`. diff --git a/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/commands.md b/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/commands.md index 6b1cbd8a1..95ab75871 100644 --- a/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/commands.md +++ b/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/commands.md @@ -31,6 +31,8 @@ Run `mimo --help` for flags on any command. Notable TUI flags: `--continue`/`-c` (resume last session), `--session`/`-s`, `--model`/`-m`, `--agent`, `--never-ask`, `--trust`, and `--dangerously-skip-permissions` (auto-approve everything not explicitly denied; prompts once for confirmation — see permissions.md). +For terminal compatibility, TUI rendering or lag, and local rendering over SSH with `mimo serve` + `mimo attach`, see @guide.md. + ## Slash commands (inside the TUI) Type `/` to see the commands available in the current context. You can also ask in chat, for example, “Which slash commands can I use?” or “How do I switch models?” MiMoCode will explain the relevant command without requiring you to remember its name. diff --git a/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/guide.md b/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/guide.md index cc5fc6813..f8b502674 100644 --- a/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/guide.md +++ b/packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/guide.md @@ -10,6 +10,29 @@ How-to for the features users most often ask about. For config keys see @config. For a custom base URL, API key, or OpenAI-/Anthropic-compatible model, read @providers.md before editing config; it covers protocol selection, adapter names, provider reuse, secret handling, and local verification. +## TUI rendering, lag & remote use + +**macOS default terminal** — MiMoCode does not support the built-in Terminal.app. For misaligned output, flicker, or other rendering problems, use the VS Code integrated terminal or install iTerm2: + +```bash +brew install --cask iterm2 +``` + +**SSH rendering** — if running the TUI directly over SSH is slow, render it locally and run only the server from the remote project directory: + +```bash +# Remote host +mimo serve --port 4096 + +# Local host: keep this tunnel open +ssh -N -L 4096:127.0.0.1:4096 user@remote-host + +# Local host: connect from another terminal +mimo attach http://127.0.0.1:4096 +``` + +**Decorative animation** — run `/vivid`, or configure the visual-mode option in `ctrl+p`, to switch between Vivid and Minimal visuals as needed. The separate animation override can stop high-frequency motion without changing the selected visual mode. + ## Memory: making MiMoCode remember Memory persists across sessions and is auto-injected on resume, so the agent doesn't relearn project context. diff --git a/packages/opencode/test/skill/mimocode-docs.test.ts b/packages/opencode/test/skill/mimocode-docs.test.ts index 16cada03a..236b201f7 100644 --- a/packages/opencode/test/skill/mimocode-docs.test.ts +++ b/packages/opencode/test/skill/mimocode-docs.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import path from "path" +import { ConfigMarkdown } from "../../src/config" const root = path.resolve(import.meta.dir, "../../src/skill/builtin/.bundle/mimocode-docs") @@ -58,6 +59,27 @@ describe("mimocode-docs provider guidance", () => { }) }) +describe("mimocode-docs TUI troubleshooting", () => { + test("routes rendering issues to actionable terminal and SSH guidance", async () => { + const skill = await ConfigMarkdown.parse(path.join(root, "SKILL.md")) + const guide = await Bun.file(path.join(root, "reference/guide.md")).text() + const commands = await Bun.file(path.join(root, "reference/commands.md")).text() + + expect(skill.data.description).toContain("terminal compatibility, rendering glitches, TUI lag, SSH or remote rendering") + expect(skill.content).toContain("Terminal compatibility, TUI rendering or lag, and SSH remote use") + expect(guide).toContain("does not support the built-in Terminal.app") + expect(guide).toContain("brew install --cask iterm2") + expect(guide).toContain("mimo serve --port 4096") + expect(guide).toContain("ssh -N -L 4096:127.0.0.1:4096 user@remote-host") + expect(guide).toContain("mimo attach http://127.0.0.1:4096") + expect(guide).toContain("switch between Vivid and Minimal visuals as needed") + expect(guide).toContain("visual-mode option in `ctrl+p`") + expect(guide).toContain("separate animation override") + expect(guide).not.toContain("same persisted setting") + expect(commands).toContain("see @guide.md") + }) +}) + /** * These docs are fed to the model AS INSTRUCTIONS, so a misclassification here * does not merely read wrong — it teaches the model that a valid config shape is From f91ae8dc4b1287e306eaffbd6bca35cb034066d8 Mon Sep 17 00:00:00 2001 From: yanyihan Date: Wed, 5 Aug 2026 15:18:21 +0800 Subject: [PATCH 122/135] feat(tui): add vivid mode home tip --- .../src/cli/cmd/tui/feature-plugins/home/tips-view.tsx | 2 ++ packages/opencode/src/cli/cmd/tui/i18n/en.ts | 1 + packages/opencode/src/cli/cmd/tui/i18n/es.ts | 2 ++ packages/opencode/src/cli/cmd/tui/i18n/fr.ts | 2 ++ packages/opencode/src/cli/cmd/tui/i18n/ja.ts | 1 + packages/opencode/src/cli/cmd/tui/i18n/ru.ts | 2 ++ packages/opencode/src/cli/cmd/tui/i18n/zh.ts | 1 + packages/opencode/src/cli/cmd/tui/i18n/zht.ts | 1 + packages/opencode/test/cli/cmd/tui/tips-view.test.ts | 7 +++++++ 9 files changed, 19 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx index 4a91f5ba5..0dcede560 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx @@ -19,6 +19,7 @@ const PRIORITY_WEIGHTS: Record = { "tui.tips.free_models": 50, "tui.tips.free_api_sunset": 50, "tui.tips.background": 50, + "tui.tips.vivid": 40, "tui.tips.login": 40, "tui.tips.theme_mode": 40, "tui.tips.tab_agent": 40, @@ -33,6 +34,7 @@ const TIP_KEYS = [ "tui.tips.multi_skills", "tui.tips.free_models", "tui.tips.background", + "tui.tips.vivid", "tui.tips.theme_mode", "tui.tips.doc", "tui.tips.attach_file", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/en.ts b/packages/opencode/src/cli/cmd/tui/i18n/en.ts index 4154db4cb..54c137abc 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/en.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/en.ts @@ -61,6 +61,7 @@ export const dict: Record = { "Looking for a shortcut? Ask {highlight}Which slash commands can I use?{/highlight} directly in chat", "tui.tips.background": "Run {highlight}/background{/highlight} to set a custom image as your home background", + "tui.tips.vivid": "Run {highlight}/vivid{/highlight} to switch between Vivid and Minimal visuals as needed", "tui.tips.compose_next": "Try {highlight}/compose-next{/highlight} instead of the Compose agent for frontier models", "tui.tips.undo": "Use {highlight}/undo{/highlight} to revert the last message and file changes", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/es.ts b/packages/opencode/src/cli/cmd/tui/i18n/es.ts index 169ab9863..60805e70e 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/es.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/es.ts @@ -67,6 +67,8 @@ export const dict = { "¿Buscas un atajo? Pregunta {highlight}¿Qué comandos slash puedo usar?{/highlight} directamente en el chat", "tui.tips.background": "Ejecuta {highlight}/background{/highlight} para usar una imagen personalizada como fondo de inicio", + "tui.tips.vivid": + "Ejecuta {highlight}/vivid{/highlight} para alternar entre las vistas enriquecida y mínima según sea necesario", "tui.tips.compose_next": "Prueba {highlight}/compose-next{/highlight} en vez del agente Compose para modelos avanzados", "tui.tips.undo": diff --git a/packages/opencode/src/cli/cmd/tui/i18n/fr.ts b/packages/opencode/src/cli/cmd/tui/i18n/fr.ts index f5f383be3..8caae5a7a 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/fr.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/fr.ts @@ -67,6 +67,8 @@ export const dict = { "Vous cherchez un raccourci ? Demandez {highlight}Quelles commandes slash puis-je utiliser ?{/highlight} directement dans le chat", "tui.tips.background": "Exécutez {highlight}/background{/highlight} pour définir une image personnalisée comme fond d'écran d'accueil", + "tui.tips.vivid": + "Exécutez {highlight}/vivid{/highlight} pour basculer entre les affichages enrichi et minimal selon vos besoins", "tui.tips.compose_next": "Essayez {highlight}/compose-next{/highlight} au lieu de l'agent Compose pour les modèles avancés", "tui.tips.undo": "Utilisez {highlight}/undo{/highlight} pour annuler le dernier message et ses modifications", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/ja.ts b/packages/opencode/src/cli/cmd/tui/i18n/ja.ts index db3501fc9..c00685813 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/ja.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/ja.ts @@ -64,6 +64,7 @@ export const dict = { "tui.tips.ask_slash_commands": "ショートカットを探すには、チャットで {highlight}使えるスラッシュコマンドは?{/highlight} と直接質問できます", "tui.tips.background": "{highlight}/background{/highlight} を実行してホーム背景にお好みの画像を設定できます", + "tui.tips.vivid": "{highlight}/vivid{/highlight} で必要に応じてリッチ表示とミニマル表示を切り替えます", "tui.tips.compose_next": "{highlight}/compose-next{/highlight} を推奨(強力なモデル向け・Compose 代替)", "tui.tips.undo": "{highlight}/undo{/highlight} で直前のメッセージとファイル変更を取り消します", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/ru.ts b/packages/opencode/src/cli/cmd/tui/i18n/ru.ts index c65d8aa36..491345379 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/ru.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/ru.ts @@ -66,6 +66,8 @@ export const dict = { "Ищете команду? Спросите {highlight}Какие slash-команды я могу использовать?{/highlight} прямо в чате", "tui.tips.background": "Выполните {highlight}/background{/highlight}, чтобы установить произвольное изображение в качестве фона главной страницы", + "tui.tips.vivid": + "Выполните {highlight}/vivid{/highlight}, чтобы при необходимости переключаться между расширенным и минимальным оформлением", "tui.tips.compose_next": "Попробуйте {highlight}/compose-next{/highlight} вместо агента Compose для передовых моделей", "tui.tips.undo": diff --git a/packages/opencode/src/cli/cmd/tui/i18n/zh.ts b/packages/opencode/src/cli/cmd/tui/i18n/zh.ts index 9bda3e13f..2c85ae35a 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/zh.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/zh.ts @@ -63,6 +63,7 @@ export const dict = { "tui.tips.ask_slash_commands": "想找快捷指令?直接在聊天中问 {highlight}有哪些 slash 快捷指令?{/highlight}", "tui.tips.background": "运行 {highlight}/background{/highlight} 设置自定义图片作为主页背景", + "tui.tips.vivid": "运行 {highlight}/vivid{/highlight},根据需要在丰富视觉模式和简洁模式间切换", "tui.tips.compose_next": "推荐前沿模型使用 {highlight}/compose-next{/highlight} 代替 Compose 智能体", "tui.tips.undo": "使用 {highlight}/undo{/highlight} 撤销最后一条消息及其文件改动", diff --git a/packages/opencode/src/cli/cmd/tui/i18n/zht.ts b/packages/opencode/src/cli/cmd/tui/i18n/zht.ts index f2eeb42ee..8ad2c10aa 100644 --- a/packages/opencode/src/cli/cmd/tui/i18n/zht.ts +++ b/packages/opencode/src/cli/cmd/tui/i18n/zht.ts @@ -63,6 +63,7 @@ export const dict = { "tui.tips.ask_slash_commands": "想找快捷指令?直接在聊天中問 {highlight}有哪些 slash 快捷指令?{/highlight}", "tui.tips.background": "執行 {highlight}/background{/highlight} 設定自訂圖片作為主頁背景", + "tui.tips.vivid": "執行 {highlight}/vivid{/highlight},根據需要在豐富視覺模式和簡潔模式間切換", "tui.tips.compose_next": "推薦前沿模型使用 {highlight}/compose-next{/highlight} 代替 Compose 智慧體", "tui.tips.undo": "使用 {highlight}/undo{/highlight} 復原最後一條訊息及其檔案變更", diff --git a/packages/opencode/test/cli/cmd/tui/tips-view.test.ts b/packages/opencode/test/cli/cmd/tui/tips-view.test.ts index 1aad0e607..2240f645b 100644 --- a/packages/opencode/test/cli/cmd/tui/tips-view.test.ts +++ b/packages/opencode/test/cli/cmd/tui/tips-view.test.ts @@ -20,6 +20,13 @@ describe("buildTipKeys", () => { Array.of(en, es, fr, ja, ru, zh, zht).forEach((dict) => expect(dict[key]).toBeTruthy()) }) + test("includes localized guidance for toggling visual modes", () => { + const key = "tui.tips.vivid" + expect(buildTipKeys(false, "linux")).toContain(key) + expect(tipWeight(key)).toBe(tipWeight("tui.tips.theme_mode")) + Array.of(en, es, fr, ja, ru, zh, zht).forEach((dict) => expect(dict[key]).toContain("{highlight}/vivid{/highlight}")) + }) + test("omits the Orchestrator tab tip when the flag is off", () => { const keys = buildTipKeys(false, "linux") expect(keys).toContain("tui.tips.tab_agent") From 8061d5fa0f977c9d70d3848c9c9f46c29668a3d4 Mon Sep 17 00:00:00 2001 From: qiaozongming Date: Wed, 5 Aug 2026 17:01:14 +0800 Subject: [PATCH 123/135] chore: bump version to 0.1.10 (#2034) --- bun.lock | 30 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/shared/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 17 files changed, 31 insertions(+), 31 deletions(-) diff --git a/bun.lock b/bun.lock index 94896eca8..8916a2f47 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ }, "packages/app": { "name": "@mimo-ai/app", - "version": "0.1.9", + "version": "0.1.10", "dependencies": { "@kobalte/core": "catalog:", "@mimo-ai/sdk": "workspace:*", @@ -81,7 +81,7 @@ }, "packages/console/app": { "name": "@mimo-ai/console-app", - "version": "0.1.9", + "version": "0.1.10", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -115,7 +115,7 @@ }, "packages/console/core": { "name": "@mimo-ai/console-core", - "version": "0.1.9", + "version": "0.1.10", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -142,7 +142,7 @@ }, "packages/console/function": { "name": "@mimo-ai/console-function", - "version": "0.1.9", + "version": "0.1.10", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -166,7 +166,7 @@ }, "packages/console/mail": { "name": "@mimo-ai/console-mail", - "version": "0.1.9", + "version": "0.1.10", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -190,7 +190,7 @@ }, "packages/desktop": { "name": "@mimo-ai/desktop", - "version": "0.1.9", + "version": "0.1.10", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -233,7 +233,7 @@ }, "packages/enterprise": { "name": "@mimo-ai/enterprise", - "version": "0.1.9", + "version": "0.1.10", "dependencies": { "@mimo-ai/shared": "workspace:*", "@mimo-ai/ui": "workspace:*", @@ -262,7 +262,7 @@ }, "packages/function": { "name": "@mimo-ai/function", - "version": "0.1.9", + "version": "0.1.10", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -278,7 +278,7 @@ }, "packages/opencode": { "name": "@mimo-ai/cli", - "version": "0.1.9", + "version": "0.1.10", "bin": { "mimo": "./bin/mimo", }, @@ -433,7 +433,7 @@ }, "packages/plugin": { "name": "@mimo-ai/plugin", - "version": "0.1.9", + "version": "0.1.10", "dependencies": { "@mimo-ai/sdk": "workspace:*", "effect": "catalog:", @@ -468,7 +468,7 @@ }, "packages/sdk/js": { "name": "@mimo-ai/sdk", - "version": "0.1.9", + "version": "0.1.10", "dependencies": { "cross-spawn": "catalog:", }, @@ -483,7 +483,7 @@ }, "packages/shared": { "name": "@mimo-ai/shared", - "version": "0.1.9", + "version": "0.1.10", "bin": { "opencode": "./bin/opencode", }, @@ -507,7 +507,7 @@ }, "packages/slack": { "name": "@mimo-ai/slack", - "version": "0.1.9", + "version": "0.1.10", "dependencies": { "@mimo-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -542,7 +542,7 @@ }, "packages/ui": { "name": "@mimo-ai/ui", - "version": "0.1.9", + "version": "0.1.10", "dependencies": { "@kobalte/core": "catalog:", "@mimo-ai/sdk": "workspace:*", @@ -591,7 +591,7 @@ }, "packages/web": { "name": "@mimo-ai/web", - "version": "0.1.9", + "version": "0.1.10", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 047510011..c51b74089 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@mimo-ai/app", - "version": "0.1.9", + "version": "0.1.10", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index e40fa563e..a753a564c 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@mimo-ai/console-app", - "version": "0.1.9", + "version": "0.1.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index e99f91a7c..598723a73 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@mimo-ai/console-core", - "version": "0.1.9", + "version": "0.1.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 0a11a7c06..eaa56bea7 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@mimo-ai/console-function", - "version": "0.1.9", + "version": "0.1.10", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 1bc1e3e7f..231bb2a49 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@mimo-ai/console-mail", - "version": "0.1.9", + "version": "0.1.10", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index bd619ed4f..160a73979 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@mimo-ai/desktop", "private": true, - "version": "0.1.9", + "version": "0.1.10", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 1885291eb..2d6550973 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@mimo-ai/enterprise", - "version": "0.1.9", + "version": "0.1.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index 19bce5d0f..e78eb02a8 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@mimo-ai/function", - "version": "0.1.9", + "version": "0.1.10", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 8fc24d151..f15cfdcc9 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "0.1.9", + "version": "0.1.10", "name": "@mimo-ai/cli", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 044769fb1..6bbcaeaa5 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@mimo-ai/plugin", - "version": "0.1.9", + "version": "0.1.10", "description": "Plugin SDK for extending MiMoCode", "keywords": ["mimo", "mimocode", "ai", "plugin", "coding-assistant"], "type": "module", diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 1ac0b882d..ceb84f993 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@mimo-ai/sdk", - "version": "0.1.9", + "version": "0.1.10", "description": "TypeScript SDK for the MiMoCode API", "keywords": ["mimo", "mimocode", "ai", "sdk", "coding-assistant"], "type": "module", diff --git a/packages/shared/package.json b/packages/shared/package.json index 7976d57af..a5d75733b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "0.1.9", + "version": "0.1.10", "name": "@mimo-ai/shared", "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index 4c44af932..6000b862f 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@mimo-ai/slack", - "version": "0.1.9", + "version": "0.1.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 7f0afd574..3808b8500 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@mimo-ai/ui", - "version": "0.1.9", + "version": "0.1.10", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index e5cae0c0a..dbfe2592a 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@mimo-ai/web", "type": "module", "license": "MIT", - "version": "0.1.9", + "version": "0.1.10", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 7151d8a00..6eb868352 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "0.1.9", + "version": "0.1.10", "publisher": "sst-dev", "repository": { "type": "git", From e8f43264624dc287528e060c23a80957817a1d54 Mon Sep 17 00:00:00 2001 From: yanyihan Date: Wed, 5 Aug 2026 19:19:04 +0800 Subject: [PATCH 124/135] fix(cli): normalize command output newlines --- packages/opencode/src/cli/cmd/generate.ts | 3 ++- packages/opencode/src/cli/ui.ts | 4 ++++ packages/opencode/src/index.ts | 4 ++-- packages/opencode/test/cli/ui.test.ts | 21 +++++++++++++++++++++ 4 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/test/cli/ui.test.ts diff --git a/packages/opencode/src/cli/cmd/generate.ts b/packages/opencode/src/cli/cmd/generate.ts index 21b4b31fc..6d70b9820 100644 --- a/packages/opencode/src/cli/cmd/generate.ts +++ b/packages/opencode/src/cli/cmd/generate.ts @@ -1,5 +1,6 @@ import { Server } from "../../server/server" import type { CommandModule } from "yargs" +import { UI } from "../ui" export const GenerateCommand = { command: "generate", @@ -41,7 +42,7 @@ export const GenerateCommand = { // Wait for stdout to finish writing before process.exit() is called await new Promise((resolve, reject) => { - process.stdout.write(json, (err) => { + process.stdout.write(UI.withTrailingEOL(json), (err) => { if (err) reject(err) else resolve() }) diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts index 1c8607d84..5ac4140d8 100644 --- a/packages/opencode/src/cli/ui.ts +++ b/packages/opencode/src/cli/ui.ts @@ -39,6 +39,10 @@ export function print(...message: string[]) { process.stderr.write(message.join(" ")) } +export function withTrailingEOL(text: string) { + return text.replace(/[\r\n]+$/, "") + EOL +} + let blank = false export function empty() { if (blank) return diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index ec991edd7..532f2deb9 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -64,10 +64,10 @@ function show(out: string) { const text = out.trimStart() if (!text.startsWith("mimo ")) { process.stderr.write(UI.logo() + EOL + EOL) - process.stderr.write(text) + process.stderr.write(UI.withTrailingEOL(text)) return } - process.stderr.write(out) + process.stderr.write(UI.withTrailingEOL(out)) } const cli = yargs(args) diff --git a/packages/opencode/test/cli/ui.test.ts b/packages/opencode/test/cli/ui.test.ts new file mode 100644 index 000000000..47d9fbef8 --- /dev/null +++ b/packages/opencode/test/cli/ui.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test" +import { EOL } from "os" +import { UI } from "../../src/cli/ui" + +describe("cli.ui", () => { + test("adds one trailing EOL when output is missing one", () => { + expect(UI.withTrailingEOL("help")).toBe("help" + EOL) + }) + + test("does not duplicate an existing trailing EOL", () => { + expect(UI.withTrailingEOL("help" + EOL)).toBe("help" + EOL) + }) + + test("normalizes multiple and mixed trailing line endings", () => { + expect(UI.withTrailingEOL("help\n\r\n\n")).toBe("help" + EOL) + }) + + test("returns one EOL for empty output", () => { + expect(UI.withTrailingEOL("")).toBe(EOL) + }) +}) From db4f4a760e9dfec5dd4d42c097c5ee434e442bf7 Mon Sep 17 00:00:00 2001 From: fanhuanjie Date: Wed, 5 Aug 2026 19:30:32 +0800 Subject: [PATCH 125/135] fix(agent): align subagent prompts with runtime tool schemas Base agent prompts hardcoded Claude-style tool names (Glob/Grep/Read/Bash) and generate.txt told the model to use a nonexistent "Agent tool", so GPT models were instructed to call tools absent from their schema. Keep the base prompts provider-neutral and move exact GPT tool contracts into a conditional fragment instead. - explore.txt: reference the tools exposed in the current turn, with an rg/rg --files shell fallback; add parent-agent delegation semantics - generate.txt: say "actor tool" (the real tool) instead of "Agent tool" - generate-gpt.txt: new GPT-only fragment (exec/apply_patch/view_image/actor) appended by usesGPTToolset(), leaving non-GPT paths untouched - general.txt: dedicated prompt for the general subagent - add Agent.Info.completionGate so general keeps RETURN_FORMAT_INSTRUCTION now that it carries its own prompt --- packages/opencode/src/actor/spawn.ts | 10 +++--- packages/opencode/src/agent/agent.ts | 8 ++++- packages/opencode/src/agent/generate.txt | 6 ++-- .../opencode/src/agent/prompt/explore.txt | 15 +++++---- .../opencode/src/agent/prompt/general.txt | 19 +++++++++++ .../src/agent/prompt/generate-gpt.txt | 7 ++++ packages/opencode/test/agent/agent.test.ts | 32 +++++++++++++++++++ packages/opencode/test/session/system.test.ts | 6 +++- 8 files changed, 86 insertions(+), 17 deletions(-) create mode 100644 packages/opencode/src/agent/prompt/general.txt create mode 100644 packages/opencode/src/agent/prompt/generate-gpt.txt diff --git a/packages/opencode/src/actor/spawn.ts b/packages/opencode/src/actor/spawn.ts index a57987eaf..a9642cdd1 100644 --- a/packages/opencode/src/actor/spawn.ts +++ b/packages/opencode/src/actor/spawn.ts @@ -846,13 +846,13 @@ export const layer = Layer.effect( forkContexts.set(actorID, input.forkContext) } - // Auto-inject return-format instruction for non-specialized subagents. - // Excluded: agents with hardcoded `prompt` (explore/title/summary — own - // contracts), checkpoint-writer (special — task is itself a complete - // writer-instruction string), and peer mode (routes via spawnPeer). + // Auto-inject return-format instruction for lifecycle-managed subagents. + // Agents with an explicit completionGate keep this behavior even when + // they also provide a dedicated system prompt. const agentInfo = yield* agents.get(input.agentType) const gateEligible = - agentInfo?.mode === "subagent" && !agentInfo?.prompt && input.agentType !== "checkpoint-writer" + agentInfo?.mode === "subagent" && + (agentInfo.completionGate === true || (!agentInfo.prompt && input.agentType !== "checkpoint-writer")) const taskWithFormat = gateEligible ? input.task + RETURN_FORMAT_INSTRUCTION : input.task const { fiber, outcome } = yield* forkWork({ diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index d2832b86a..d720e0f65 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -6,10 +6,13 @@ import { ModelID, ProviderID } from "../provider/schema" import { generateObject, streamObject, type ModelMessage } from "ai" import { Instance } from "../project/instance" import { Truncate } from "../tool" +import { usesGPTToolset } from "../tool/gpt" import { Auth } from "../auth" import { ProviderTransform } from "../provider" import PROMPT_GENERATE from "./generate.txt" +import PROMPT_GENERATE_GPT from "./prompt/generate-gpt.txt" +import PROMPT_GENERAL from "./prompt/general.txt" import PROMPT_EXPLORE from "./prompt/explore.txt" import PROMPT_DREAM from "./prompt/dream.txt" import PROMPT_DISTILL from "./prompt/distill.txt" @@ -52,6 +55,7 @@ export const Info = z modelRef: z.string().optional(), variant: z.string().optional(), prompt: z.string().optional(), + completionGate: z.boolean().optional(), options: z.record(z.string(), z.any()), steps: z.number().int().positive().optional(), toolAllowlist: z.array(z.string()).optional(), @@ -260,6 +264,8 @@ export const layer = Layer.effect( ), options: {}, mode: "subagent", + prompt: PROMPT_GENERAL, + completionGate: true, native: true, }, explore: { @@ -563,7 +569,7 @@ export const layer = Layer.effect( ? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer)) : undefined - const system = [PROMPT_GENERATE] + const system = [PROMPT_GENERATE, ...(usesGPTToolset(resolved.id) ? [PROMPT_GENERATE_GPT] : [])] yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system }) const existing = yield* InstanceState.useEffect(state, (s) => s.list()) diff --git a/packages/opencode/src/agent/generate.txt b/packages/opencode/src/agent/generate.txt index 387a7f967..0ab4839c6 100644 --- a/packages/opencode/src/agent/generate.txt +++ b/packages/opencode/src/agent/generate.txt @@ -41,12 +41,12 @@ When a user describes what they want an agent to do, you will: assistant: "Here is the relevant function: " - Since the user is greeting, use the actor tool to launch the greeting-responder agent to respond with a friendly joke. + Since the user is asking for a code review, use the actor tool to launch the code-reviewer agent after the implementation is complete. assistant: "Now let me use the code-reviewer agent to review the code" - - Context: User is creating an agent to respond to the word "hello" with a friendly jok. + Context: User is creating an agent to respond to the word "hello" with a friendly joke. user: "Hello" assistant: "I'm going to use the actor tool to launch the greeting-responder agent to respond with a friendly joke" @@ -54,7 +54,7 @@ When a user describes what they want an agent to do, you will: - If the user mentioned or implied that the agent should be used proactively, you should include examples of this. -- NOTE: Ensure that in the examples, you are making the assistant use the Agent tool and not simply respond directly to the task. +- NOTE: Ensure that in the examples, you are making the assistant use the actor tool and not simply respond directly to the task. Your output must be a valid JSON object with exactly these fields: { diff --git a/packages/opencode/src/agent/prompt/explore.txt b/packages/opencode/src/agent/prompt/explore.txt index 5761077cb..5f1d78294 100644 --- a/packages/opencode/src/agent/prompt/explore.txt +++ b/packages/opencode/src/agent/prompt/explore.txt @@ -1,4 +1,6 @@ -You are a file search specialist. You excel at thoroughly navigating and exploring codebases. +You are a file search specialist working for a parent agent. You excel at thoroughly navigating and exploring codebases. + +The delegated search request comes from the parent agent, and your result is returned to that parent agent. Do not address the end user, send user-facing progress updates, ask the end user questions, or offer follow-up work. Your strengths: - Rapidly finding files using glob patterns @@ -6,13 +8,12 @@ Your strengths: - Reading and analyzing file contents Guidelines: -- Use Glob for broad file pattern matching -- Use Grep for searching file contents with regex -- Use Read when you know the specific file path you need to read -- Use Bash for file operations like copying, moving, or listing directory contents +- Use the file-search, content-search, file-reading, and shell tools exposed in the current turn. Tool names and availability are model-specific; never invent or assume a tool that is not in the current tool list. +- For broad file pattern matching, use the exposed glob/file-listing capability. For content searches, use the exposed grep/search capability. When those dedicated tools are unavailable, use the shell tool with targeted `rg` or `rg --files` commands. +- Use the shell tool for read-only file operations such as listing directories or inspecting metadata. Do not modify files or repository state. - Adapt your search approach based on the thoroughness level specified by the caller -- Return file paths as absolute paths in your final response +- Return file paths as absolute paths in your final result to the parent agent - For clear communication, avoid using emojis - Do not create any files, or run bash commands that modify the user's system state in any way -Complete the user's search request efficiently and report your findings clearly. +Complete the delegated search request efficiently and report your findings clearly to the parent agent. diff --git a/packages/opencode/src/agent/prompt/general.txt b/packages/opencode/src/agent/prompt/general.txt new file mode 100644 index 000000000..5453d7321 --- /dev/null +++ b/packages/opencode/src/agent/prompt/general.txt @@ -0,0 +1,19 @@ +You are an agent for MiMoCode, Xiaomi's official CLI for MiMo. Given the user's message, you should use the tools available to complete the task. Complete the task fully—don't gold-plate, but don't leave it half-done. + +When you complete the task, respond with a concise report covering what was done and any key findings — the caller will relay this to the user, so it only needs the essentials. + +Your strengths: + +- Searching for code, configurations, and patterns across large codebases +- Analyzing multiple files to understand system architecture +- Investigating complex questions that require exploring many files +- Performing multi-step research tasks + +Guidelines: + +- For file searches: search broadly when you don't know where something lives. Use Read when you know the specific file path. +- For analysis: Start broad and narrow down. Use multiple search strategies if the first doesn't yield results. +- Be thorough: Check multiple locations, consider different naming conventions, look for related files. +- Do not create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. +- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested. +- You are already the dedicated agent for this task. Do the work directly — do not re-delegate your entire assignment to another single subagent. diff --git a/packages/opencode/src/agent/prompt/generate-gpt.txt b/packages/opencode/src/agent/prompt/generate-gpt.txt new file mode 100644 index 000000000..0377337c7 --- /dev/null +++ b/packages/opencode/src/agent/prompt/generate-gpt.txt @@ -0,0 +1,7 @@ +GPT generation compatibility rules: + +- The generated agent will run with the tools exposed by its selected model. Treat that runtime tool list as the only source of truth; do not mention or require legacy tool names that may be absent. +- For delegation examples, use the `actor` tool and its `subagent_type` field. Do not write `Agent tool`, `Agent`, or another invented tool name. +- For GPT-5-family agents, describe file inspection through `exec` with targeted `rg`, `rg --files`, and `sed -n` commands when a dedicated file tool is not exposed. Describe edits through `apply_patch`, and visual inspection through `view_image` when relevant. +- Keep generated instructions model-agnostic where possible. Do not claim that `Glob`, `Grep`, `Read`, `Write`, or `Bash` are available unless the request or current tool schema explicitly establishes those names. +- Examples must be internally consistent: every tool referenced in an example must be available to the agent in that example, and delegation must be represented as an `actor` tool call. diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index b7b2ba37f..b77353ec3 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -9,6 +9,9 @@ import { ToolRegistry } from "../../src/tool" import { ModelID, ProviderID } from "../../src/provider/schema" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { testEffect } from "../lib/effect" +import PROMPT_GENERATE from "../../src/agent/generate.txt" +import PROMPT_GENERATE_GPT from "../../src/agent/prompt/generate-gpt.txt" +import PROMPT_EXPLORE from "../../src/agent/prompt/explore.txt" const itTool = testEffect( Layer.mergeAll(ToolRegistry.defaultLayer, Agent.defaultLayer, CrossSpawnSpawner.defaultLayer), @@ -28,6 +31,19 @@ afterEach(async () => { await Instance.disposeAll() }) +test("agent prompts use runtime tool names and GPT generation guidance", () => { + expect(PROMPT_EXPLORE).toContain("Tool names and availability are model-specific") + expect(PROMPT_EXPLORE).not.toContain("Use Glob") + expect(PROMPT_EXPLORE).not.toContain("Use Grep") + expect(PROMPT_EXPLORE).not.toContain("Use Read") + expect(PROMPT_GENERATE).toContain("use the actor tool") + expect(PROMPT_GENERATE).not.toContain("use the Agent tool") + expect(PROMPT_GENERATE_GPT).toContain("`exec`") + expect(PROMPT_GENERATE_GPT).toContain("`apply_patch`") + expect(PROMPT_GENERATE_GPT).toContain("`view_image`") + expect(PROMPT_GENERATE_GPT).toContain("`actor`") +}) + test("returns default native agents when no config", async () => { await using tmp = await tmpdir() await Instance.provide({ @@ -215,6 +231,22 @@ test("explore agent asks for external directories and allows Truncate.GLOB", asy }) }) +test("general and explore agents use dedicated prompts", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const general = await load(tmp.path, (svc) => svc.get("general")) + const explore = await load(tmp.path, (svc) => svc.get("explore")) + expect(general?.prompt).toContain("You are an agent for MiMoCode") + expect(general?.prompt).toContain("the caller will relay this to the user") + expect(general?.completionGate).toBe(true) + expect(explore?.prompt).toContain("file search specialist working for a parent agent") + expect(explore?.prompt).not.toBe(general?.prompt) + }, + }) +}) + test("custom agent from config creates new agent", async () => { await using tmp = await tmpdir({ diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 719b5b890..ba1b1b57c 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -168,6 +168,7 @@ describe("session.system", () => { { name: "general", mode: "subagent", + prompt: "You are an agent for MiMoCode. The caller will relay this to the user.", permission: [], options: {}, }, @@ -179,7 +180,10 @@ describe("session.system", () => { expect(prompt).toContain("Use `apply_patch` for project text edits") expect(prompt).toContain("Use `view_image`") expect(prompt).toContain("`rg --files`") - expect(general).toContain("On GPT models, use `exec` as the main composition surface") + expect(general).toContain("You are an agent for MiMoCode.") + expect(general).toContain("The caller will relay this to the user.") + expect(general).toContain("Use `exec` as the main composition surface") + expect(general).not.toContain("# Working with the user") }) test("prefers the catalog model ID when the API deployment ID is opaque", () => { From faedc0bdb020c2ba74de67d61a992431240ed110 Mon Sep 17 00:00:00 2001 From: fanhuanjie Date: Wed, 5 Aug 2026 20:24:35 +0800 Subject: [PATCH 126/135] feat(agent): enable full-capability general subagents - allow general subagents to use the inherited runtime tool surface\n- update prompts, tests, and documentation for end-to-end delegated work --- packages/opencode/src/agent/agent.ts | 15 +++++------ .../opencode/src/agent/prompt/general.txt | 26 +++++++++---------- .../opencode/src/session/prompt/default.txt | 4 +-- packages/opencode/test/agent/agent.test.ts | 23 +++++++++++----- packages/opencode/test/session/system.test.ts | 6 ++--- packages/web/src/content/docs/agents.mdx | 2 +- 6 files changed, 40 insertions(+), 36 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index d720e0f65..afcd51a1e 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -254,14 +254,9 @@ export const layer = Layer.effect( general: { name: "general", color: "#aac4e1", - description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, - permission: Permission.merge( - defaults, - Permission.fromConfig({ - change_directory: "deny", - }), - user, - ), + description: + "Full-capability general-purpose subagent for autonomous read/write work, including investigation, implementation, debugging, testing, and multi-step delivery. It inherits the parent's available tool surface and can complete a delegated task end to end.", + permission: Permission.merge(defaults, user), options: {}, mode: "subagent", prompt: PROMPT_GENERAL, @@ -494,7 +489,9 @@ export const layer = Layer.effect( const agent = agents[name] const globs = whitelistedDirs.filter( (glob) => - !agent.permission.some((r) => r.permission === "external_directory" && r.action === "deny" && r.pattern === glob), + !agent.permission.some( + (r) => r.permission === "external_directory" && r.action === "deny" && r.pattern === glob, + ), ) if (globs.length === 0) continue diff --git a/packages/opencode/src/agent/prompt/general.txt b/packages/opencode/src/agent/prompt/general.txt index 5453d7321..92f56140d 100644 --- a/packages/opencode/src/agent/prompt/general.txt +++ b/packages/opencode/src/agent/prompt/general.txt @@ -1,19 +1,17 @@ -You are an agent for MiMoCode, Xiaomi's official CLI for MiMo. Given the user's message, you should use the tools available to complete the task. Complete the task fully—don't gold-plate, but don't leave it half-done. +You are a full-capability general-purpose subagent for MiMoCode, Xiaomi's official CLI for MiMo. A parent agent has delegated a bounded task to you. Own that task and complete it end to end. -When you complete the task, respond with a concise report covering what was done and any key findings — the caller will relay this to the user, so it only needs the essentials. +You inherit the tool surface available from the parent runtime. The tools exposed in this turn are the source of truth. Use any of them needed for the assignment, including reading and searching, editing or creating files, running commands, inspecting visual assets, and validating the result. Tool names vary by model, so never invent a tool or assume a legacy tool is present. -Your strengths: +Work autonomously: -- Searching for code, configurations, and patterns across large codebases -- Analyzing multiple files to understand system architecture -- Investigating complex questions that require exploring many files -- Performing multi-step research tasks +- Inspect the relevant implementation, tests, configuration, instructions, and current workspace state before making consequential changes. +- For implementation tasks, make the smallest complete change that satisfies the request, preserve unrelated user changes, and follow established project patterns. +- Carry work through verification. Run focused tests or checks first, broaden them when the change has wider risk, and report any check you could not run. +- For investigation or review tasks, return concrete evidence with file and line references. Do not modify files unless the delegated task includes implementation or fixes. +- Use read and write capabilities freely when they are required by the task. Do not stop at recommendations when the assignment asks for a working change. +- Keep external side effects within the authority granted by the parent task. Do not publish, push, message people, or perform destructive operations unless explicitly authorized. +- You may delegate genuinely independent, bounded subtasks when that improves throughput, but do not hand your entire assignment to another agent. -Guidelines: +The parent agent, not you, communicates with the end user. Do not ask the end user questions or send user-facing progress updates. If essential information is missing, investigate first; if still blocked, explain the exact blocker in your final response to the parent. -- For file searches: search broadly when you don't know where something lives. Use Read when you know the specific file path. -- For analysis: Start broad and narrow down. Use multiple search strategies if the first doesn't yield results. -- Be thorough: Check multiple locations, consider different naming conventions, look for related files. -- Do not create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. -- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested. -- You are already the dedicated agent for this task. Do the work directly — do not re-delegate your entire assignment to another single subagent. +When finished, respond with a concise report of the outcome, verification, files changed, and any residual risk or blocker. The caller will relay the relevant parts to the user. diff --git a/packages/opencode/src/session/prompt/default.txt b/packages/opencode/src/session/prompt/default.txt index 50dfc542b..0fbaa6608 100644 --- a/packages/opencode/src/session/prompt/default.txt +++ b/packages/opencode/src/session/prompt/default.txt @@ -61,7 +61,7 @@ Primary agents shipped in-box: - **max** (experimental, opt-in via `experimental.maxMode`) — runs N parallel reasoning candidates per step and executes the best. Subagents shipped in-box: -- **general** — general-purpose multi-step worker. `change_directory: deny` so it stays pinned to the caller's cwd. +- **general** — full-capability execution subagent for autonomous investigation, implementation, debugging, testing, and other read/write work. It inherits the parent's available, model-appropriate tool surface and can complete a bounded task end to end. - **explore** — fast, READ-ONLY codebase explorer. Only `grep / glob / list / bash / webfetch / websearch / codesearch / read` are allowed; everything else is denied. Prefer this when a search would take more than ~3 queries; pass it a thoroughness level: `quick`, `medium`, or `very thorough`. - **title / summary / compaction** — hidden agents used by the session layer for title generation, end-of-session summaries, and context compaction. Their tool allowlists are empty. - **checkpoint-writer** — a *fork agent*. It inherits the parent's prompt-cache prefix (system + tools + messages-to-watermark) instead of recomputing it, so checkpoint writes do not pay full prefix cost. Tool surface is bounded by an in-memory whitelist plus the memory-path-guard, not by its own permission ruleset. @@ -167,4 +167,4 @@ In code: default to writing no comments. Never write multi-paragraph docstrings ## Session-specific guidance - Use the Agent tool with specialized agents when the task at hand matches the agent's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but they should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing - if you delegate research to a subagent, do not also perform the same searches yourself. - For broad codebase exploration or research that'll take more than 3 queries, spawn Agent with subagent_type=Explore. Otherwise use the Glob or Grep directly. - - When the user types `/`, invoke it via Skill. Only use skills listed in the user-invocable skills section — don't guess. \ No newline at end of file + - When the user types `/`, invoke it via Skill. Only use skills listed in the user-invocable skills section — don't guess. diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index b77353ec3..38e00ec4b 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -13,9 +13,7 @@ import PROMPT_GENERATE from "../../src/agent/generate.txt" import PROMPT_GENERATE_GPT from "../../src/agent/prompt/generate-gpt.txt" import PROMPT_EXPLORE from "../../src/agent/prompt/explore.txt" -const itTool = testEffect( - Layer.mergeAll(ToolRegistry.defaultLayer, Agent.defaultLayer, CrossSpawnSpawner.defaultLayer), -) +const itTool = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, Agent.defaultLayer, CrossSpawnSpawner.defaultLayer)) // Helper to evaluate permission for a tool with wildcard pattern function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined { @@ -167,7 +165,9 @@ test("compose:* skills are denied for build/plan, allowed for compose", async () expect(Permission.evaluate("skill", "compose:tdd", compose!.permission).action).toBe("allow") expect(Permission.evaluate("skill", "compose:review", compose!.permission).action).toBe("allow") // Non-compose skills remain allowed for all agents - expect(Permission.evaluate("skill", "effect", agents.find((a) => a.name === "build")!.permission).action).toBe("allow") + expect(Permission.evaluate("skill", "effect", agents.find((a) => a.name === "build")!.permission).action).toBe( + "allow", + ) expect(Permission.evaluate("skill", "effect", compose!.permission).action).toBe("allow") }, }) @@ -238,16 +238,25 @@ test("general and explore agents use dedicated prompts", async () => { fn: async () => { const general = await load(tmp.path, (svc) => svc.get("general")) const explore = await load(tmp.path, (svc) => svc.get("explore")) - expect(general?.prompt).toContain("You are an agent for MiMoCode") - expect(general?.prompt).toContain("the caller will relay this to the user") + expect(general?.description).toContain("Full-capability general-purpose subagent") + expect(general?.description).toContain("inherits the parent's available tool surface") + expect(general?.prompt).toContain("full-capability general-purpose subagent") + expect(general?.prompt).toContain("including reading and searching, editing or creating files") + expect(general?.prompt).toContain("complete it end to end") + expect(general?.prompt).toContain("The parent agent, not you, communicates with the end user") expect(general?.completionGate).toBe(true) + expect(general?.toolAllowlist).toBeUndefined() + expect(Permission.evaluate("read", "src/index.ts", general!.permission).action).toBe("allow") + expect(Permission.evaluate("edit", "src/index.ts", general!.permission).action).toBe("allow") + expect(Permission.evaluate("write", "src/index.ts", general!.permission).action).toBe("allow") + expect(Permission.evaluate("bash", "bun test", general!.permission).action).toBe("allow") + expect(Permission.evaluate("change_directory", "/tmp/project", general!.permission).action).toBe("allow") expect(explore?.prompt).toContain("file search specialist working for a parent agent") expect(explore?.prompt).not.toBe(general?.prompt) }, }) }) - test("custom agent from config creates new agent", async () => { await using tmp = await tmpdir({ config: { diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index ba1b1b57c..25c33248c 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -168,7 +168,7 @@ describe("session.system", () => { { name: "general", mode: "subagent", - prompt: "You are an agent for MiMoCode. The caller will relay this to the user.", + prompt: "You are a full-capability general-purpose subagent. The parent agent communicates with the end user.", permission: [], options: {}, }, @@ -180,8 +180,8 @@ describe("session.system", () => { expect(prompt).toContain("Use `apply_patch` for project text edits") expect(prompt).toContain("Use `view_image`") expect(prompt).toContain("`rg --files`") - expect(general).toContain("You are an agent for MiMoCode.") - expect(general).toContain("The caller will relay this to the user.") + expect(general).toContain("You are a full-capability general-purpose subagent.") + expect(general).toContain("The parent agent communicates with the end user.") expect(general).toContain("Use `exec` as the main composition surface") expect(general).not.toContain("# Working with the user") }) diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index 5522f77aa..c4c121591 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -72,7 +72,7 @@ This agent is useful when you want the LLM to analyze code, suggest changes, or _Mode_: `subagent` -A general-purpose agent for researching complex questions and executing multi-step tasks. Has full tool access (except todo), so it can make file changes when needed. Use this to run multiple units of work in parallel. +A full-capability general-purpose subagent for autonomous investigation, implementation, debugging, testing, and other multi-step work. It inherits the parent's available, model-appropriate tool surface and can read, write, execute commands, and complete a delegated task end to end. --- From c9d8cf6617d6338d58772a64fa880bec9b14e0b7 Mon Sep 17 00:00:00 2001 From: fanhuanjie Date: Wed, 5 Aug 2026 20:38:41 +0800 Subject: [PATCH 127/135] fix(agent): unify subagent prompts across models - remove the GPT-only subagent prompt fragment\n- preserve model-specific runtime tool selection and coverage --- .../opencode/src/agent/prompt/gpt-tools.txt | 10 --- packages/opencode/src/session/system.ts | 7 +- packages/opencode/test/actor/spawn.test.ts | 6 -- packages/opencode/test/session/system.test.ts | 72 +++++-------------- 4 files changed, 20 insertions(+), 75 deletions(-) delete mode 100644 packages/opencode/src/agent/prompt/gpt-tools.txt diff --git a/packages/opencode/src/agent/prompt/gpt-tools.txt b/packages/opencode/src/agent/prompt/gpt-tools.txt deleted file mode 100644 index 96ad055ed..000000000 --- a/packages/opencode/src/agent/prompt/gpt-tools.txt +++ /dev/null @@ -1,10 +0,0 @@ -# GPT subagent tools - -The tools exposed in this turn are the source of truth. GPT-5-family agents use a model-specific tool set, so do not call legacy file tools that are absent from the tool list. - -- Use `exec` as the main composition surface when you need to batch independent tool calls or compactly transform their results. Run independent calls with `Promise.all` or `Promise.allSettled`, keep dependent calls sequential, and return only the evidence needed by the caller. Call one small tool directly instead of wrapping it in `exec`. -- Code inside `exec` is the body of an async JavaScript/TypeScript function. Use only the declared `tools`, `files`, and `console` globals. Conversation-control tools are unavailable inside `exec` and must be called directly. -- Use `apply_patch` for project text edits. Provide the complete patch in `patch_text`; do not create or modify project files through `exec` raw file helpers. -- Use `view_image` to inspect local JPEG, PNG, GIF, or WebP files when visual analysis is needed. -- When `read`, `grep`, or `glob` are not exposed, use `bash` with targeted `rg`, `rg --files`, and `sed -n` commands for codebase exploration. Keep every command read-only when the subagent's role is read-only. -- `exec` never broadens permissions: its nested calls have the same model-, agent-, and permission-filtered tool set as direct calls. diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index c30abfa7a..35bb96c53 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -12,7 +12,6 @@ import PROMPT_BEAST from "./prompt/beast.txt" import PROMPT_GEMINI from "./prompt/gemini.txt" import PROMPT_GPT from "./prompt/gpt.txt" import PROMPT_KIMI from "./prompt/kimi.txt" -import PROMPT_GPT_SUBAGENT_TOOLS from "../agent/prompt/gpt-tools.txt" import PROMPT_CODEX from "./prompt/codex.txt" import PROMPT_DEEPSEEK from "./prompt/deepseek.txt" @@ -25,7 +24,6 @@ import type { Agent } from "@/agent/agent" import { Permission } from "@/permission" import { Skill } from "@/skill" import { isSkillSearchDisabled, type SkillSearchModel } from "@/skill/search" -import { usesGPTToolset } from "@/tool/gpt" function renderGitResult(result: Git.Result, fallback = "(none)") { if (result.exitCode !== 0) return fallback @@ -50,10 +48,7 @@ export function provider(model: Provider.Model) { } export function agent(agent: Agent.Info, model: Provider.Model) { - const base = agent.prompt ? [agent.prompt] : provider(model) - if (agent.mode !== "subagent" || agent.toolAllowlist?.length === 0 || !usesGPTToolset(model.id)) return base - if (!agent.prompt && base.includes(PROMPT_GPT)) return base - return [...base, PROMPT_GPT_SUBAGENT_TOOLS] + return agent.prompt ? [agent.prompt] : provider(model) } export interface Interface { diff --git a/packages/opencode/test/actor/spawn.test.ts b/packages/opencode/test/actor/spawn.test.ts index 3004c0252..b0cf55b81 100644 --- a/packages/opencode/test/actor/spawn.test.ts +++ b/packages/opencode/test/actor/spawn.test.ts @@ -453,12 +453,6 @@ describe("Actor.spawn subagent mode", () => { expect(names).not.toContain("read") expect(names).not.toContain("edit") expect(names).not.toContain("write") - expect( - (request?.body.messages as Array<{ role?: string; content?: string }> | undefined) - ?.filter((message) => message.role === "system") - .map((message) => message.content) - .join("\n"), - ).toContain("Use `exec` as the main composition surface") }), { git: true, config: gptProviderCfg }, ), diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 25c33248c..becfc41bb 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -149,41 +149,25 @@ describe("session.system", () => { expect(prompt).not.toContain("When possible, prefer parallelization over sequential tool calls") }) - test("adds GPT tool guidance to prompted subagents", () => { - const model = ProviderTest.model({ - id: ModelID.make("gpt-5.4"), - api: { id: "deployment-primary" } as never, - }) - const prompt = SystemPrompt.agent( - { - name: "explore", - mode: "subagent", - prompt: "Explore files without modifying them.", - permission: [], - options: {}, - }, - model, - ).join("\n") - const general = SystemPrompt.agent( - { - name: "general", - mode: "subagent", - prompt: "You are a full-capability general-purpose subagent. The parent agent communicates with the end user.", - permission: [], - options: {}, - }, - model, - ).join("\n") - - expect(prompt).toContain("Explore files without modifying them.") - expect(prompt).toContain("Use `exec` as the main composition surface") - expect(prompt).toContain("Use `apply_patch` for project text edits") - expect(prompt).toContain("Use `view_image`") - expect(prompt).toContain("`rg --files`") - expect(general).toContain("You are a full-capability general-purpose subagent.") - expect(general).toContain("The parent agent communicates with the end user.") - expect(general).toContain("Use `exec` as the main composition surface") - expect(general).not.toContain("# Working with the user") + test("uses the same prompted subagent system across models", () => { + const subagent = { + name: "general", + mode: "subagent" as const, + prompt: "You are a full-capability general-purpose subagent.", + permission: [], + options: {}, + } + const gpt = SystemPrompt.agent( + subagent, + ProviderTest.model({ id: ModelID.make("gpt-5.4"), api: { id: "deployment-primary" } as never }), + ) + const claude = SystemPrompt.agent( + subagent, + ProviderTest.model({ id: ModelID.make("claude-sonnet-4-6"), api: { id: "claude-sonnet-4-6" } as never }), + ) + + expect(gpt).toEqual([subagent.prompt]) + expect(claude).toEqual(gpt) }) test("prefers the catalog model ID when the API deployment ID is opaque", () => { @@ -197,24 +181,6 @@ describe("session.system", () => { expect(prompt).toContain("You are MiMoCode, an agent based on the GPT-5 family") }) - test("does not add GPT tool guidance to non-GPT or tool-less subagents", () => { - const subagent = { - name: "explore", - mode: "subagent" as const, - prompt: "Explore files.", - permission: [], - options: {}, - } - const nonGPT = SystemPrompt.agent( - subagent, - ProviderTest.model({ id: ModelID.make("claude-sonnet-4-6"), api: { id: "claude-sonnet-4-6" } as never }), - ).join("\n") - const toolLess = SystemPrompt.agent({ ...subagent, toolAllowlist: [] }, ProviderTest.model()).join("\n") - - expect(nonGPT).toBe("Explore files.") - expect(toolLess).toBe("Explore files.") - }) - test("does not inject vision capability guidance for GPT, Claude, or Gemini models", async () => { await using tmp = await tmpdir({ git: true }) From abf5957ac8d97ac7450518adcd354ccc4d7d4ce0 Mon Sep 17 00:00:00 2001 From: Jinyu Xiang Date: Thu, 6 Aug 2026 14:53:45 +0800 Subject: [PATCH 128/135] feat(skill): add memory-search builtin skill Teaches agents how to search mimocode's memory system and raw trajectory database when the built-in memory tool alone is insufficient. Covers BM25 query optimization, scope escalation, SQLite schema documentation, 5 ready-to-use query templates, and per-goal search strategies. All queries validated against a live 2.3GB production database. --- .../builtin/.bundle/memory-search/SKILL.md | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 packages/opencode/src/skill/builtin/.bundle/memory-search/SKILL.md diff --git a/packages/opencode/src/skill/builtin/.bundle/memory-search/SKILL.md b/packages/opencode/src/skill/builtin/.bundle/memory-search/SKILL.md new file mode 100644 index 000000000..1314c97e1 --- /dev/null +++ b/packages/opencode/src/skill/builtin/.bundle/memory-search/SKILL.md @@ -0,0 +1,181 @@ +--- +name: "memory-search" +description: "Advanced memory and trajectory search techniques. Use when the built-in memory tool returns 0 results, when you need to search raw conversation history in the SQLite database, or when you need to locate specific past commands, tool outputs, decisions, or user statements across sessions. Covers: BM25 query optimization, scope escalation (session → project → global → raw DB), SQLite schema and query templates for the trajectory database, and strategies for finding repeated patterns, decisions, and errors." +--- + +# Memory Search Skill + +Techniques for searching mimocode's memory system and raw trajectory database when the built-in `memory` tool alone is insufficient. + +## When to use this skill + +- The `memory` tool returned 0 results for several query attempts. +- You need verbatim recall of a specific command, path, token, or connection string that the curated memory may have paraphrased. +- You need to find patterns across multiple sessions (repeated errors, recurring workflows, user preferences stated long ago). +- You want to verify whether something was actually said/done in a past session. + +## Memory system architecture + +``` +/memory/ +├── projects/ +│ ├── global/MEMORY.md # cross-project user preferences +│ └── / # per-project (UUID from .git/mimocode-project-id) +│ ├── MEMORY.md # project-level durable knowledge +│ └── MEMORY-*.md # spillover files when main exceeds budget +└── sessions// + ├── checkpoint.md # structured session state (11 sections) + ├── notes.md # free-form scratchpad + └── tasks//progress.md # per-task subagent findings +``` + +`` is the mimocode data directory (typically `~/.local/share/mimocode/`). + +## Step 1: Optimize memory tool queries + +The `memory` tool uses BM25 (OR-joined, relevance-ranked). Common mistakes: + +- **Too many generic words**: "config params database connection" — every word dilutes. Pick the 1-3 rarest, most specific terms. +- **Punctuation in queries**: `.`, `-`, `/`, `:` are stripped during tokenization. `postgres://host:5433` becomes tokens `postgres`, `host`, `5433`. Search one of those, not the full URL. +- **Wrong scope**: default is current session. Widen progressively: `scope: "sessions"` → `scope: "projects"` → `scope: "global"`. + +Good queries: `"T5.3 closure"`, `"permission deadlock"`, `"drizzle inArray"`, a function name, an error code. + +## Step 2: Use the history tool + +When memory search misses (curated summaries may have dropped the literal), fall back to `history`: + +``` +history({ operation: "search", query: "the exact keyword" }) +``` + +This searches raw conversation messages (user text, assistant text, tool inputs/outputs). Hits include `message_id` — use `history({ operation: "around", message_id: "..." })` to get surrounding context. + +## Step 3: Query the raw trajectory database + +When history search also misses, or you need cross-session analysis, query the SQLite database directly. + +### Locating the database + +```bash +# The DB path is derived from the memory root visible in system instructions. +# Typically: ~/.local/share/mimocode/mimocode.db +# If MIMOCODE_DB is set in the environment, it overrides. +ls ~/.local/share/mimocode/mimocode.db +``` + +### Schema + +Key tables: + +| Table | Purpose | Key columns | +|-------|---------|-------------| +| `session` | Session metadata | `id`, `project_id`, `title`, `time_created`, `parent_id` | +| `message` | User/assistant turns | `id`, `session_id`, `agent_id`, `time_created`, `data` (JSON: `$.role`) | +| `part` | Message parts (text, tool calls, steps) | `id`, `message_id`, `session_id`, `time_created`, `data` (JSON) | +| `task` | Task tree | `id`, `session_id`, `summary`, `status` | +| `task_event` | Task state transitions | `id`, `session_id`, `task_id`, `at`, `kind`, `summary` | +| `actor_registry` | Subagent/peer history | `session_id`, `actor_id`, `agent`, `mode`, `status`, `description` | + +### Part types in `part.data` + +- `{"type":"text","text":"..."}` — agent text output +- `{"type":"tool","tool":"","callID":"...","state":{"status":"completed","input":{...},"output":"..."}}` — tool call + result +- `{"type":"step-start"}` / `{"type":"step-finish","tokens":...}` — step boundaries +- `{"type":"compaction","auto":true/false}` — compaction boundary (the summary text is in the following assistant message, not this part) +- `{"type":"checkpoint",...}` — checkpoint/rebuild boundary + +`agent_id = 'main'` = main agent; any other value = subagent (e.g. `"explore-1"`, `"general-1"`). + +### Query templates + +**List recent sessions for this project:** + +```sql +SELECT id, title, time_created, + datetime(time_created/1000, 'unixepoch', 'localtime') as created +FROM session +WHERE project_id = '' + AND parent_id IS NULL +ORDER BY time_created DESC +LIMIT 20; +``` + +**Find user messages containing a keyword:** + +```sql +SELECT m.session_id, m.id, + substr(json_extract(p.data, '$.text'), 1, 200) as preview +FROM message m +JOIN part p ON p.message_id = m.id AND p.session_id = m.session_id +WHERE json_extract(m.data, '$.role') = 'user' + AND json_extract(p.data, '$.type') = 'text' + AND json_extract(p.data, '$.text') LIKE '%keyword%' +ORDER BY m.time_created DESC +LIMIT 10; +``` + +**Find tool calls by tool name:** + +```sql +SELECT m.session_id, m.id, m.agent_id, + json_extract(p.data, '$.tool') as tool, + substr(json_extract(p.data, '$.state.output'), 1, 300) as output_preview +FROM message m +JOIN part p ON p.message_id = m.id AND p.session_id = m.session_id +WHERE json_extract(m.data, '$.role') = 'assistant' + AND json_extract(p.data, '$.type') = 'tool' + AND json_extract(p.data, '$.tool') = '' + AND m.session_id = '' +ORDER BY m.time_created DESC +LIMIT 20; +``` + +**View a session's full assistant execution chain:** + +```sql +SELECT m.id, m.agent_id, + json_extract(p.data, '$.type') as part_type, + json_extract(p.data, '$.tool') as tool, + substr(p.data, 1, 800) as preview +FROM message m +JOIN part p ON p.message_id = m.id AND p.session_id = m.session_id +WHERE m.session_id = '' + AND json_extract(m.data, '$.role') = 'assistant' +ORDER BY m.time_created, p.time_created; +``` + +**Find repeated errors across sessions (last 7 days):** + +```sql +SELECT json_extract(p.data, '$.state.output') as error_output, + COUNT(*) as occurrences, + GROUP_CONCAT(DISTINCT m.session_id) as sessions +FROM part p +JOIN message m ON m.id = p.message_id AND m.session_id = p.session_id +WHERE json_extract(p.data, '$.type') = 'tool' + AND json_extract(p.data, '$.tool') = 'bash' + AND json_extract(p.data, '$.state.output') LIKE '%error%' + AND m.time_created > (strftime('%s', 'now') - 7*86400) * 1000 +GROUP BY substr(json_extract(p.data, '$.state.output'), 1, 200) +HAVING occurrences > 1 +ORDER BY occurrences DESC +LIMIT 10; +``` + +### Search strategies for common goals + +| Goal | Strategy | +|------|----------| +| Find a user's stated rule/preference | Search `LIKE '%always%'`, `'%never%'`, `'%remember%'`, `'%rule%'` in user text parts | +| Find a design decision | Search `'%decided%'`, `'%tradeoff%'`, `'%reason%'` | +| Find a specific file path or command | Use exact substring LIKE match on tool output | +| Find repeated workflows | Group tool call sequences by session, look for recurring patterns | +| Verify a memory claim | Find the session_id in the memory entry `[ses_xxx]`, then query its full execution chain | + +## Important constraints + +- **Read-only**: Never modify the database. Use `sqlite3` in read-only mode or just SELECT queries. +- **Performance**: The database can be large. Always use LIMIT, filter by session_id or time range when possible. +- **Privacy**: Raw trajectory contains everything the user typed. Treat it with care. +- **Encoding**: Part data is JSON-in-a-column. Always use `json_extract()` for structured access. From f36d7b75dbce6fd0eff30715328fa350baf8ccea Mon Sep 17 00:00:00 2001 From: Jinyu Xiang Date: Thu, 6 Aug 2026 15:10:03 +0800 Subject: [PATCH 129/135] =?UTF-8?q?fix(skill):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20state.error=20blind=20spot,=20scope=20cc,=20query?= =?UTF-8?q?=20robustness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Query 5 (repeated errors) was only finding completed bash calls with 'error' in stdout — genuinely failed tool calls (status='error') store the message in $.state.error, not $.state.output. Split into two queries: one for stdout errors, one for actual tool failures. Added explanatory note. - Query 3 (by tool name) now uses COALESCE(output, error) and shows status so both success and failure cases are visible. - Added 'cc' to the scope escalation list (Claude Code imported memories). - Review point 2 (agent_id examples) verified as correct — DB shows explore-1, general-1, etc. from allocateActorID; reviewer confused peer mode (actorID = sessionID) with normal subagent mode. --- .../builtin/.bundle/memory-search/SKILL.md | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/skill/builtin/.bundle/memory-search/SKILL.md b/packages/opencode/src/skill/builtin/.bundle/memory-search/SKILL.md index 1314c97e1..07f6bdebe 100644 --- a/packages/opencode/src/skill/builtin/.bundle/memory-search/SKILL.md +++ b/packages/opencode/src/skill/builtin/.bundle/memory-search/SKILL.md @@ -37,7 +37,7 @@ The `memory` tool uses BM25 (OR-joined, relevance-ranked). Common mistakes: - **Too many generic words**: "config params database connection" — every word dilutes. Pick the 1-3 rarest, most specific terms. - **Punctuation in queries**: `.`, `-`, `/`, `:` are stripped during tokenization. `postgres://host:5433` becomes tokens `postgres`, `host`, `5433`. Search one of those, not the full URL. -- **Wrong scope**: default is current session. Widen progressively: `scope: "sessions"` → `scope: "projects"` → `scope: "global"`. +- **Wrong scope**: default is current session. Widen progressively: `scope: "sessions"` → `scope: "projects"` → `scope: "global"` → `scope: "cc"` (Claude Code imported memories, if cc_index is enabled). Good queries: `"T5.3 closure"`, `"permission deadlock"`, `"drizzle inArray"`, a function name, an error code. @@ -115,12 +115,13 @@ ORDER BY m.time_created DESC LIMIT 10; ``` -**Find tool calls by tool name:** +**Find tool calls by tool name (output only exists for status=completed):** ```sql SELECT m.session_id, m.id, m.agent_id, json_extract(p.data, '$.tool') as tool, - substr(json_extract(p.data, '$.state.output'), 1, 300) as output_preview + json_extract(p.data, '$.state.status') as status, + substr(COALESCE(json_extract(p.data, '$.state.output'), json_extract(p.data, '$.state.error')), 1, 300) as result_preview FROM message m JOIN part p ON p.message_id = m.id AND p.session_id = m.session_id WHERE json_extract(m.data, '$.role') = 'assistant' @@ -147,7 +148,10 @@ ORDER BY m.time_created, p.time_created; **Find repeated errors across sessions (last 7 days):** +Note: Tool failures (exceptions, aborts) store the error in `$.state.error` with `$.state.status = "error"`, NOT in `$.state.output` (which only exists for completed calls). This query finds completed bash calls whose stdout contains "error"; to find actual tool failures, query `$.state.error` instead. + ```sql +-- Completed bash calls with "error" in stdout (last 7 days) SELECT json_extract(p.data, '$.state.output') as error_output, COUNT(*) as occurrences, GROUP_CONCAT(DISTINCT m.session_id) as sessions @@ -155,6 +159,7 @@ FROM part p JOIN message m ON m.id = p.message_id AND m.session_id = p.session_id WHERE json_extract(p.data, '$.type') = 'tool' AND json_extract(p.data, '$.tool') = 'bash' + AND json_extract(p.data, '$.state.status') = 'completed' AND json_extract(p.data, '$.state.output') LIKE '%error%' AND m.time_created > (strftime('%s', 'now') - 7*86400) * 1000 GROUP BY substr(json_extract(p.data, '$.state.output'), 1, 200) @@ -163,6 +168,24 @@ ORDER BY occurrences DESC LIMIT 10; ``` +**Find actual tool failures (any tool, last 7 days):** + +```sql +SELECT json_extract(p.data, '$.tool') as tool, + json_extract(p.data, '$.state.error') as error_msg, + COUNT(*) as occurrences, + GROUP_CONCAT(DISTINCT m.session_id) as sessions +FROM part p +JOIN message m ON m.id = p.message_id AND m.session_id = p.session_id +WHERE json_extract(p.data, '$.type') = 'tool' + AND json_extract(p.data, '$.state.status') = 'error' + AND m.time_created > (strftime('%s', 'now') - 7*86400) * 1000 +GROUP BY json_extract(p.data, '$.tool'), substr(json_extract(p.data, '$.state.error'), 1, 200) +HAVING occurrences > 1 +ORDER BY occurrences DESC +LIMIT 10; +``` + ### Search strategies for common goals | Goal | Strategy | From eba27b64439c5d3e4c64cb8fcaac168ef0b763a4 Mon Sep 17 00:00:00 2001 From: wqymi Date: Thu, 6 Aug 2026 16:19:59 +0800 Subject: [PATCH 130/135] refactor(mcp): share one process-wide client layer (#2044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mcp): share one process-wide client layer * test(mcp): assert single MCP ownership behaviorally The previous test only used toBeDefined() and reference inequality, which any two distinct objects satisfy — re-adding a self-provided MCP.defaultLayer to an appLayer kept it green. AppLayer is also a Layer.suspend, so toBeDefined() never forces the thunk, leaving the test with no marginal value over tsc. Now: - Rebuild the ownership chain exactly as app-runtime.ts does, swapping only MCP.defaultLayer for a counting stub, and assert the stub is constructed once and that provideMerge keeps MCP.Service in the output so server routes can still resolve it - Assert each appLayer fails to build standalone on a specific missing service (Command and SessionPrompt miss MCP, Actor misses SessionPrompt because it never consumes MCP directly), so re-adding a self-provided dependency turns the test red - Add one narrow source assertion that AppLayer wires MCP.defaultLayer exactly once, covering the extra-leaf regression the behavioral tests cannot see The real MCP.defaultLayer and the full AppLayer are never built, so no subprocess is spawned. * docs(mcp): correct stale Actor.defaultLayer references AppLayer now constructs Actor.appLayer rather than Actor.defaultLayer, which leaves these references inaccurate: - session/checkpoint.ts: this comment is the only explanation of how the Actor -> SessionPrompt -> SessionCheckpoint -> Actor cycle is broken by the late-bound spawnRef, so a wrong name sends readers looking for a spawnRef assignment that is not there. Also records that appLayer wraps the same Actor.layer, hence spawnRef is still populated. - tool/actor.ts, tool/session.ts, server/routes/instance/session.ts: three developer-facing diagnostics that point at an unpopulated spawnRef. Verified by grep that no test asserts these strings literally. - effect/app-runtime.ts: the same reference in the TDZ comment (comment only, mechanism untouched). * docs(mcp): correct the guard comment's account of the old layer shape The comment claimed the four-leaf shape "started one MCP subprocess set per leaf". It did not: Layer.effect memoises on the layer's own identity and every ManagedRuntime in this process shares the single memo map from src/effect/memo-map.ts, so the old graph already built exactly one MCP instance. Single-instance behaviour was therefore incidental — it rested on memo identity rather than on the composition — which is the actual reason to make the ownership chain explicit, and the actual thing the regressions in this file protect. --- packages/opencode/src/actor/spawn.ts | 6 +- packages/opencode/src/command/index.ts | 10 +- packages/opencode/src/effect/app-runtime.ts | 14 ++- .../src/server/routes/instance/session.ts | 2 +- packages/opencode/src/session/checkpoint.ts | 5 +- packages/opencode/src/session/prompt.ts | 8 +- packages/opencode/src/tool/actor.ts | 2 +- packages/opencode/src/tool/session.ts | 2 +- .../effect/app-runtime-mcp-singleton.test.ts | 117 ++++++++++++++++++ 9 files changed, 149 insertions(+), 17 deletions(-) create mode 100644 packages/opencode/test/effect/app-runtime-mcp-singleton.test.ts diff --git a/packages/opencode/src/actor/spawn.ts b/packages/opencode/src/actor/spawn.ts index a9642cdd1..283e4a590 100644 --- a/packages/opencode/src/actor/spawn.ts +++ b/packages/opencode/src/actor/spawn.ts @@ -1096,12 +1096,12 @@ export const layer = Layer.effect( // "Cannot access 'defaultLayer' before initialization", breaking every // it.live test harness. Same pattern session/prompt, session/checkpoint, // tool/registry, provider, etc. already use. -export const defaultLayer = Layer.suspend(() => +/** App composition variant with SessionPrompt supplied by the root graph. */ +export const appLayer = Layer.suspend(() => layer.pipe( Layer.provide(Session.defaultLayer), Layer.provide(ActorRegistry.defaultLayer), Layer.provide(Agent.defaultLayer), - Layer.provide(SessionPrompt.defaultLayer), Layer.provide(SessionRunState.defaultLayer), Layer.provide(Inbox.defaultLayer), Layer.provide(Plugin.defaultLayer), @@ -1110,4 +1110,6 @@ export const defaultLayer = Layer.suspend(() => ), ) +export const defaultLayer = appLayer.pipe(Layer.provide(SessionPrompt.defaultLayer)) + export * as Actor from "./spawn" diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 0d7025afe..12915c85d 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -296,10 +296,16 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( +/** + * Application composition variant. The process-wide AppLayer supplies the + * MCP service so Command and SessionPrompt share one client set instead of + * each hiding a separately scoped transport layer. + */ +export const appLayer = layer.pipe( Layer.provide(Config.defaultLayer), - Layer.provide(MCP.defaultLayer), Layer.provide(Skill.defaultLayer), ) +export const defaultLayer = appLayer.pipe(Layer.provide(MCP.defaultLayer)) + export * as Command from "." diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 637ffb389..083648a92 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -62,7 +62,7 @@ import * as BashInteractive from "@/tool/bash-interactive" import { memoMap } from "./memo-map" // Wrapped in Layer.suspend so the cross-module `.defaultLayer` reads defer to -// first use instead of running at module load — same TDZ fix as Actor.defaultLayer. +// first use instead of running at module load — same TDZ fix as Actor.appLayer. export const AppLayer = Layer.suspend(() => Layer.mergeAll( Npm.defaultLayer, @@ -95,15 +95,12 @@ export const AppLayer = Layer.suspend(() => SessionPrune.defaultLayer, SessionRevert.defaultLayer, SessionSummary.defaultLayer, - SessionPrompt.defaultLayer, CronBridgeDefaultLayer, SessionCheckpoint.defaultLayer, Instruction.defaultLayer, LLM.defaultLayer, LSP.defaultLayer, - MCP.defaultLayer, McpAuth.defaultLayer, - Command.defaultLayer, Truncate.defaultLayer, ToolRegistry.defaultLayer, Format.defaultLayer, @@ -116,11 +113,18 @@ export const AppLayer = Layer.suspend(() => SessionShare.defaultLayer, ActorRegistry.defaultLayer, ActorWaiter.defaultLayer, - Actor.defaultLayer, TaskRegistry.defaultLayer, WorkflowRuntime.defaultLayer, Memory.defaultLayer, History.defaultLayer, + // MCP, Command, SessionPrompt, and Actor form one ownership chain. Their + // standalone default layers remain convenient for focused tests, while + // the application graph deliberately provides each stateful service once. + Actor.appLayer.pipe( + Layer.provideMerge(SessionPrompt.appLayer.pipe( + Layer.provideMerge(Command.appLayer.pipe(Layer.provideMerge(MCP.defaultLayer))), + )), + ), ).pipe(Layer.provideMerge(Observability.layer), Layer.provideMerge(BashInteractive.defaultLayer)), ) diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts index 883a513a1..c072765d0 100644 --- a/packages/opencode/src/server/routes/instance/session.ts +++ b/packages/opencode/src/server/routes/instance/session.ts @@ -732,7 +732,7 @@ export const SessionRoutes = lazy(() => const actor = spawnRef.current if (!actor) return yield* Effect.fail( - new Error("Actor service unavailable — Actor.defaultLayer must be running to ask a side question"), + new Error("Actor service unavailable — Actor.appLayer must be running to ask a side question"), ) const selectedModel = body.providerID && body.modelID ? { providerID: body.providerID, modelID: body.modelID } : undefined diff --git a/packages/opencode/src/session/checkpoint.ts b/packages/opencode/src/session/checkpoint.ts index 2ca048f97..7f025738c 100644 --- a/packages/opencode/src/session/checkpoint.ts +++ b/packages/opencode/src/session/checkpoint.ts @@ -1703,8 +1703,9 @@ export const layer: Layer.Layer< // the Actor implementation through the late-bound `spawnRef` (see // `actor/spawn-ref.ts`). This deliberately breaks the otherwise-unresolvable // layer cycle Actor → SessionPrompt → SessionCheckpoint → Actor. The AppLayer -// constructs `Actor.defaultLayer` separately; its initialiser populates -// `spawnRef`, which `tryStartCheckpointWriter` reads at call time. +// constructs `Actor.appLayer` separately; that variant wraps the same +// `Actor.layer`, whose initialiser populates `spawnRef` (see +// `actor/spawn.ts`), and `tryStartCheckpointWriter` reads the ref at call time. export const defaultLayer = Layer.suspend(() => layer.pipe( Layer.provide(Session.defaultLayer), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8f9ca04db..97964ec67 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -4555,7 +4555,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the }), ) -export const defaultLayer = Layer.suspend(() => +/** App composition variant with MCP supplied by the process-wide layer. */ +export const appLayer = Layer.suspend(() => layer.pipe( Layer.provide(SessionRunState.defaultLayer), Layer.provide(SessionStatus.defaultLayer), @@ -4563,9 +4564,8 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(SessionCheckpoint.defaultLayer), Layer.provide(SessionCompaction.defaultLayer), Layer.provide(SessionProcessor.defaultLayer), - Layer.provide(Command.defaultLayer), + Layer.provide(Command.appLayer), Layer.provide(Permission.defaultLayer), - Layer.provide(MCP.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(ToolRegistry.defaultLayer), Layer.provide(Truncate.defaultLayer), @@ -4593,6 +4593,8 @@ export const defaultLayer = Layer.suspend(() => ), ), ) + +export const defaultLayer = appLayer.pipe(Layer.provide(MCP.defaultLayer)) /** * Returns true when at least one resolved user-message part carries substantive * content that will survive the send-side filter (message-v2.ts). Used by diff --git a/packages/opencode/src/tool/actor.ts b/packages/opencode/src/tool/actor.ts index 5269d9ece..3a97fe97c 100644 --- a/packages/opencode/src/tool/actor.ts +++ b/packages/opencode/src/tool/actor.ts @@ -318,7 +318,7 @@ export const ActorTool = Tool.define( if (!a) { return Effect.fail( new Error( - "Actor service unavailable — Actor.defaultLayer must be running for the actor tool to spawn or cancel actors", + "Actor service unavailable — Actor.appLayer must be running for the actor tool to spawn or cancel actors", ), ) } diff --git a/packages/opencode/src/tool/session.ts b/packages/opencode/src/tool/session.ts index 49899b879..ec81443b5 100644 --- a/packages/opencode/src/tool/session.ts +++ b/packages/opencode/src/tool/session.ts @@ -641,7 +641,7 @@ export const SessionTool = Tool.define( if (!a) { return Effect.fail( new Error( - "Actor service unavailable — Actor.defaultLayer must be running for the session tool to spawn or cancel sessions", + "Actor service unavailable — Actor.appLayer must be running for the session tool to spawn or cancel sessions", ), ) } diff --git a/packages/opencode/test/effect/app-runtime-mcp-singleton.test.ts b/packages/opencode/test/effect/app-runtime-mcp-singleton.test.ts new file mode 100644 index 000000000..03efd7743 --- /dev/null +++ b/packages/opencode/test/effect/app-runtime-mcp-singleton.test.ts @@ -0,0 +1,117 @@ +import { expect, test } from "bun:test" +import path from "path" +import { Cause, Context, Effect, Exit, Layer, Option } from "effect" + +import { Actor } from "../../src/actor/spawn" +import { Command } from "../../src/command" +import { MCP } from "../../src/mcp" +import { SessionPrompt } from "../../src/session/prompt" + +// Guards the MCP single-instance ownership chain built in src/effect/app-runtime.ts: +// +// Actor.appLayer <- SessionPrompt.appLayer <- Command.appLayer <- MCP.defaultLayer +// +// Before that chain existed, MCP, Command, SessionPrompt and Actor were four +// independent AppLayer leaves, three of which provided MCP.defaultLayer +// themselves. That shape still built only ONE MCP instance, because Layer.effect +// memoises on the layer's own identity and every ManagedRuntime here shares the +// single memo map from src/effect/memo-map.ts — so single-instance behaviour was +// incidental, resting on memo identity rather than on the graph. The chain makes +// the ownership explicit instead. The regressions this file exists to catch are +// someone re-adding a self-provided MCP inside any of the three `appLayer` +// variants, or adding a second MCP.defaultLayer leaf — either of which would +// break the memo assumption the old shape silently depended on. +// +// These tests deliberately never build the real MCP.defaultLayer and never build +// the full AppLayer: booting real MCP is precisely the behaviour under guard. +// Layer.build below also intentionally does NOT reuse the process-wide memoMap +// from src/effect/memo-map.ts — a fresh memo map per build keeps this test from +// resolving against (or polluting) instances another test already memoized. + +/** + * Stand-in for MCP.Service. Every property access throws, so if any layer in the + * chain calls an MCP method while merely *building*, this test fails loudly + * instead of silently letting a real transport get established later. + */ +function makeCountingMcpLayer() { + let built = 0 + const layer = Layer.effect( + MCP.Service, + Effect.sync(() => { + built++ + return MCP.Service.of( + new Proxy( + {}, + { + get(_target, property) { + throw new Error(`stub MCP.Service.${String(property)} used during layer build`) + }, + }, + ) as never, + ) + }), + ) + return { layer, builds: () => built } +} + +test("app graph's MCP chain composes, and one MCP instance serves all three consumers", async () => { + const mcp = makeCountingMcpLayer() + + // Same shape as src/effect/app-runtime.ts, with only MCP.defaultLayer swapped + // for the counting stub. + const chain = Actor.appLayer.pipe( + Layer.provideMerge( + SessionPrompt.appLayer.pipe(Layer.provideMerge(Command.appLayer.pipe(Layer.provideMerge(mcp.layer)))), + ), + ) + + const context = await Effect.runPromise(Effect.scoped(Layer.build(chain))) + + // Exactly one MCP construction for Command + SessionPrompt + Actor together. + expect(mcp.builds()).toBe(1) + + // provideMerge (not provide) keeps MCP.Service in the AppLayer output so + // server routes can still resolve it, alongside the three consumers. + // getOption is used rather than get so the throwing stub is never dereferenced. + expect(Option.isSome(Context.getOption(context, MCP.Service))).toBe(true) + expect(Option.isSome(Context.getOption(context, Command.Service))).toBe(true) + expect(Option.isSome(Context.getOption(context, SessionPrompt.Service))).toBe(true) + expect(Option.isSome(Context.getOption(context, Actor.Service))).toBe(true) +}) + +// Each appLayer must leave its MCP-bearing dependency *unmet* so the root graph +// supplies it once. Asserting the specific missing service is what makes this a +// real guard: if someone re-adds Layer.provide(MCP.defaultLayer) to Command or +// SessionPrompt, or Layer.provide(SessionPrompt.defaultLayer) to Actor, the +// layer becomes self-sufficient, the build succeeds, and this test fails. +// +// Actor is listed against SessionPrompt rather than MCP because Actor never +// consumes MCP.Service directly; it reaches MCP only through SessionPrompt, so +// SessionPrompt.defaultLayer is the edge that would smuggle a second MCP in. +const unmetDependency = [ + { name: "Command.appLayer", layer: Command.appLayer, missing: "@opencode/MCP" }, + { name: "SessionPrompt.appLayer", layer: SessionPrompt.appLayer, missing: "@opencode/MCP" }, + { name: "Actor.appLayer", layer: Actor.appLayer, missing: "@opencode/SessionPrompt" }, +] as const + +for (const { name, layer, missing } of unmetDependency) { + test(`${name} does not provide its own ${missing}`, async () => { + // The cast is load-bearing: these layers legitimately still declare an unmet + // requirement, which is exactly the property asserted here. + const build = Layer.build(layer as unknown as Layer.Layer) + const exit = await Effect.runPromiseExit(Effect.scoped(build)) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toInclude(`Service not found: ${missing}`) + }) +} + +test("app-runtime.ts wires MCP.defaultLayer exactly once", async () => { + // Structural companion to the behavioural tests above: they cannot see an + // extra independent `MCP.defaultLayer` leaf added to AppLayer's mergeAll, + // because building the real AppLayer is off-limits here. + const source = await Bun.file(path.join(import.meta.dir, "../../src/effect/app-runtime.ts")).text() + const occurrences = source.match(/MCP\.defaultLayer/g) ?? [] + + expect(occurrences).toHaveLength(1) +}) From 46b7d70c9b466d1bec6ade0bee6f4fdca7ea13f6 Mon Sep 17 00:00:00 2001 From: Jinyu Xiang Date: Fri, 7 Aug 2026 16:32:57 +0800 Subject: [PATCH 131/135] =?UTF-8?q?feat(skill):=20add=20memory-search=20bu?= =?UTF-8?q?iltin=20skill=20=E2=80=94=20SQLite=20trajectory=20DB=20query=20?= =?UTF-8?q?guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Focused skill for querying the raw mimocode trajectory database directly via SQL when the built-in memory (BM25 curated markdown) and history (FTS raw messages) tools are insufficient. Covers: schema documentation, 6 validated query templates (session listing, keyword search, tool calls by name, execution chains, stdout errors, actual tool failures), per-goal strategy table, and safety constraints. All queries validated against a live 2.3GB production database. --- .../builtin/.bundle/memory-search/SKILL.md | 117 ++++++------------ 1 file changed, 39 insertions(+), 78 deletions(-) diff --git a/packages/opencode/src/skill/builtin/.bundle/memory-search/SKILL.md b/packages/opencode/src/skill/builtin/.bundle/memory-search/SKILL.md index 07f6bdebe..b7962c4aa 100644 --- a/packages/opencode/src/skill/builtin/.bundle/memory-search/SKILL.md +++ b/packages/opencode/src/skill/builtin/.bundle/memory-search/SKILL.md @@ -1,72 +1,30 @@ --- name: "memory-search" -description: "Advanced memory and trajectory search techniques. Use when the built-in memory tool returns 0 results, when you need to search raw conversation history in the SQLite database, or when you need to locate specific past commands, tool outputs, decisions, or user statements across sessions. Covers: BM25 query optimization, scope escalation (session → project → global → raw DB), SQLite schema and query templates for the trajectory database, and strategies for finding repeated patterns, decisions, and errors." +description: "Query the raw trajectory SQLite database directly when the built-in memory and history tools are insufficient. Use when you need structured analysis across sessions: finding repeated errors, grouping tool calls by pattern, verifying what was actually executed, or locating specific past commands/decisions that text search cannot surface. Provides the database schema, ready-to-use SQL query templates, and per-goal strategies." --- -# Memory Search Skill +# Memory Search: SQLite Trajectory Database -Techniques for searching mimocode's memory system and raw trajectory database when the built-in `memory` tool alone is insufficient. +Direct SQL access to mimocode's trajectory database for structured analysis that the `memory` (BM25 over curated markdown) and `history` (FTS over raw messages) tools cannot perform — aggregation, filtering by tool/status/time, cross-session pattern detection, and execution chain inspection. -## When to use this skill +## When to use -- The `memory` tool returned 0 results for several query attempts. -- You need verbatim recall of a specific command, path, token, or connection string that the curated memory may have paraphrased. -- You need to find patterns across multiple sessions (repeated errors, recurring workflows, user preferences stated long ago). -- You want to verify whether something was actually said/done in a past session. +- You need to **aggregate or count** across sessions (e.g. "which tool fails most often?", "how many sessions touched file X?"). +- You need to **filter by structure** — tool name, status, agent_id, time range — not just text content. +- You need to **view a complete execution chain** for a session (every tool call in order). +- You need to **verify a memory claim** against what actually happened (the DB is the source of truth). +- The `memory` and `history` tools returned nothing useful despite multiple query attempts. -## Memory system architecture - -``` -/memory/ -├── projects/ -│ ├── global/MEMORY.md # cross-project user preferences -│ └── / # per-project (UUID from .git/mimocode-project-id) -│ ├── MEMORY.md # project-level durable knowledge -│ └── MEMORY-*.md # spillover files when main exceeds budget -└── sessions// - ├── checkpoint.md # structured session state (11 sections) - ├── notes.md # free-form scratchpad - └── tasks//progress.md # per-task subagent findings -``` - -`` is the mimocode data directory (typically `~/.local/share/mimocode/`). - -## Step 1: Optimize memory tool queries - -The `memory` tool uses BM25 (OR-joined, relevance-ranked). Common mistakes: - -- **Too many generic words**: "config params database connection" — every word dilutes. Pick the 1-3 rarest, most specific terms. -- **Punctuation in queries**: `.`, `-`, `/`, `:` are stripped during tokenization. `postgres://host:5433` becomes tokens `postgres`, `host`, `5433`. Search one of those, not the full URL. -- **Wrong scope**: default is current session. Widen progressively: `scope: "sessions"` → `scope: "projects"` → `scope: "global"` → `scope: "cc"` (Claude Code imported memories, if cc_index is enabled). - -Good queries: `"T5.3 closure"`, `"permission deadlock"`, `"drizzle inArray"`, a function name, an error code. - -## Step 2: Use the history tool - -When memory search misses (curated summaries may have dropped the literal), fall back to `history`: - -``` -history({ operation: "search", query: "the exact keyword" }) -``` - -This searches raw conversation messages (user text, assistant text, tool inputs/outputs). Hits include `message_id` — use `history({ operation: "around", message_id: "..." })` to get surrounding context. - -## Step 3: Query the raw trajectory database - -When history search also misses, or you need cross-session analysis, query the SQLite database directly. - -### Locating the database +## Locating the database ```bash -# The DB path is derived from the memory root visible in system instructions. -# Typically: ~/.local/share/mimocode/mimocode.db -# If MIMOCODE_DB is set in the environment, it overrides. -ls ~/.local/share/mimocode/mimocode.db +# Typically at this path. MIMOCODE_DB env var overrides if set. +sqlite3 -readonly ~/.local/share/mimocode/mimocode.db ".tables" ``` -### Schema +Always use `-readonly` or only SELECT queries — never modify the database. -Key tables: +## Schema | Table | Purpose | Key columns | |-------|---------|-------------| @@ -80,14 +38,19 @@ Key tables: ### Part types in `part.data` - `{"type":"text","text":"..."}` — agent text output -- `{"type":"tool","tool":"","callID":"...","state":{"status":"completed","input":{...},"output":"..."}}` — tool call + result +- `{"type":"tool","tool":"","callID":"...","state":{"status":"completed","input":{...},"output":"..."}}` — completed tool call +- `{"type":"tool","tool":"","callID":"...","state":{"status":"error","input":{...},"error":"..."}}` — failed tool call (no `output` field; error message in `$.state.error`) - `{"type":"step-start"}` / `{"type":"step-finish","tokens":...}` — step boundaries -- `{"type":"compaction","auto":true/false}` — compaction boundary (the summary text is in the following assistant message, not this part) +- `{"type":"compaction","auto":true/false}` — compaction boundary - `{"type":"checkpoint",...}` — checkpoint/rebuild boundary -`agent_id = 'main'` = main agent; any other value = subagent (e.g. `"explore-1"`, `"general-1"`). +### Key conventions -### Query templates +- `agent_id = 'main'` = main agent; other values = subagent (e.g. `"explore-1"`, `"general-1"`). +- `$.state.output` only exists when `$.state.status = "completed"`. Failures store the message in `$.state.error`. +- `time_created` is Unix milliseconds. + +## Query templates **List recent sessions for this project:** @@ -115,7 +78,7 @@ ORDER BY m.time_created DESC LIMIT 10; ``` -**Find tool calls by tool name (output only exists for status=completed):** +**Find tool calls by tool name:** ```sql SELECT m.session_id, m.id, m.agent_id, @@ -132,7 +95,7 @@ ORDER BY m.time_created DESC LIMIT 20; ``` -**View a session's full assistant execution chain:** +**View a session's full execution chain:** ```sql SELECT m.id, m.agent_id, @@ -146,13 +109,10 @@ WHERE m.session_id = '' ORDER BY m.time_created, p.time_created; ``` -**Find repeated errors across sessions (last 7 days):** - -Note: Tool failures (exceptions, aborts) store the error in `$.state.error` with `$.state.status = "error"`, NOT in `$.state.output` (which only exists for completed calls). This query finds completed bash calls whose stdout contains "error"; to find actual tool failures, query `$.state.error` instead. +**Find repeated stdout errors (completed bash calls, last 7 days):** ```sql --- Completed bash calls with "error" in stdout (last 7 days) -SELECT json_extract(p.data, '$.state.output') as error_output, +SELECT substr(json_extract(p.data, '$.state.output'), 1, 200) as error_output, COUNT(*) as occurrences, GROUP_CONCAT(DISTINCT m.session_id) as sessions FROM part p @@ -172,7 +132,7 @@ LIMIT 10; ```sql SELECT json_extract(p.data, '$.tool') as tool, - json_extract(p.data, '$.state.error') as error_msg, + substr(json_extract(p.data, '$.state.error'), 1, 200) as error_msg, COUNT(*) as occurrences, GROUP_CONCAT(DISTINCT m.session_id) as sessions FROM part p @@ -186,19 +146,20 @@ ORDER BY occurrences DESC LIMIT 10; ``` -### Search strategies for common goals +## Search strategies | Goal | Strategy | |------|----------| -| Find a user's stated rule/preference | Search `LIKE '%always%'`, `'%never%'`, `'%remember%'`, `'%rule%'` in user text parts | -| Find a design decision | Search `'%decided%'`, `'%tradeoff%'`, `'%reason%'` | -| Find a specific file path or command | Use exact substring LIKE match on tool output | -| Find repeated workflows | Group tool call sequences by session, look for recurring patterns | -| Verify a memory claim | Find the session_id in the memory entry `[ses_xxx]`, then query its full execution chain | +| Find a user's stated rule/preference | Search user text parts for `'%always%'`, `'%never%'`, `'%remember%'`, `'%rule%'` | +| Find a design decision | Search `'%decided%'`, `'%tradeoff%'`, `'%reason%'` in user text | +| Find a specific file path or command | LIKE match on tool output/error | +| Find repeated workflows | Group tool call sequences by session, look for recurring tool×N patterns | +| Verify a memory claim | Find the session_id from the memory entry `[ses_xxx]`, then query its full execution chain | +| Count tool usage | `GROUP BY json_extract(p.data, '$.tool')` with COUNT | -## Important constraints +## Constraints -- **Read-only**: Never modify the database. Use `sqlite3` in read-only mode or just SELECT queries. -- **Performance**: The database can be large. Always use LIMIT, filter by session_id or time range when possible. +- **Read-only**: Never modify the database. Always `sqlite3 -readonly` or SELECT only. +- **Performance**: The DB can be multi-GB. Always use LIMIT and filter by `session_id` or `time_created` range. - **Privacy**: Raw trajectory contains everything the user typed. Treat it with care. -- **Encoding**: Part data is JSON-in-a-column. Always use `json_extract()` for structured access. +- **JSON access**: Part data is JSON-in-a-column. Always use `json_extract()` for structured field access. From c5188007aa6c289ec3843380eff98178cc9721be Mon Sep 17 00:00:00 2001 From: wqymi Date: Fri, 7 Aug 2026 17:32:09 +0800 Subject: [PATCH 132/135] fix(tui): hide runtime-spawned agent hosts from the Sessions list (#2035) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1964 put the render prohibition behind the navigation gate, so opening a checkpoint-writer host is refused — but the Sessions dialog still LISTED one `↳ checkpoint-writer: …` row per checkpoint. Those are two separate paths and the gate cannot stand in for the list. The leak: sync.sync() fetches children with `visible: true`, but the `session.updated` arm in sync.tsx inserts EVERY session it sees into the store, and checkpoint.ts creates the writer host with its title already set — before the actor row is registered — so it arrives on that path with a display-ready title and `isChildOfCurrent` passed it straight through. Filter the child arm through classifySession, the same predicate the gate uses, so the list cannot disagree with what opening the entry would do. Fails open (no actor rows ⇒ listed), which is what keeps orchestrator `session create` children listed: they own a mode "peer" row and classify renderable outright. --- .../cmd/tui/component/dialog-session-list.tsx | 28 ++- .../cli/tui/session-list-visibility.test.ts | 231 ++++++++++++++++++ 2 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/cli/tui/session-list-visibility.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index b9f8baec7..cbeed2249 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -11,6 +11,7 @@ import { useSDK } from "../context/sdk" import { useLanguage } from "../context/language" import { Flag } from "@/flag/flag" import { isSystemSession } from "@/session/auto-dream" +import { classifySession } from "@/session/visibility" import { DialogSessionRename } from "./dialog-session-rename" import { Keybind } from "@/util" import { createDebouncedSignal } from "../util/signal" @@ -112,15 +113,40 @@ export function DialogSessionList() { )) } + // A child session is listed only if the render prohibition would allow it to be + // opened. The actor rows come from the sync store rather than a fetch on + // purpose: a host is only ever IN that store because it was created during this + // TUI's lifetime (bootstrap loads roots only, and sync.sync() loads children + // with `visible: true`), and the same lifetime delivers its `actor.registered` + // event — so the rows this reads are present for exactly the population that + // can leak. `undefined` means "no rows", which classifySession renders. + const listable = (x: { id: string; parentID?: string }) => + classifySession(x, sync.data.actor?.[x.id]).renderable + const options = createMemo(() => { const today = new Date().toDateString() const current = currentSessionID() // Top-level sessions, plus the CURRENT session's children (e.g. Orchestrator // child sessions) so the user can discover and switch into them. Other // sessions' children stay hidden to keep the list focused. + // + // The child arm needs the visibility predicate on top of the parent test. + // `sync.data.session` is NOT already filtered: sync.sync() merges children + // fetched with `visible: true` (sync.tsx), but `session.updated` inserts + // EVERY session it sees (sync.tsx, "session.updated" arm) — and a + // checkpoint-writer host is created with its title already set + // (`title: "checkpoint-writer: …"`, session/checkpoint.ts), so it arrives on + // that path and lands in the store. Filtering only on `parentID === current` + // therefore listed one `↳ checkpoint-writer: …` row per checkpoint. + // + // classifySession is the same predicate the route's render gate uses, so the + // list cannot disagree with what opening the entry would do. It fails OPEN + // (no actor rows ⇒ listed), which is what keeps orchestrator `session create` + // children — including the `[topic:…]` ones — listed: they own a mode "peer" + // row and are returned renderable outright. const isChildOfCurrent = (x: { parentID?: string }) => current !== undefined && x.parentID === current return sessions() - .filter((x) => x.parentID === undefined || isChildOfCurrent(x)) + .filter((x) => x.parentID === undefined || (isChildOfCurrent(x) && listable(x))) .toSorted((a, b) => { const updatedDay = new Date(b.time.updated).setHours(0, 0, 0, 0) - new Date(a.time.updated).setHours(0, 0, 0, 0) if (updatedDay !== 0) return updatedDay diff --git a/packages/opencode/test/cli/tui/session-list-visibility.test.ts b/packages/opencode/test/cli/tui/session-list-visibility.test.ts new file mode 100644 index 000000000..6df2e8831 --- /dev/null +++ b/packages/opencode/test/cli/tui/session-list-visibility.test.ts @@ -0,0 +1,231 @@ +import { afterEach, describe, expect, setDefaultTimeout } from "bun:test" +import { Effect, Layer } from "effect" + +setDefaultTimeout(30_000) + +import { Agent } from "../../../src/agent/agent" +import { Actor } from "../../../src/actor/spawn" +import { ActorRegistry } from "../../../src/actor/registry" +import { Bus } from "../../../src/bus" +import { Config } from "../../../src/config" +import { Git } from "../../../src/git" +import { Instance } from "../../../src/project/instance" +import { Provider } from "../../../src/provider" +import { Session } from "../../../src/session" +import { classifySession } from "../../../src/session/visibility" +import { SessionID } from "../../../src/session/schema" +import { Truncate } from "../../../src/tool" +import { Worktree } from "../../../src/worktree" +import * as CrossSpawnSpawner from "../../../src/effect/cross-spawn-spawner" +import { Log } from "../../../src/util" +import { provideTmpdirInstance } from "../../fixture/fixture" +import { testEffect } from "../../lib/effect" + +void Log.init({ print: false }) + +afterEach(async () => { + await Instance.disposeAll() +}) + +const env = Layer.mergeAll( + Session.defaultLayer, + ActorRegistry.defaultLayer, + Provider.defaultLayer, + Truncate.defaultLayer, + Agent.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Bus.defaultLayer, + Config.defaultLayer, + Worktree.defaultLayer, + Git.defaultLayer, + Actor.defaultLayer, +) + +const it = testEffect(env) + +const DIALOG = new URL("../../../src/cli/cmd/tui/component/dialog-session-list.tsx", import.meta.url).pathname + +/** + * The populations the Sessions dialog has to tell apart, as the user actually + * sees them. Both are children of the SAME parent, both were created by + * `session.create({ parentID })`, and the only thing that separates them is the + * actor row — which is exactly why the list may not discriminate on the title. + * + * - orchestrator peer children (`actor/spawn.ts`, `mode: "peer"`) — the + * `Orchestrator` / `[topic:…]` rows in the user's list. MUST stay listed. + * - the checkpoint-writer host (`session/checkpoint.ts`, `mode: "subagent"`, + * `agent: "checkpoint-writer"`) — the `↳ checkpoint-writer: …` rows. MUST go. + */ +const scaffold = Effect.gen(function* () { + const sessions = yield* Session.Service + const actorReg = yield* ActorRegistry.Service + + const root = yield* sessions.create({ title: "Orchestrator" }) + + const registerPeer = (sessionID: string) => + actorReg.register({ + sessionID: SessionID.make(sessionID), + actorID: sessionID, + mode: "peer", + agent: "build", + description: "orchestrator child", + contextMode: "none", + contextWatermark: undefined, + background: true, + lifecycle: "persistent", + tools: undefined, + }) + + // Titled exactly as the user's screenshot shows them. + const topic = yield* sessions.create({ + parentID: root.id as SessionID, + title: "[topic:memory-switch] memory 开关方案调研", + }) + yield* registerPeer(topic.id) + + const plain = yield* sessions.create({ + parentID: root.id as SessionID, + title: "build: 在 mimocode 引擎侧实现「memory 写入开关」", + }) + yield* registerPeer(plain.id) + + // checkpoint.ts creates this with the title ALREADY set, before it registers + // the actor row — which is how it reaches the TUI store via `session.updated`. + const writerHost = yield* sessions.create({ + parentID: root.id as SessionID, + title: "checkpoint-writer: Previous checkpoint: /Users/mi/.local/share/mimocode/memory/sessions/ses_x/checkpoint.md", + }) + yield* actorReg.register({ + sessionID: writerHost.id as SessionID, + actorID: "checkpoint-writer-1", + mode: "subagent", + agent: "checkpoint-writer", + description: "writer", + contextMode: "none", + contextWatermark: undefined, + background: true, + lifecycle: "ephemeral", + tools: undefined, + }) + + return { sessions, actorReg, root, topic, plain, writerHost } +}) + +/** The dialog reads rows out of the sync store; over the API that is listBySession. */ +const rowsOf = (actorReg: ActorRegistry.Interface, sessionID: string) => + actorReg.listBySession(SessionID.make(sessionID)).pipe( + Effect.map((rows) => rows.map((row) => ({ mode: row.mode, agent: row.agent }))), + ) + +describe("the Sessions dialog lists orchestrator children and not machinery hosts", () => { + it.live("admits orchestrator peer children (including [topic:…]) and refuses the writer host", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const { actorReg, root, topic, plain, writerHost } = yield* scaffold + + const verdict = (s: { id: string; parentID?: string | null }) => + rowsOf(actorReg, s.id).pipe(Effect.map((rows) => classifySession(s, rows))) + + // ⚠️The regression this test exists for. These are user-visible sessions + // the orchestrator created with `session create`; a filter that drops them + // is worse than the bug it was written to fix. + expect((yield* verdict(topic)).renderable).toBe(true) + expect((yield* verdict(plain)).renderable).toBe(true) + + // The parent itself is a root and is listed without consulting rows. + expect((yield* verdict(root)).renderable).toBe(true) + + const writer = yield* verdict(writerHost) + expect(writer.renderable).toBe(false) + if (!writer.renderable) expect(writer.reason).toContain("checkpoint-writer") + }), + ), + ) + + // The two populations differ ONLY by actor row: same parent, same creation call. + // The writer's title is the one thing a tempting shortcut would key on, so the + // titles are swapped here. If either verdict follows the title, the rule has + // drifted and a user session named "checkpoint-writer: …" would vanish. + it.live("the verdict follows the actor row, not the title", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const actorReg = yield* ActorRegistry.Service + const root = yield* sessions.create({ title: "Orchestrator" }) + + // Peer row wearing the writer's title. + const decoy = yield* sessions.create({ + parentID: root.id as SessionID, + title: "checkpoint-writer: Previous checkpoint: /tmp/decoy.md", + }) + yield* actorReg.register({ + sessionID: decoy.id as SessionID, + actorID: decoy.id, + mode: "peer", + agent: "build", + description: "orchestrator child that named itself confusingly", + contextMode: "none", + contextWatermark: undefined, + background: true, + lifecycle: "persistent", + tools: undefined, + }) + + // Writer row wearing a friendly topic title. + const disguised = yield* sessions.create({ + parentID: root.id as SessionID, + title: "[topic:memory-switch] memory 开关方案调研", + }) + yield* actorReg.register({ + sessionID: disguised.id as SessionID, + actorID: "checkpoint-writer-1", + mode: "subagent", + agent: "checkpoint-writer", + description: "writer", + contextMode: "none", + contextWatermark: undefined, + background: true, + lifecycle: "ephemeral", + tools: undefined, + }) + + const verdict = (s: { id: string; parentID?: string | null }) => + rowsOf(actorReg, s.id).pipe(Effect.map((rows) => classifySession(s, rows))) + + expect((yield* verdict(decoy)).renderable).toBe(true) + expect((yield* verdict(disguised)).renderable).toBe(false) + }), + ), + ) +}) + +// There is no Solid render harness for the dialog, so the wiring is asserted at +// the source level — the same reason and the same shape as the route guard's +// assertion in test/session/internal-session-prohibition.test.ts. Without this, +// deleting the filter would restore the bug while both behavioural tests above +// still passed, because they exercise classifySession rather than the dialog. +describe("the Sessions dialog wires the visibility predicate into its child arm", () => { + it.live("filters children through classifySession", () => + Effect.promise(async () => { + const src = await Bun.file(DIALOG).text() + expect(src).toContain('from "@/session/visibility"') + expect(src).toContain("classifySession(x, sync.data.actor?.[x.id]).renderable") + // The root arm must stay unconditional and the child arm must be gated: + // this is the exact expression, so a future edit that drops `listable(x)` + // fails here. + expect(src).toContain("x.parentID === undefined || (isChildOfCurrent(x) && listable(x))") + }), + ) + + it.live("does not discriminate on the checkpoint-writer title", () => + Effect.promise(async () => { + const src = await Bun.file(DIALOG).text() + const code = src + .split("\n") + .filter((line) => !line.trimStart().startsWith("//") && !line.trimStart().startsWith("*")) + .join("\n") + expect(code).not.toContain('"checkpoint-writer') + expect(code).not.toContain("startsWith(") + }), + ) +}) From bce8bccfb62625dc6c4f57bf363f54883cd52e5e Mon Sep 17 00:00:00 2001 From: wqymi Date: Fri, 7 Aug 2026 20:42:59 +0800 Subject: [PATCH 133/135] =?UTF-8?q?feat(memory):=20add=20the=20memory.disa?= =?UTF-8?q?ble=5Fwrite=20switch=20=E2=80=94=20stop=20memory=20writes,=20ke?= =?UTF-8?q?ep=20reads=20(#2040)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(config): add the memory.capture switch field Controls memory WRITES only; the read path is unaffected. The default is not written into the schema — each read site realizes it with `?? true`, matching memory.cc_index / checkpoint.* / dream.* in this repo. * feat(checkpoint): W1 do not start the checkpoint writer when memory.capture is off Short-circuit with `return "skipped"` at the very top of tryStartCheckpointWriter, which holds down all three write paths from a single place: direct template writes, the writer subagent spawn, and the validation-retry rename. The read path (renderRebuildContext / memory retrieval) is completely unaffected. * feat(memory): W2 stop demanding progress.md when capture is off W2 (subagent-progress-checker): the postStop hook returns immediately when capture is off. Otherwise "demand progress.md -> the write is hard denied -> demand it again" forms a postStop infinite loop that burns tokens. Config is read through the plugin client to avoid the app-runtime import cycle; a failed read fails open. W3 (gating the high-pressure "write to memory" nudge) is not part of this commit: that nudge was deleted wholesale upstream by dd1e20cab "fix(session): remove context pressure nudge". Gating a feature that no longer exists is pointless, so prompt.ts is byte-for-byte identical to origin/main. * feat(memory): W5 hard-deny tool writes inside the memory directory when capture is off memory-path-guard stays a pure function: the switch is passed in via the optional captureEnabled parameter, and omitting it counts as enabled (backward compatible, so existing call sites need no change). The error copy states outright that memory writing is disabled and forbids the model from retrying under a different path, which avoids a loop caused by prompt drift. external-directory reads config with Effect.serviceOption — the `R` of Tool.Def.execute must be `never`, so it cannot `yield* Config.Service`; it fails open when the service is missing. Known residual: bash is not covered by this gate (already declared in the memory-path-guard.ts comment), and a model using a heredoc can still get through. Extending the gate to bash would disturb the whole permission layer, so it is out of scope for this round. * test(plugin): stub the client in the postStop test and pin that capture=false stops demanding progress.md The original test built pluginInput as `{} as never`, which crashes once the hook starts reading config. It now supplies a minimal client stub (capture unset = no memory section = on by default), plus three new cases: with capture=false the hook neither nags nor creates files, and capture=true behaves identically to having no config at all. * test(memory): integration coverage of the capture switch on the write and read paths W1: with the field absent and with capture:true it still reports started and bootstraps the templates; with capture:false it returns skipped, the spawn count is 0, and not one of checkpoint.md / notes.md / tasks is created. Read path: under capture:false an existing checkpoint still produces rebuild context and memory retrieval still hits old memories. W5: strings config -> serviceOption -> guard end to end, confirming that the caller gets an explicit "disabled" error, that writes outside the memory tree are unaffected, and that an absent field or true still lets writes through. * refactor(memory)!: rename memory.capture to memory.disable_write (negative boolean) + W6 stops dream/distill Field: memory.capture (positive) -> memory.disable_write (negative, optional boolean, no .default). Absent/false = writes proceed as usual; only true disables them. Read sites funnelled: the new isMemoryWriteEnabled(cfg) in src/memory/write-gate.ts. The double negative exists only inside that function body (disable_write !== true). Business code always calls it positively and never reads the field directly. All five call sites across W1/W2/W5/W6 go through the accessor. W6 (new this round): shouldAutoDream / shouldAutoDistill return false when writing is off, so the background does not keep auto-producing memory and skill artifacts after the switch is flipped. Filled in: the end-to-end postStop infinite-loop regression now asserts that the nag copy does not appear, because a general subagent also receives unfinished-task reminders unrelated to memory, so the turn count cannot isolate it. W3 has no corresponding change: the high-pressure "write to memory" nudge section was already deleted wholesale upstream by dd1e20cab. Error copy: states outright that memory WRITING is disabled, points at memory.disable_write, and notes that reads are unaffected. * feat(memory): surface the compaction fallback when memory writing is off With memory.disable_write on, no checkpoint is ever written, so every overflow degrades to compaction for the whole session. That degradation left the user only a log line, and the one message that was surfaced blamed a failed checkpoint writer - reading like a bug to report rather than the switch they set. Name the switch instead, once per session, on the existing status channel plus a persisted display-only part so the notice survives past the status flash and reaches headless runs. * fix(memory): fall back to compaction immediately when memory writing is off A rebuild with `memory.disable_write` on could only ever end in compaction, but it walked the whole doomed path to get there: read the checkpoint file, probe hasCheckpoint plus lastBoundary, start a writer that short-circuits to "skipped", then await a writer that was never started. Every step is predetermined when the switch is on, and the wait was announced to the user as "Writing checkpoint…" — a wait for a writer we were never going to start. Guard at the top of rebuildEnsuringCheckpoint: when memory writing is off it returns the new "memory-write-off" outcome before touching disk, the DB or the writer. All three fallback sites (the token-threshold overflow, the provider-signalled overflow, and manual /rebuild) treat it as compact now and say why, so the notice is reason-attributed rather than inferred from a re-read of the config. Memory-on semantics are untouched: "insert-failed" still refuses to compact, and a genuine "writer-failed" keeps its own "the checkpoint writer failed" text, which the switch case can no longer trigger. The unit test installs a working writer stub and asserts the writer-wait announcement never fires — verified discriminating by neutralizing the early return, which makes exactly that assertion fail. The spawn count is asserted too but is documented as weaker: the memory gate inside tryStartCheckpointWriter already blocks the spawn, so it stays zero either way. * fix(memory): emit the memory-write-off messages in English only The compaction-fallback notice and the W5 memory-write refusal each packed English and Chinese into a single string. Neither is a prompt: the notice is persisted with `ignored: true`, the repo's display-only flag, so it is shown to the user and withheld from the model context. These messages land in the session record, which the TUI, headless `run --format json`, and other consuming clients all read. The engine cannot know the reader's locale; the consuming client can, and already carries its own translations. So the engine emits stable single-language English, matching its neighbours `compactedInsteadMsg` / `rebuildFailedMsg`, and localization stays with whoever renders it. No i18n mechanism is introduced here. Both texts keep their full content. The notice still names the switch AND that compaction stood in for the rebuild, keeps the `memory.disable_write` remedy and the "nothing is broken" reassurance, and does not escalate its wording. The refusal still says WRITING is off rather than memory, still tells the caller not to retry another memory path, and still notes that reading is unaffected. The tests that asserted both languages were present now anchor on the English text and pin the single-language invariant instead. --- packages/opencode/src/config/config.ts | 4 + packages/opencode/src/memory/write-gate.ts | 33 +++ .../src/plugin/subagent-progress-checker.ts | 30 ++- packages/opencode/src/session/auto-dream.ts | 7 + packages/opencode/src/session/checkpoint.ts | 28 ++- packages/opencode/src/session/prompt.ts | 181 ++++++++++++-- .../opencode/src/tool/external-directory.ts | 23 +- .../opencode/src/tool/memory-path-guard.ts | 23 ++ ...op-progress-write-permission.repro.test.ts | 52 ++++ .../test/config/memory-disable-write.test.ts | 60 +++++ .../plugin/subagent-progress-checker.test.ts | 54 +++- .../session/auto-dream-memory-write.test.ts | 111 +++++++++ .../session/checkpoint-memory-write.test.ts | 233 ++++++++++++++++++ .../test/session/rebuild-on-the-spot.test.ts | 194 ++++++++++++++- .../test/tool/memory-path-guard.test.ts | 107 ++++++++ .../test/tool/memory-write-gate.test.ts | 111 +++++++++ 16 files changed, 1221 insertions(+), 30 deletions(-) create mode 100644 packages/opencode/src/memory/write-gate.ts create mode 100644 packages/opencode/test/config/memory-disable-write.test.ts create mode 100644 packages/opencode/test/session/auto-dream-memory-write.test.ts create mode 100644 packages/opencode/test/session/checkpoint-memory-write.test.ts create mode 100644 packages/opencode/test/tool/memory-write-gate.test.ts diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a1e573696..6e8725799 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -341,6 +341,10 @@ const InfoSchema = Schema.Struct({ ), memory: Schema.optional( Schema.Struct({ + disable_write: Schema.optional(Schema.Boolean).annotate({ + description: + "Stop WRITING new memory. Default: false (memory is written). When true, no new memory is produced — session checkpoint.md, project MEMORY.md, notes.md and per-task progress.md are never written, the high-pressure 'save your learnings to memory' nudge is suppressed, and automatic dream/distill runs are skipped. READING is deliberately unaffected: existing memory still loads into session-rebuild context and the builtin `memory` search tool keeps working. Nothing is ever deleted — set it back to false to resume writing on top of the existing files.", + }), cc_index: Schema.optional(Schema.Boolean).annotate({ description: "Index Claude Code memory (~/.claude/projects//memory) and expose under scope='cc'. Default: false. Note: when enabled, every mimocode agent (build/explore/subagents) can search these memories via the builtin `memory` tool — including CC's `type: user` (your role/preferences) and `type: feedback` (your guidance) categories. CC originally writes them for future CC sessions; flipping this on widens the consumer set to mimocode agents on the same machine. Leave disabled (default) if you don't want personal context recallable from a prompt-injection-vulnerable agent.", diff --git a/packages/opencode/src/memory/write-gate.ts b/packages/opencode/src/memory/write-gate.ts new file mode 100644 index 000000000..0c2c2e3eb --- /dev/null +++ b/packages/opencode/src/memory/write-gate.ts @@ -0,0 +1,33 @@ +/** + * Single read point for the memory write switch. + * + * Config field: `memory.disable_write` (negative). This accessor is the ONLY + * place that double negative is allowed to exist — it exposes a positive + * predicate so every gate reads as `if (!isMemoryWriteEnabled(cfg)) ...`. + * Business code must never touch `disable_write` directly: field name, polarity, + * and default all live in this one function body. + * + * The parameter is structural rather than `Config.Info` so the same accessor + * serves callers holding a generated-SDK config object (the plugin hook reads + * config over the plugin client, whose type lags the engine schema). + */ +export type MemoryWriteConfig = { + memory?: { + disable_write?: boolean + } +} + +/** + * Whether NEW memory may be written. Reading is never affected by this switch. + * + * Default ENABLED — an absent config, an absent `memory` section, an absent + * field, and an explicit `false` all mean writes proceed, so upgrading without + * touching config keeps today's behavior. + * + * `!== true` rather than `?? false`: only a literal `true` disables, so a + * malformed non-boolean value degrades to "writes enabled" instead of silently + * killing memory writes. + */ +export function isMemoryWriteEnabled(cfg: MemoryWriteConfig | undefined): boolean { + return cfg?.memory?.disable_write !== true +} diff --git a/packages/opencode/src/plugin/subagent-progress-checker.ts b/packages/opencode/src/plugin/subagent-progress-checker.ts index 6a5a11c67..302f5c9c4 100644 --- a/packages/opencode/src/plugin/subagent-progress-checker.ts +++ b/packages/opencode/src/plugin/subagent-progress-checker.ts @@ -3,6 +3,7 @@ import fs from "fs/promises" import path from "path" import { Log } from "../util" import { progressPath } from "../session/checkpoint-paths" +import { isMemoryWriteEnabled, type MemoryWriteConfig } from "../memory/write-gate" import type { SessionID } from "../session/schema" const log = Log.create({ service: "plugin.subagent-progress-checker" }) @@ -78,7 +79,27 @@ async function injectFrontmatter(filePath: string, body: string): Promise await Bun.write(filePath, newBody) } -export async function SubagentProgressCheckerPlugin(_pluginInput: PluginInput): Promise { +/** + * Whether new memory may be written. Delegates the field read to the shared + * accessor (memory/write-gate.ts) so this hook can't drift from the write gate. + * + * Config comes over the plugin client, not Config.Service: this hook body is a + * plain async function with no Effect context, and importing AppRuntime here + * would close an import cycle (app-runtime → Plugin.defaultLayer → + * plugin/index → this file). The client carries the instance directory, so it + * resolves the same config the write gate sees. + * + * Fails OPEN: a config read that errors must never silently disable the journal + * check. + */ +async function memoryWriteEnabled(client: PluginInput["client"]): Promise { + const res = await client.config.get().catch(() => undefined) + // Cast is structural: the generated SDK config type lags the engine schema + // until the next SDK regen, so the field isn't on it yet. + return isMemoryWriteEnabled(res?.data as MemoryWriteConfig | undefined) +} + +export async function SubagentProgressCheckerPlugin(pluginInput: PluginInput): Promise { return { "actor.postStop": { // Use excludeOnly so the matcher fires for ALL actor types EXCEPT those @@ -110,6 +131,13 @@ export async function SubagentProgressCheckerPlugin(_pluginInput: PluginInput): // `=== false` (not falsy): an absent canWrite must NOT suppress (fail-open). if ((input as { canWrite?: boolean }).canWrite === false) return + // Memory writing off — the write gate (memory-path-guard) hard-rejects + // progress.md, so asking the subagent to write it would spin the postStop + // ReAct loop forever: nudge → write rejected → nudge again, burning a model + // turn per iteration. Bail before the first nudge. Checked after the two + // sync fast-outs above so non-task-bound subagents don't pay a config read. + if (!(await memoryWriteEnabled(pluginInput.client))) return + const sessionID = input.sessionID as SessionID const filePath = progressPath(sessionID, taskId) diff --git a/packages/opencode/src/session/auto-dream.ts b/packages/opencode/src/session/auto-dream.ts index 6908821bf..7f8177302 100644 --- a/packages/opencode/src/session/auto-dream.ts +++ b/packages/opencode/src/session/auto-dream.ts @@ -1,4 +1,5 @@ import { Effect } from "effect" +import { isMemoryWriteEnabled } from "@/memory/write-gate" import { Database, eq, desc, asc, isNull } from "@/storage" import { SessionTable } from "./session.sql" import { Log } from "@/util" @@ -107,6 +108,9 @@ function shouldAutoRun(input: { } export function shouldAutoDream(cfg: Config.Info) { + // Memory writing off → the consolidation pass that rewrites project memory + // must not run either. + if (!isMemoryWriteEnabled(cfg)) return Effect.succeed(false) const enabled = cfg.dream?.auto === true if (!enabled) return Effect.succeed(false) const now = Date.now() @@ -117,6 +121,9 @@ export function shouldAutoDream(cfg: Config.Info) { } export function shouldAutoDistill(cfg: Config.Info) { + // Distill reads memory to mine patterns and then auto-produces artifacts in the + // background. With writing off, nothing should be produced automatically. + if (!isMemoryWriteEnabled(cfg)) return Effect.succeed(false) const enabled = cfg.distill?.auto === true if (!enabled) return Effect.succeed(false) const now = Date.now() diff --git a/packages/opencode/src/session/checkpoint.ts b/packages/opencode/src/session/checkpoint.ts index 7f025738c..b5d34bac4 100644 --- a/packages/opencode/src/session/checkpoint.ts +++ b/packages/opencode/src/session/checkpoint.ts @@ -4,6 +4,7 @@ import { Global } from "@/global" import { Bus } from "@/bus" import { Config } from "@/config" import { Memory } from "@/memory" +import { isMemoryWriteEnabled } from "@/memory/write-gate" import { MemoryFtsTable } from "@/memory/fts.sql" import { TaskRegistry } from "@/task/registry" import { ActorRegistry } from "@/actor/registry" @@ -417,9 +418,9 @@ export type TryStartCheckpointWriterInput = { * newest wins because its range is a strict superset of the * older pending range, so the older one would just duplicate * work. (F40) - * - "skipped": the request was rejected outright — empty session, system- - * spawned subagent, or Actor service unavailable. No writer - * will fire for this request now or later. + * - "skipped": the request was rejected outright — memory writing disabled, + * empty session, system-spawned subagent, or Actor service + * unavailable. No writer will fire for this request now or later. */ export type TryStartCheckpointWriterResult = "started" | "queued" | "skipped" @@ -588,6 +589,27 @@ export const layer: Layer.Layer< ) => Effect.Effect = Effect.fn("SessionCheckpoint.tryStartCheckpointWriter")(function* ( input: TryStartCheckpointWriterInput, ) { + // Memory writing disabled — stop producing NEW memory. This is the single + // gate for the whole write side of checkpointing: template bootstrap + // (ensureCheckpointTemplate / ensureMemoryTemplate / ensureNotesTemplate), + // the writer subagent spawn, and the validator retry rename all live past + // this point, so returning here holds every one of them down at once. We + // deliberately never spawn rather than spawn-and-drop-the-write: the writer + // would burn a full model turn producing bytes nobody stores. + // + // READS are untouched — renderRebuildContext still injects an existing + // checkpoint.md / MEMORY.md / notes.md, and the `memory` search tool keeps + // working. The reads inside this function (prior checkpoint, progressDiff) + // exist only to feed the writer prompt, so short-circuiting loses no + // read capability. + // + // Default is ENABLED: absent config → write. The field name and polarity + // live in exactly one place (memory/write-gate.ts). + if (!isMemoryWriteEnabled(yield* config.get())) { + log.info("memory writing disabled, skipping checkpoint", { sessionID: input.sessionID }) + return "skipped" as const + } + // F40: writer1 still running. Evict any prior pending and queue this // request — newest wins because its range is a strict superset of the // older pending range, so older pending checkpoints would only diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 97964ec67..049b1ad0e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -31,6 +31,7 @@ import { SessionCompaction } from "./compaction" import { computeLastMessageInfo } from "./last-message-info" import { contextPressureLevel, usable, isOverflow as overflowCheck } from "./overflow" import { Config } from "@/config" +import { isMemoryWriteEnabled } from "@/memory/write-gate" import { Global } from "@/global" import { Bus } from "../bus" import { ProviderTransform } from "../provider" @@ -435,8 +436,12 @@ export const layer = Layer.effect( * - "insert-failed" a checkpoint DOES exist but the boundary insert still * refused (degraded, e.g. renderRebuildContext empty). * Callers must report this honestly and must NOT compact. + * - "memory-write-off" nothing was attempted at all: memory writing is + * switched off, so a checkpoint cannot exist and cannot be + * produced. Callers may compact, and MUST say the switch is + * why — never that a writer failed. */ - type RebuildAttempt = "rebuilt" | "writer-failed" | "insert-failed" + type RebuildAttempt = "rebuilt" | "writer-failed" | "insert-failed" | "memory-write-off" // The single place that decides whether a rebuild may degrade to // compaction. Every caller — both auto context-overflow sites and the @@ -462,6 +467,26 @@ export const layer = Layer.effect( /** Run once, immediately before the wait begins, to explain the stall. */ onWaitingForWriter?: Effect.Effect }) { + // 0. Memory writing off → there is nothing to try. Bail out BEFORE any of + // the work below, because with the switch on every step of it is + // predetermined to be useless: no checkpoint can exist (the writer has + // never been allowed to write one), so `rebuildFromCheckpoint` fails, + // the hasCheckpoint/lastBoundary probes both come back empty, and + // `tryStartCheckpointWriter` short-circuits to "skipped" + // (checkpoint.ts:608) — after which `waitForWriter` still has to be + // awaited for a writer that was never started. That whole detour ends at + // the same compaction the guard reaches immediately, so it buys nothing + // and costs disk reads, DB reads and a wait. Reaching compaction + // immediately also means `onWaitingForWriter` is never run: telling the + // user we are waiting for a writer we are not going to start would be a + // lie. + // + // Default-enabled lives in isMemoryWriteEnabled (memory/write-gate.ts): + // only a literal `disable_write: true` takes this branch, so a missing + // or malformed value keeps the normal path rather than silently + // degrading every rebuild. + if (!isMemoryWriteEnabled(yield* config.get())) return "memory-write-off" as const + // 1. Whatever is already on disk. if (yield* rebuildFromCheckpoint(input).pipe(Effect.catch(() => Effect.succeed(false)))) return "rebuilt" as const @@ -534,6 +559,92 @@ export const layer = Layer.effect( return "insert-failed" as const }) + /** + * What the user is told when a rebuild degrades to compaction *because the + * memory write switch is off* — not because anything failed. + * + * With `memory.disable_write` on, no checkpoint can ever exist for the + * session, so `rebuildEnsuringCheckpoint` returns "memory-write-off" on the + * spot and every overflow degrades to compaction. That is the switch working + * as asked, but the only trace of it was a log line ("memory writing + * disabled, skipping checkpoint") no user reads — and the one message that IS + * surfaced, `compactedInsteadMsg`, blames "the checkpoint writer failed", + * which reads like a bug worth reporting. So the two causes get two texts: + * this one names the switch. + * + * Single-language English, deliberately: this text is persisted into the + * session record, which the TUI, headless `run --format json`, and every + * other consuming client all read, and the engine does not know the reader's + * locale — the consuming client does, and already carries its own + * translations. So the engine emits one stable English string, exactly like + * its neighbours `compactedInsteadMsg` / `rebuildFailedMsg`, and + * localization stays with whoever renders it. + */ + const MEMORY_WRITE_OFF_FALLBACK_NOTICE = + "Memory writing is off, so no checkpoint can be written for this session and the context was compacted " + + "instead of rebuilt from one. Compaction is what runs whenever the context fills up: earlier turns leave " + + "the model's view without a summary, which can weaken continuity on long-running work. Nothing is broken " + + "and the session keeps working — to get checkpoint rebuilds back, set `memory.disable_write` to false in " + + "config." + + // Sessions that have already been told once, this process. + // + // The notice describes a CONFIG STATE, not an event: it says exactly the + // same thing at every boundary, and the automatic overflow path can reach + // that boundary many times in one long session. Persisting it once per + // session keeps a long run from stacking identical warnings in the + // transcript. A fresh process (a resumed session, a later `run`) announces + // it again — the user may never have seen the earlier one, and the switch + // still shapes that run — so this is deliberately in-memory rather than a + // durable "already warned" flag. + const memoryWriteOffNoticed = new Set() + + /** + * Surface the memory-write-off degradation, and return the notice text so a + * caller holding its own user-facing channel can reuse the same wording. + * + * Only ever called on the "memory-write-off" branch, so it does not re-check + * the switch: the attempt value already carries that fact, decided by the + * guard at the top of `rebuildEnsuringCheckpoint`. Re-reading the config here + * would let a mid-rebuild config change mis-attribute the cause, and would + * imply this notice is reachable from a genuine `writer-failed` — it is not. + * + * Persisting the notice as a part is what makes it outlive the status-line + * flash: a `session.status` busy→idle pair is in-memory and never reaches + * the headless event stream, so on `run --format json` the degradation was + * literally unobservable. `ignored: true` keeps the part out of the model's + * context (message-v2.ts:709) — a notice addressed to the user must never + * reach the model as something the user instructed — and `time.end` is what + * makes the CLI emit it (cli/cmd/run.ts:498). + */ + const noticeMemoryWriteOffFallback = Effect.fn("SessionPrompt.noticeMemoryWriteOffFallback")(function* ( + sessionID: SessionID, + ) { + if (memoryWriteOffNoticed.has(sessionID)) return MEMORY_WRITE_OFF_FALLBACK_NOTICE + memoryWriteOffNoticed.add(sessionID) + const msgs = yield* sessions.messages({ sessionID, agentID: "main" }) + // Anchor on the compaction boundary this fallback just inserted — the + // notice exists to explain that boundary. Falling back to the newest + // message keeps the notice visible if the boundary insert itself was + // swallowed (compaction.create runs under Effect.ignore at every site). + const anchor = msgs.findLast((m) => m.parts.some((p) => p.type === "compaction")) ?? msgs[msgs.length - 1] + if (!anchor) return MEMORY_WRITE_OFF_FALLBACK_NOTICE + const now = Date.now() + yield* sessions + .updatePart({ + id: PartID.ascending(), + messageID: anchor.info.id, + sessionID, + type: "text", + text: MEMORY_WRITE_OFF_FALLBACK_NOTICE, + synthetic: true, + ignored: true, + time: { start: now, end: now }, + }) + .pipe(Effect.ignore) + return MEMORY_WRITE_OFF_FALLBACK_NOTICE + }) + const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { const ctx = yield* InstanceState.context const parts: PromptInput["parts"] = [{ type: "text", text: template }] @@ -3491,13 +3602,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the } // A writer was started and awaited above (AUTO_WRITER_WAIT_MS) and - // still produced nothing — the ONE condition that may compact. - if (attempt === "writer-failed") { + // still produced nothing — or memory writing is off, so nothing was + // attempted at all. Either way this is the ONE state that may compact. + if (attempt === "writer-failed" || attempt === "memory-write-off") { // THE single compaction fallback: no checkpoint existed AND the - // writer failed / never ran / the bound expired. Note this is a - // bare boundary insert, not an LLM summary — everything before it - // is dropped unsummarized (compaction.ts:499, message-v2.ts:1037) - // — which is exactly why we tried to write a checkpoint first. + // writer failed / never ran / the bound expired / was never + // allowed to run at all. Note this is a bare boundary insert, not + // an LLM summary — everything before it is dropped unsummarized + // (compaction.ts:499, message-v2.ts:1037), which is exactly why + // we try to write a checkpoint first whenever we are allowed to. yield* compaction .create({ sessionID, @@ -3507,6 +3620,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the agentID: lastUser.agentID, }) .pipe(Effect.ignore) + // Was the switch the reason no checkpoint existed? Then say so — + // this path is otherwise completely silent (no status message at + // all mid-turn), which is how "compaction instead of rebuild" + // became invisible to the user. A genuine writer failure keeps its + // existing behaviour untouched. + if (attempt === "memory-write-off") + yield* noticeMemoryWriteOffFallback(sessionID).pipe(Effect.ignore) skipOverflowCheck = true continue } @@ -4070,8 +4190,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the return "continue" as const } - // Same as above: the writer ran and failed — not "no checkpoint". - if (attempt2 === "writer-failed") { + // Same as above: the writer ran and failed — not "no checkpoint" — + // or memory writing is off and nothing was attempted. + if (attempt2 === "writer-failed" || attempt2 === "memory-write-off") { // THE single compaction fallback (see the token-threshold site). yield* compaction .create({ @@ -4083,6 +4204,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the agentID: lastUser.agentID, }) .pipe(Effect.ignore) + // Same reason-split as the token-threshold site. + if (attempt2 === "memory-write-off") + yield* noticeMemoryWriteOffFallback(sessionID).pipe(Effect.ignore) skipOverflowCheck = true } // "insert-failed" → a checkpoint exists; must not compact. @@ -4351,19 +4475,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the }).pipe(Effect.catch(() => Effect.succeed("insert-failed" as const))) // A writer was started and awaited above (MANUAL_WRITER_WAIT_MS) and - // still produced nothing — only then may /rebuild degrade to compaction. - if (attempt === "writer-failed") { + // still produced nothing — or memory writing is off, so no writer was + // started at all. Only in those two states may /rebuild degrade to + // compaction. + if (attempt === "writer-failed" || attempt === "memory-write-off") { // No checkpoint AND the writer genuinely failed / never ran / the bound - // expired — the ONE fallback condition, shared with the auto overflow - // paths. An earlier revision of this branch deliberately did NOT - // compact here, reasoning that /rebuild means "rebuild from a - // checkpoint" so substituting a lossy summary would misreport what - // happened. The user overruled that tradeoff: if the writer genuinely - // failed, a truncating compaction beats doing nothing. We keep the - // report honest by naming the substitution on the status channel - // instead of silently swapping the mechanism, and — per the branch's - // existing noReply decision (3244ca732) — fabricate neither an - // assistant reply nor a synthetic user turn. + // expired / was never allowed to run — the ONE fallback condition, + // shared with the auto overflow paths. An earlier revision of this + // branch deliberately did NOT compact here, reasoning that /rebuild + // means "rebuild from a checkpoint" so substituting a lossy summary + // would misreport what happened. The user overruled that tradeoff: if + // the writer genuinely failed, a truncating compaction beats doing + // nothing. We keep the report honest by naming the substitution on the + // status channel instead of silently swapping the mechanism, and — per + // the branch's existing noReply decision (3244ca732) — fabricate + // neither an assistant reply nor a synthetic user turn. yield* compaction .create({ sessionID: input.sessionID, @@ -4375,7 +4501,18 @@ NOTE: At any point in time through this workflow you should feel free to ask the agentID: lastUser?.info.agentID ?? "main", }) .pipe(Effect.ignore) - yield* settle(compactedInsteadMsg) + // The two causes are very different and the user has to be able to + // tell them apart: a writer that genuinely broke (report it) versus the + // memory write switch being off (expected — you turned it off). When + // it's the switch, its notice replaces `compactedInsteadMsg`, whose + // "the checkpoint writer failed" would be a false alarm here. + const msg = + attempt === "memory-write-off" + ? yield* noticeMemoryWriteOffFallback(input.sessionID).pipe( + Effect.catch(() => Effect.succeed(MEMORY_WRITE_OFF_FALLBACK_NOTICE)), + ) + : compactedInsteadMsg + yield* settle(msg) return lastUser ?? msgs[msgs.length - 1]! } diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index 775f92473..8789cc341 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -1,8 +1,10 @@ import path from "path" -import { Effect } from "effect" +import { Effect, Option } from "effect" import { EffectLogger } from "@/effect" import { InstanceState } from "@/effect" import { Global } from "@/global" +import { Config } from "@/config" +import { isMemoryWriteEnabled } from "@/memory/write-gate" import type * as Tool from "./tool" import { Instance } from "../project/instance" import { ProjectID } from "../project/schema" @@ -71,6 +73,24 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string return Effect.runPromise(assertExternalDirectoryEffect(ctx, target, options).pipe(Effect.provide(EffectLogger.layer))) } +/** + * Whether new memory may be written (see memory/write-gate.ts for the field). + * + * Resolved with `Effect.serviceOption` rather than `yield* Config.Service` on + * purpose: `Tool.Def.execute` is typed `Effect` with NO + * requirements, so every helper a write tool calls must keep R = never. + * serviceOption reads the service out of the ambient runtime when present + * (always, in-app) without adding it to the requirement set. + * + * Fails OPEN — no Config service (unit tests, detached fibers) means writing + * stays enabled. A config we cannot read must never silently block memory writes. + */ +const memoryWriteEnabled = Effect.gen(function* () { + const svc = yield* Effect.serviceOption(Config.Service) + if (Option.isNone(svc)) return true + return isMemoryWriteEnabled(yield* svc.value.get()) +}) + /** * The single write-permission gate for file-mutating tools (edit, write, * apply_patch). Runs the two checks every write must pass, in order: @@ -123,6 +143,7 @@ export const assertWriteAllowed = Effect.fn("Tool.assertWriteAllowed")(function* projectID, sessionID: ctx.sessionID, taskId: ctx.taskId, + writeEnabled: yield* memoryWriteEnabled, }) }) diff --git a/packages/opencode/src/tool/memory-path-guard.ts b/packages/opencode/src/tool/memory-path-guard.ts index d03214b93..4e0c269a1 100644 --- a/packages/opencode/src/tool/memory-path-guard.ts +++ b/packages/opencode/src/tool/memory-path-guard.ts @@ -149,6 +149,11 @@ function isReservedForCheckpointWriter(parts: string[]): boolean { * - For all other agents: cannot write /tasks/* — that's * checkpoint-writer-only. * + * Both policies sit behind the memory write switch: when the caller passes + * `writeEnabled: false`, every write inside the memory tree is refused + * regardless of agent or path. Purity is preserved by taking the flag as a + * parameter — this module never reads config itself. + * * Non-memory paths and free keys under valid scopes pass through unmodified. */ export function assertMemoryWriteAllowed(input: { @@ -158,6 +163,8 @@ export function assertMemoryWriteAllowed(input: { projectID: ProjectID sessionID: SessionID taskId?: string + /** Whether memory writing is enabled. Omitted → enabled (the default). */ + writeEnabled?: boolean }): void { const { target, agentName, memoryRoot, projectID, sessionID } = input const memoryFile = path.join(memoryRoot, "projects", projectID, "MEMORY.md") @@ -167,6 +174,22 @@ export function assertMemoryWriteAllowed(input: { const normalizedRoot = memoryRoot.endsWith(path.sep) ? memoryRoot : memoryRoot + path.sep if (!target.startsWith(normalizedRoot)) return + // Memory write switch. Deliberately worded so the refusal cannot be mistaken + // for a path/permission problem — a model that reads "not allowed here" tends to + // retry a different memory path, which would just loop. Says WRITING is off, not + // that memory is off: reads still work. English only, like every other message + // this module throws: it has no locale to consult, and the consuming client + // that surfaces it carries its own translations. + if (input.writeEnabled === false) { + throw new Error( + `Memory WRITING is disabled: config \`memory.disable_write\` is true, so no new memory may be written.\n` + + `Refused: ${target}.\n` + + `Do NOT retry with another memory path — every path under ${memoryRoot} is refused while writing is off.\n` + + `Reading is unaffected: existing memory still loads into session context and the \`memory\` search tool still works.\n` + + `To re-enable, set \`memory.disable_write: false\` in config.`, + ) + } + const rel = path.relative(memoryRoot, target) const parts = rel.split(path.sep) diff --git a/packages/opencode/test/actor/poststop-progress-write-permission.repro.test.ts b/packages/opencode/test/actor/poststop-progress-write-permission.repro.test.ts index 8be394816..aaec6f86a 100644 --- a/packages/opencode/test/actor/poststop-progress-write-permission.repro.test.ts +++ b/packages/opencode/test/actor/poststop-progress-write-permission.repro.test.ts @@ -413,4 +413,56 @@ describe("postStop progress.md is gated by the subagent's write permission", () { git: true, config: providerCfg }, ), ) + + // memory.disable_write: true — the write gate hard-rejects progress.md, so the + // checker must not nudge at all. If it did, the subagent would loop + // nudge → rejected write → nudge until MAX_POST_REACT, burning a model turn per + // pass (the T3 death loop). + // + // The assertion is on the NUDGE TEXT, not the turn count: a task left + // in_progress triggers an unrelated "tasks you own are unfinished" reminder that + // also consumes turns, so a turn count would not isolate this hook. + it.live("memory writing disabled → task-bound general is never asked for a journal (no postStop loop)", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const actor = yield* Actor.Service + const session = yield* Session.Service + const tasks = yield* TaskRegistry.Service + + const parent = yield* session.create({ title: "writing off, no nudge" }) + const task = yield* tasks.create({ session_id: parent.id, summary: "probe" }) + const target = progressPath(parent.id, task.id) + + for (let i = 0; i < 6; i++) { + yield* llm.text("**Status**: success\n**Summary**: did the work, no journal expected") + } + + const result = yield* actor.spawn({ + mode: "subagent", + sessionID: parent.id, + agentType: "general", + task: "do the work", + context: "none", + tools: "INHERIT", + background: false, + model: ref, + task_id: task.id, + }) + + const outcome = yield* Deferred.await(result.outcome).pipe(Effect.timeout("30 seconds")) + expect(outcome.status).toBe("success") + + // No request may carry the progress-journal nudge. buildFeedback emits these + // two openers; either one appearing means the checker asked for a write the + // gate would refuse. + const sent = JSON.stringify(yield* llm.inputs) + expect(sent).not.toContain("write the task progress journal") + expect(sent).not.toContain("is missing required sections") + + const fs = yield* AppFileSystem.Service + expect(yield* fs.existsSafe(target)).toBe(false) + }), + { git: true, config: (url) => ({ ...providerCfg(url), memory: { disable_write: true } }) }, + ), + ) }) diff --git a/packages/opencode/test/config/memory-disable-write.test.ts b/packages/opencode/test/config/memory-disable-write.test.ts new file mode 100644 index 000000000..f4b49af74 --- /dev/null +++ b/packages/opencode/test/config/memory-disable-write.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test" +import { Config } from "../../src/config" +import { isMemoryWriteEnabled } from "../../src/memory/write-gate" + +describe("config.memory.disable_write", () => { + test("absent when memory section is omitted", () => { + expect(Config.Info.parse({}).memory?.disable_write).toBeUndefined() + }) + + test("absent when memory section is present but disable_write is unset", () => { + expect(Config.Info.parse({ memory: {} }).memory?.disable_write).toBeUndefined() + }) + + test("accepts boolean value", () => { + expect(Config.Info.parse({ memory: { disable_write: true } }).memory?.disable_write).toBe(true) + expect(Config.Info.parse({ memory: { disable_write: false } }).memory?.disable_write).toBe(false) + }) + + test("rejects non-boolean values", () => { + expect(() => Config.Info.parse({ memory: { disable_write: "yes" } })).toThrow() + }) + + test("coexists with cc_index", () => { + const cfg = Config.Info.parse({ memory: { disable_write: true, cc_index: true } }) + expect(cfg.memory?.disable_write).toBe(true) + expect(cfg.memory?.cc_index).toBe(true) + }) +}) + +// The accessor is the only place allowed to know the field name, its negative +// polarity, and its default. These pin all three so a future rename or a +// degradation of `!== true` into a truthy check fails loudly here. +describe("isMemoryWriteEnabled", () => { + test("undefined config → writing enabled", () => { + expect(isMemoryWriteEnabled(undefined)).toBe(true) + }) + + test("no memory section → writing enabled (backward compatible default)", () => { + expect(isMemoryWriteEnabled(Config.Info.parse({}))).toBe(true) + }) + + test("memory section without the field → writing enabled", () => { + expect(isMemoryWriteEnabled(Config.Info.parse({ memory: {} }))).toBe(true) + }) + + test("disable_write: false → writing enabled", () => { + expect(isMemoryWriteEnabled(Config.Info.parse({ memory: { disable_write: false } }))).toBe(true) + }) + + test("disable_write: true → writing disabled", () => { + expect(isMemoryWriteEnabled(Config.Info.parse({ memory: { disable_write: true } }))).toBe(false) + }) + + test("only a literal true disables — a non-boolean must not silently disable", () => { + // Config parsing rejects this shape; the accessor is nonetheless the last + // line of defense for callers holding an unvalidated (SDK-typed) object. + expect(isMemoryWriteEnabled({ memory: { disable_write: "true" as unknown as boolean } })).toBe(true) + expect(isMemoryWriteEnabled({ memory: { disable_write: 1 as unknown as boolean } })).toBe(true) + }) +}) diff --git a/packages/opencode/test/plugin/subagent-progress-checker.test.ts b/packages/opencode/test/plugin/subagent-progress-checker.test.ts index 1936e7322..84a762f75 100644 --- a/packages/opencode/test/plugin/subagent-progress-checker.test.ts +++ b/packages/opencode/test/plugin/subagent-progress-checker.test.ts @@ -21,8 +21,18 @@ async function withTmpHome(fn: (sessionID: SessionID) => Promise): Promise } } -async function getHooks() { - return await SubagentProgressCheckerPlugin({} as never) +// The hook reads the memory write switch through the plugin client, so the stub +// has to answer /config. `disableWrite: undefined` models a config with no memory +// section at all — the backward-compatible default (writing enabled). +async function getHooks(disableWrite?: boolean) { + const client = { + config: { + get: async () => ({ + data: disableWrite === undefined ? {} : { memory: { disable_write: disableWrite } }, + }), + }, + } + return await SubagentProgressCheckerPlugin({ client } as never) } function makeInput(sessionID: SessionID, task_id?: string, canWrite?: boolean) { @@ -183,6 +193,46 @@ describe("SubagentProgressCheckerPlugin postStop", () => { expect(fmCount).toBe(2) // opening --- and closing --- }) }) + + // T3 regression: with memory writing disabled the write gate hard-rejects + // progress.md. If this hook still nudged, the subagent would loop + // nudge → rejected write → nudge, burning a model turn each pass. + test("disable_write=true → no nudge even though the file is missing", async () => { + await withTmpHome(async (sid) => { + const hooks = await getHooks(true) + const reg = hooks["actor.postStop"] + if (!reg || typeof reg === "function") throw new Error("expected object form with run") + const fn = (reg as { run: (...args: any[]) => Promise }).run + const output: { continue?: boolean; reason?: string } = {} + await fn(makeInput(sid, "T4", true), output) + expect(output.continue).toBeUndefined() + expect(output.reason).toBeUndefined() + }) + }) + + test("disable_write=true → no file is created for a complete-looking task", async () => { + await withTmpHome(async (sid) => { + const hooks = await getHooks(true) + const reg = hooks["actor.postStop"] + if (!reg || typeof reg === "function") throw new Error("expected object form with run") + const fn = (reg as { run: (...args: any[]) => Promise }).run + await fn(makeInput(sid, "T4"), {}) + expect(await Bun.file(progressPath(sid, "T4")).exists()).toBe(false) + }) + }) + + test("disable_write=false → nudges exactly as with no config", async () => { + await withTmpHome(async (sid) => { + const hooks = await getHooks(false) + const reg = hooks["actor.postStop"] + if (!reg || typeof reg === "function") throw new Error("expected object form with run") + const fn = (reg as { run: (...args: any[]) => Promise }).run + const output: { continue?: boolean; reason?: string } = {} + await fn(makeInput(sid, "T4"), output) + expect(output.continue).toBe(true) + expect(output.reason).toContain(progressPath(sid, "T4")) + }) + }) }) // --------------------------------------------------------------------------- diff --git a/packages/opencode/test/session/auto-dream-memory-write.test.ts b/packages/opencode/test/session/auto-dream-memory-write.test.ts new file mode 100644 index 000000000..5f245f47f --- /dev/null +++ b/packages/opencode/test/session/auto-dream-memory-write.test.ts @@ -0,0 +1,111 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { eq } from "drizzle-orm" +import { Bus } from "../../src/bus" +import { Config } from "../../src/config" +import { AutoDream } from "../../src/session/auto-dream" +import { Session as SessionNs } from "../../src/session" +import { SessionTable } from "../../src/session/session.sql" +import { Database } from "../../src/storage" +import { Log } from "../../src/util" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" + +void Log.init({ print: false }) + +const it = testEffect( + Layer.mergeAll(CrossSpawnSpawner.defaultLayer, Bus.defaultLayer, Config.defaultLayer, SessionNs.defaultLayer), +) + +const DAY_MS = 24 * 60 * 60 * 1000 + +// Both auto-runs refuse to fire on a project younger than their interval, so a +// backdated top-level session is what makes these cases non-vacuous: the interval +// brake is released, and the ONLY remaining reason to return false is the switch. +// 60 days clears dream (7d) and distill (30d) alike. +const seedOldProject = Effect.fn("seedOldProject")(function* () { + const ssn = yield* SessionNs.Service + const info = yield* ssn.create({}) + yield* Effect.sync(() => + Database.use((db) => + db + .update(SessionTable) + .set({ time_created: Date.now() - 60 * DAY_MS }) + .where(eq(SessionTable.id, info.id)) + .run(), + ), + ) + return info +}) + +// W6. Dream rewrites project memory; distill mines memory for patterns and then +// auto-produces artifacts in the background. Neither may run while memory writing +// is off. +// +// Only the DISABLED direction is asserted here, and deliberately so: the enabled +// direction additionally passes through a module-level 10s spawn throttle +// (lastDreamSpawnTime / lastDistillSpawnTime) that is process-global and armed by +// any other test file whose session-create path evaluates these same predicates. +// An "enabled → true" control is therefore order-dependent across files. The +// enabled polarity is pinned instead by test/config/memory-disable-write.test.ts +// (accessor level, where `disable_write: false` and an absent field both mean +// enabled). The switch check is the first statement in both functions, so the +// assertions below hold regardless of throttle state. +describe("shouldAutoDream × memory write switch", () => { + it.live( + "disable_write: true → no auto dream, even on a project old enough to be due", + provideTmpdirInstance( + () => + Effect.gen(function* () { + yield* seedOldProject() + const cfg = yield* (yield* Config.Service).get() + expect(yield* AutoDream.shouldAutoDream(cfg)).toBe(false) + }), + { outsideGit: true, config: { memory: { disable_write: true } } }, + ), + ) + + // dream.auto is opt-in upstream, so this pins that the switch overrides the + // feature's own enable flag rather than merely coinciding with it. + it.live( + "disable_write: true beats an explicit dream.auto: true", + provideTmpdirInstance( + () => + Effect.gen(function* () { + yield* seedOldProject() + const cfg = yield* (yield* Config.Service).get() + expect(yield* AutoDream.shouldAutoDream(cfg)).toBe(false) + }), + { outsideGit: true, config: { dream: { auto: true }, memory: { disable_write: true } } }, + ), + ) +}) + +describe("shouldAutoDistill × memory write switch", () => { + it.live( + "disable_write: true → no auto distill, even on a project old enough to be due", + provideTmpdirInstance( + () => + Effect.gen(function* () { + yield* seedOldProject() + const cfg = yield* (yield* Config.Service).get() + expect(yield* AutoDream.shouldAutoDistill(cfg)).toBe(false) + }), + { outsideGit: true, config: { memory: { disable_write: true } } }, + ), + ) + + it.live( + "disable_write: true beats an explicit distill.auto: true", + provideTmpdirInstance( + () => + Effect.gen(function* () { + yield* seedOldProject() + const cfg = yield* (yield* Config.Service).get() + expect(yield* AutoDream.shouldAutoDistill(cfg)).toBe(false) + }), + { outsideGit: true, config: { distill: { auto: true }, memory: { disable_write: true } } }, + ), + ) +}) diff --git a/packages/opencode/test/session/checkpoint-memory-write.test.ts b/packages/opencode/test/session/checkpoint-memory-write.test.ts new file mode 100644 index 000000000..103e9d374 --- /dev/null +++ b/packages/opencode/test/session/checkpoint-memory-write.test.ts @@ -0,0 +1,233 @@ +import { describe, expect } from "bun:test" +import { Deferred, Effect, Layer } from "effect" +import * as fs from "fs/promises" +import path from "path" +import { Bus } from "../../src/bus" +import { Config } from "../../src/config" +import { Agent } from "../../src/agent/agent" +import { Memory } from "../../src/memory" +import { ActorRegistry } from "../../src/actor/registry" +import { Actor, type AgentOutcome } from "../../src/actor/spawn" +import { spawnRef } from "../../src/actor/spawn-ref" +import { prefixCaptureRef } from "../../src/session/prefix-capture-ref" +import { TaskRegistry } from "../../src/task/registry" +import { SessionCheckpoint } from "../../src/session/checkpoint" +import { checkpointPath, notesPath, tasksDir } from "../../src/session/checkpoint-paths" +import { Log } from "../../src/util" +import { Plugin } from "../../src/plugin" +import { provideTmpdirInstance } from "../fixture/fixture" +import { Session as SessionNs } from "../../src/session" +import { MessageID, PartID } from "../../src/session/schema" +import { ProviderID, ModelID } from "../../src/provider/schema" +import { ProviderTest } from "../fake/provider" +import { testEffect } from "../lib/effect" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" + +void Log.init({ print: false }) + +const ref = { + providerID: ProviderID.make("test"), + modelID: ModelID.make("test-model"), +} + +// Counts writer spawns so "writing off ⇒ no writer at all" is asserted on the +// spawn itself, not just on the absence of files. Mirrors the recordingActor in +// checkpoint-child-session.test.ts. +const spawnLog: { count: number } = { count: 0 } + +const recordingActor = Layer.effect( + Actor.Service, + Effect.gen(function* () { + const prevSpawnRef = spawnRef.current + const impl = Actor.Service.of({ + spawn: (input) => + Effect.gen(function* () { + spawnLog.count += 1 + const outcome = yield* Deferred.make() + return { actorID: `${input.agentType}-${spawnLog.count}`, sessionID: input.sessionID, outcome } + }), + cancel: () => Effect.void, + getForkContext: () => Effect.succeed(undefined), + }) + spawnRef.current = impl + yield* Effect.addFinalizer( + () => + Effect.sync(() => { + if (spawnRef.current === impl) spawnRef.current = prevSpawnRef + }), + ) + return impl + }), +) + +const deps = Layer.mergeAll( + ProviderTest.fake().layer, + Agent.defaultLayer, + Plugin.defaultLayer, + Bus.layer, + Config.defaultLayer, + Memory.defaultLayer, + TaskRegistry.defaultLayer, + ActorRegistry.defaultLayer, + recordingActor, +) + +const env = Layer.mergeAll( + SessionNs.defaultLayer, + CrossSpawnSpawner.defaultLayer, + SessionCheckpoint.layer.pipe(Layer.provide(SessionNs.defaultLayer), Layer.provideMerge(deps)), +) + +const it = testEffect(env) + +const reset = Effect.sync(() => { + spawnLog.count = 0 + prefixCaptureRef.current = undefined +}) + +const seedSession = Effect.fn("seedSession")(function* () { + const ssn = yield* SessionNs.Service + const info = yield* ssn.create({}) + const user = yield* ssn.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + agent: "build", + model: ref, + time: { created: Date.now() }, + }) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: user.id, + sessionID: info.id, + type: "text", + text: "seed", + }) + return info +}) + +describe("memory write gate (W1)", () => { + it.live( + "absent config → writer starts and memory files are bootstrapped (today's behavior)", + provideTmpdirInstance( + () => + Effect.gen(function* () { + yield* reset + const cp = yield* SessionCheckpoint.Service + const info = yield* seedSession() + + const outcome = yield* cp.tryStartCheckpointWriter({ + sessionID: info.id, + model: { providerID: "test", modelID: "test-model" }, + promptOps: {} as never, + }) + + expect(outcome).toBe("started") + expect(spawnLog.count).toBe(1) + expect(yield* Effect.promise(() => Bun.file(checkpointPath(info.id)).exists())).toBe(true) + expect(yield* Effect.promise(() => Bun.file(notesPath(info.id)).exists())).toBe(true) + }), + { outsideGit: true }, + ), + ) + + it.live( + "disable_write: false → identical to absent config", + provideTmpdirInstance( + () => + Effect.gen(function* () { + yield* reset + const cp = yield* SessionCheckpoint.Service + const info = yield* seedSession() + + const outcome = yield* cp.tryStartCheckpointWriter({ + sessionID: info.id, + model: { providerID: "test", modelID: "test-model" }, + promptOps: {} as never, + }) + + expect(outcome).toBe("started") + expect(spawnLog.count).toBe(1) + expect(yield* Effect.promise(() => Bun.file(checkpointPath(info.id)).exists())).toBe(true) + }), + { outsideGit: true, config: { memory: { disable_write: false } } }, + ), + ) + + it.live( + "disable_write: true → skipped, no writer spawned, no memory files created", + provideTmpdirInstance( + () => + Effect.gen(function* () { + yield* reset + const cp = yield* SessionCheckpoint.Service + const info = yield* seedSession() + + const outcome = yield* cp.tryStartCheckpointWriter({ + sessionID: info.id, + model: { providerID: "test", modelID: "test-model" }, + promptOps: {} as never, + }) + + expect(outcome).toBe("skipped") + expect(spawnLog.count).toBe(0) + // Nothing bootstrapped: no template, no notes, not even the session dir. + expect(yield* Effect.promise(() => Bun.file(checkpointPath(info.id)).exists())).toBe(false) + expect(yield* Effect.promise(() => Bun.file(notesPath(info.id)).exists())).toBe(false) + const tasks = yield* Effect.promise(() => + fs.readdir(tasksDir(info.id)).catch(() => "ENOENT" as const), + ) + expect(tasks === "ENOENT" || tasks.length === 0).toBe(true) + }), + { outsideGit: true, config: { memory: { disable_write: true } } }, + ), + ) +}) + +describe("the memory write switch leaves the READ path intact", () => { + it.live( + "disable_write: true → an existing checkpoint still produces rebuild context", + provideTmpdirInstance( + () => + Effect.gen(function* () { + yield* reset + const ssn = yield* SessionNs.Service + const cp = yield* SessionCheckpoint.Service + const info = yield* seedSession() + + // Memory written BEFORE the switch was flipped must keep feeding context. + const cpPath = checkpointPath(info.id) + yield* Effect.promise(() => fs.mkdir(path.dirname(cpPath), { recursive: true })) + yield* Effect.promise(() => + fs.writeFile(cpPath, "# Session checkpoint\n\n## §1 Active intent\nOld intent survives.\n"), + ) + + const rendered = yield* cp.renderRebuildContext(info.id, { agentID: "main" }) + expect(rendered.length).toBeGreaterThan(0) + expect(rendered).toContain("Old intent survives.") + }), + { outsideGit: true, config: { memory: { disable_write: true } } }, + ), + ) + + it.live( + "disable_write: true → the memory search tool still finds pre-existing memory", + provideTmpdirInstance( + () => + Effect.gen(function* () { + yield* reset + const memory = yield* Memory.Service + const root = yield* memory.root() + const file = path.join(root, "sessions", "ses_write_probe", "notes.md") + yield* Effect.promise(() => fs.mkdir(path.dirname(file), { recursive: true })) + yield* Effect.promise(() => + fs.writeFile(file, "## [turn 1]\nzarquon deadlock discovered in the widget pipeline.\n"), + ) + + const hits = yield* memory.search({ query: "zarquon" }) + expect(hits.some((h) => h.path === file)).toBe(true) + }), + { outsideGit: true, config: { memory: { disable_write: true } } }, + ), + ) +}) diff --git a/packages/opencode/test/session/rebuild-on-the-spot.test.ts b/packages/opencode/test/session/rebuild-on-the-spot.test.ts index 802c33710..0da94f1a4 100644 --- a/packages/opencode/test/session/rebuild-on-the-spot.test.ts +++ b/packages/opencode/test/session/rebuild-on-the-spot.test.ts @@ -157,12 +157,36 @@ function writerThatWritesCheckpoint(marker: string): SpawnImpl { } as SpawnImpl } -function mimocodeConfig(baseURL: string) { +/** + * Wrap a spawn impl so a test can assert whether it was ever ASKED to spawn. + * + * This is the difference between "the rebuild eventually gave up and compacted" + * and "the rebuild never tried": both end at the same compaction, so counting + * boundaries cannot tell them apart. A working writer that is never invoked can. + */ +function countingSpawn(impl: SpawnImpl) { + let calls = 0 + return { + impl: { + ...impl, + spawn: (input: Parameters[0]) => { + calls += 1 + return impl.spawn(input) + }, + } as SpawnImpl, + get calls() { + return calls + }, + } +} + +function mimocodeConfig(baseURL: string, extra?: Record) { return JSON.stringify({ $schema: "https://opencode.ai/config.json", enabled_providers: ["alibaba"], provider: { alibaba: { options: { apiKey: "test-key", baseURL: `${baseURL}/v1` } } }, agent: { build: { model: "alibaba/qwen-plus" } }, + ...extra, }) } @@ -600,4 +624,172 @@ describe("Manual /rebuild: on-the-spot rebuild driven through SessionPrompt.comm }, { timeout: 30_000 }, ) + + // `memory.disable_write: true` means no checkpoint can ever be written for the + // session, so every rebuild degrades to compaction for the whole life of that + // session. That is the switch doing its job, but it used to leave the user with + // nothing: the sole trace was a log line, and the one message that WAS surfaced + // blamed a failed checkpoint writer, which reads like a bug to report. + // + // A fully working writer stub is installed on purpose here, and the test + // asserts the switch is checked UP FRONT rather than discovered at the end of a + // doomed detour (read the checkpoint file, probe hasCheckpoint + lastBoundary, + // start a writer, wait for it). Both the detour and the guard end at the same + // compaction, so a compaction count cannot tell them apart; the writer-wait + // announcement can, and is what the mutation check confirms. See the numbered + // comments at the assertions for exactly what each probe does and does not + // prove. + test( + "memory writing disabled → compacts IMMEDIATELY without ever asking for a writer, and names the switch once per session", + async () => { + const llm = startLLM("should-not-be-used-as-a-reply") + const writer = countingSpawn(writerThatWritesCheckpoint("SHOULD_NEVER_BE_WRITTEN")) + const seen: Array = [] + const onEvent = (e: { + payload?: { type?: string; properties?: { status?: { type?: string; message?: string } } } + }) => { + if (e?.payload?.type === "session.status" && e.payload.properties?.status?.type === "busy") { + seen.push(e.payload.properties.status.message) + } + } + GlobalBus.on("event", onEvent) + try { + await using tmp = await tmpdir({ + git: true, + init: (dir) => + Bun.write( + path.join(dir, "mimocode.json"), + mimocodeConfig(llm.origin, { memory: { disable_write: true } }), + ), + }) + + await withSpawnRef(writer.impl, () => + Instance.provide({ + directory: tmp.path, + fn: () => + run( + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const info = yield* sessions.create({ title: "rebuild-memory-write-off" }) + yield* Effect.promise(() => seedUserMessage(info.id, "turn one with memory writing off")) + + yield* prompt.command({ + sessionID: info.id, + command: Command.Default.REBUILD, + arguments: "", + agent: "build", + }) + + const after = yield* sessions.messages({ sessionID: info.id }) + + // Degraded exactly as documented: compacted, never rebuilt. + expect(after.filter((m) => m.parts.some((p) => p.type === "compaction")).length).toBe(1) + expect(after.filter((m) => m.parts.some((p) => p.type === "checkpoint")).length).toBe(0) + + // ── THE "IMMEDIATELY" ASSERTIONS ──────────────────────────── + // Both the old detour and the guard end at the same + // compaction, so the counts above cannot tell them apart. What + // can: the detour's own side effects. + // + // 1. The writer-wait announcement. `onWaitingForWriter` runs + // inside rebuildEnsuringCheckpoint immediately before + // waitForWriter, so this message appearing means we entered + // the start-and-wait stage. Verified discriminating: with + // the guard's early return neutralized, this assertion is + // the one that fails. It is also a correctness requirement + // in its own right — announcing a wait for a writer we will + // never start would be a lie. "Rebuilding context…" IS still + // expected: the user did ask for a rebuild. + expect(seen).toContain("Rebuilding context\u2026") + expect(seen).not.toContain("Writing checkpoint\u2026") + // 2. A writer that would have SUCCEEDED was available the whole + // time and produced nothing — so the switch, not a + // missing/broken writer, is what blocked the checkpoint. + // Deliberately NOT offered as proof of "never tried": the + // memory gate inside tryStartCheckpointWriter + // (checkpoint.ts:608) returns "skipped" before the spawn + // seam, so this count stays 0 on the old detour too. The + // call-was-never-made evidence is the absent + // "memory writing disabled, skipping checkpoint" log line, + // which only tryStartCheckpointWriter can emit. + expect(writer.calls).toBe(0) + // 3. Nothing was written to disk for this session either. + expect(yield* Effect.promise(() => Bun.file(checkpointPath(info.id)).exists())).toBe(false) + + // The notice is PERSISTED, so it outlives the busy→idle status + // flash (which never reaches a headless event stream at all). + const notices = after.flatMap((m) => + m.parts.filter((p) => p.type === "text" && p.text.includes("Memory writing is off")), + ) + expect(notices.length).toBe(1) + const notice = notices[0]! + if (notice.type !== "text") throw new Error("expected a text part") + // Names both facts — the switch, and that compaction stood in for + // the rebuild — plus the consequence the user cares about. + expect(notice.text).toContain("Memory writing is off") + expect(notice.text).toContain("compacted instead of rebuilt") + expect(notice.text).toContain("weaken continuity") + // Reassures rather than alarms, and says how to undo it. + expect(notice.text).toContain("Nothing is broken") + expect(notice.text).toContain("memory.disable_write") + // Engine-side text is single-language English; the consuming + // client owns localization, as for `compactedInsteadMsg` / + // `rebuildFailedMsg`. + expect(notice.text).not.toMatch(/[\u4e00-\u9fff]/) + // Display-only: `ignored` keeps it out of the model's context, + // so a notice addressed to the user can never be read back as + // an instruction the user gave. + expect(notice.ignored).toBe(true) + expect(notice.synthetic).toBe(true) + // `time.end` is what makes the CLI emit it on --format json. + expect(notice.time?.end).toBeNumber() + + // Same wording on the existing status channel, and the + // misleading "writer failed" text is NOT used here. + expect(seen.some((m) => m?.includes("Memory writing is off"))).toBe(true) + expect(seen.some((m) => m?.includes("the checkpoint writer failed"))).toBe(false) + + // No assistant reply, as on every other /rebuild path. + expect( + after.some((m) => + m.parts.some((p) => p.type === "text" && p.text.includes("should-not-be-used-as-a-reply")), + ), + ).toBe(false) + + // Second fallback in the same session: it degrades again (a + // second compaction boundary), but the notice describes a + // config state, not an event, so it is NOT stacked a second + // time in the transcript. The status line still names the + // switch, because that line is transient. + seen.length = 0 + yield* prompt.command({ + sessionID: info.id, + command: Command.Default.REBUILD, + arguments: "", + agent: "build", + }) + const again = yield* sessions.messages({ sessionID: info.id }) + expect(again.filter((m) => m.parts.some((p) => p.type === "compaction")).length).toBe(2) + expect( + again.flatMap((m) => + m.parts.filter((p) => p.type === "text" && p.text.includes("Memory writing is off")), + ).length, + ).toBe(1) + expect(seen.some((m) => m?.includes("Memory writing is off"))).toBe(true) + // Still no writer, on the second fallback either: the guard is + // not a once-per-session memo, it re-decides every time. + expect(writer.calls).toBe(0) + expect(seen).not.toContain("Writing checkpoint\u2026") + }), + ), + }), + ) + } finally { + GlobalBus.off("event", onEvent) + await llm.stop() + } + }, + { timeout: 30_000 }, + ) }) diff --git a/packages/opencode/test/tool/memory-path-guard.test.ts b/packages/opencode/test/tool/memory-path-guard.test.ts index 1f38e8499..04d60b1e7 100644 --- a/packages/opencode/test/tool/memory-path-guard.test.ts +++ b/packages/opencode/test/tool/memory-path-guard.test.ts @@ -730,3 +730,110 @@ describe("assertAgentWriteSandbox", () => { }) } }) + +describe("assertMemoryWriteAllowed — memory write switch", () => { + const CANONICAL = [ + ["project MEMORY.md", path.join(MEMORY_ROOT, "projects", "p_test", "MEMORY.md")], + ["session checkpoint.md", path.join(MEMORY_ROOT, "sessions", "sid", "checkpoint.md")], + ["session notes.md", path.join(MEMORY_ROOT, "sessions", "sid", "notes.md")], + ["task progress.md", path.join(MEMORY_ROOT, "sessions", "sid", "tasks", "T1", "progress.md")], + ] as const + + for (const [label, target] of CANONICAL) { + test(`writeEnabled: false refuses ${label} for the checkpoint-writer`, () => { + expect(() => + assertMemoryWriteAllowed({ + target, + agentName: "checkpoint-writer", + memoryRoot: MEMORY_ROOT, + projectID: PROJECT_ID, + sessionID: SESSION_ID, + writeEnabled: false, + }), + ).toThrow(/Memory WRITING is disabled/) + }) + + test(`writeEnabled: true still allows ${label} for the checkpoint-writer`, () => { + expect(() => + assertMemoryWriteAllowed({ + target, + agentName: "checkpoint-writer", + memoryRoot: MEMORY_ROOT, + projectID: PROJECT_ID, + sessionID: SESSION_ID, + writeEnabled: true, + }), + ).not.toThrow() + }) + } + + test("writeEnabled: false refuses the main agent's MEMORY.md edit", () => { + expect(() => + assertMemoryWriteAllowed({ + target: path.join(MEMORY_ROOT, "projects", "p_test", "MEMORY.md"), + agentName: "build", + memoryRoot: MEMORY_ROOT, + projectID: PROJECT_ID, + sessionID: SESSION_ID, + writeEnabled: false, + }), + ).toThrow(/Memory WRITING is disabled/) + }) + + test("writeEnabled: false refuses a task-bound subagent's own progress.md", () => { + expect(() => + assertMemoryWriteAllowed({ + target: path.join(MEMORY_ROOT, "sessions", "sid", "tasks", "T1", "progress.md"), + agentName: "general", + memoryRoot: MEMORY_ROOT, + projectID: PROJECT_ID, + sessionID: SESSION_ID, + taskId: "T1", + writeEnabled: false, + }), + ).toThrow(/Memory WRITING is disabled/) + }) + + test("writeEnabled: false leaves non-memory paths untouched", () => { + expect(() => + assertMemoryWriteAllowed({ + target: "/some/cwd/foo.txt", + agentName: "build", + memoryRoot: MEMORY_ROOT, + projectID: PROJECT_ID, + sessionID: SESSION_ID, + writeEnabled: false, + }), + ).not.toThrow() + }) + + test("omitted writeEnabled defaults to ON (backward compatible)", () => { + expect(() => + assertMemoryWriteAllowed({ + target: path.join(MEMORY_ROOT, "sessions", "sid", "checkpoint.md"), + agentName: "checkpoint-writer", + memoryRoot: MEMORY_ROOT, + projectID: PROJECT_ID, + sessionID: SESSION_ID, + }), + ).not.toThrow() + }) + + test("refusal names the config key and forbids retrying another memory path", () => { + let message = "" + try { + assertMemoryWriteAllowed({ + target: path.join(MEMORY_ROOT, "sessions", "sid", "notes.md"), + agentName: "build", + memoryRoot: MEMORY_ROOT, + projectID: PROJECT_ID, + sessionID: SESSION_ID, + writeEnabled: false, + }) + } catch (err) { + message = (err as Error).message + } + expect(message).toContain("memory.disable_write") + expect(message).toContain("Do NOT retry with another memory path") + }) +}) diff --git a/packages/opencode/test/tool/memory-write-gate.test.ts b/packages/opencode/test/tool/memory-write-gate.test.ts new file mode 100644 index 000000000..2daac3729 --- /dev/null +++ b/packages/opencode/test/tool/memory-write-gate.test.ts @@ -0,0 +1,111 @@ +import { describe, expect } from "bun:test" +import path from "path" +import { Cause, Effect, Exit, Layer } from "effect" +import type { Tool } from "../../src/tool" +import { assertWriteAllowed } from "../../src/tool/external-directory" +import { Config } from "../../src/config" +import { Global } from "../../src/global" +import { SessionID, MessageID } from "../../src/session/schema" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { Log } from "../../src/util" + +void Log.init({ print: false }) + +const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, Config.defaultLayer)) + +const ctx: Tool.Context = { + sessionID: SessionID.make("ses_write_gate"), + messageID: MessageID.make(""), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +// Global.Path.data is redirected to a per-run temp dir by the test preload, so +// these targets never point at the real user memory tree. Nothing here writes or +// deletes — the gate is asserted before any filesystem touch. +const memoryTarget = (...parts: string[]) => path.join(Global.Path.data, "memory", ...parts) + +const failureMessage = (exit: Exit.Exit) => + Exit.isFailure(exit) ? String((Cause.squash(exit.cause) as Error).message) : "" + +describe("assertWriteAllowed × memory write switch (W5)", () => { + it.live( + "disable_write: true → memory write is refused with an explicit 'disabled' message", + provideTmpdirInstance( + () => + Effect.gen(function* () { + const exit = yield* Effect.exit(assertWriteAllowed(ctx, memoryTarget("projects", "global", "MEMORY.md"))) + + expect(Exit.isFailure(exit)).toBe(true) + const message = failureMessage(exit) + expect(message).toContain("Memory WRITING is disabled") + expect(message).toContain("memory.disable_write") + // Single-language English, like every other message this gate throws: + // the engine has no locale to consult, and the consuming client that + // surfaces this carries its own translations. + expect(message).not.toMatch(/[\u4e00-\u9fff]/) + // Must not read as a path/permission problem, or the model retries elsewhere. + expect(message).toContain("Do NOT retry with another memory path") + // Must not claim memory as a whole is off — reads still work. + expect(message).toContain("Reading is unaffected") + }), + { outsideGit: true, config: { memory: { disable_write: true } } }, + ), + ) + + it.live( + "disable_write: true → notes.md is refused too (not just canonical writer paths)", + provideTmpdirInstance( + () => + Effect.gen(function* () { + const exit = yield* Effect.exit( + assertWriteAllowed(ctx, memoryTarget("sessions", "ses_write_gate", "notes.md")), + ) + expect(failureMessage(exit)).toContain("Memory WRITING is disabled") + }), + { outsideGit: true, config: { memory: { disable_write: true } } }, + ), + ) + + it.live( + "disable_write: true → writes OUTSIDE the memory tree are unaffected", + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const exit = yield* Effect.exit(assertWriteAllowed(ctx, path.join(dir, "src", "app.ts"))) + expect(Exit.isSuccess(exit)).toBe(true) + }), + { outsideGit: true, config: { memory: { disable_write: true } } }, + ), + ) + + it.live( + "absent config → memory write still allowed (backward compatible default)", + provideTmpdirInstance( + () => + Effect.gen(function* () { + const exit = yield* Effect.exit(assertWriteAllowed(ctx, memoryTarget("projects", "global", "MEMORY.md"))) + expect(Exit.isSuccess(exit)).toBe(true) + }), + { outsideGit: true }, + ), + ) + + it.live( + "disable_write: false → memory write still allowed", + provideTmpdirInstance( + () => + Effect.gen(function* () { + const exit = yield* Effect.exit(assertWriteAllowed(ctx, memoryTarget("projects", "global", "MEMORY.md"))) + expect(Exit.isSuccess(exit)).toBe(true) + }), + { outsideGit: true, config: { memory: { disable_write: false } } }, + ), + ) +}) From e7c691ab0d0473d8c156a75e72dfe63061340450 Mon Sep 17 00:00:00 2001 From: YOMXXX <15901434509@qq.com> Date: Fri, 7 Aug 2026 21:07:58 +0800 Subject: [PATCH 134/135] fix(mcp): patch @ai-sdk/openai-compatible to stop dropping multi-arg tool calls (#2054) * fix(mcp): use last valid JSON snapshot for tool args in openai-compatible patch * test(mcp): regression test for openai-compatible multi-arg stream patch --- bun.lock | 1 + package.json | 3 +- .../mcp/openai-compatible-args-patch.test.ts | 158 ++++++++++++++++++ .../@ai-sdk%2Fopenai-compatible@2.0.41.patch | 150 +++++++++++++++++ 4 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/mcp/openai-compatible-args-patch.test.ts create mode 100644 patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch diff --git a/bun.lock b/bun.lock index 8916a2f47..82c3c0512 100644 --- a/bun.lock +++ b/bun.lock @@ -636,6 +636,7 @@ "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@opentui/core@0.1.101": "patches/@opentui%2Fcore@0.1.101.patch", + "@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch", }, "overrides": { "@types/bun": "catalog:", diff --git a/package.json b/package.json index 8d01a8d4b..38a7572a9 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,7 @@ "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", - "@opentui/core@0.1.101": "patches/@opentui%2Fcore@0.1.101.patch" + "@opentui/core@0.1.101": "patches/@opentui%2Fcore@0.1.101.patch", + "@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch" } } diff --git a/packages/opencode/test/mcp/openai-compatible-args-patch.test.ts b/packages/opencode/test/mcp/openai-compatible-args-patch.test.ts new file mode 100644 index 000000000..13ece2a42 --- /dev/null +++ b/packages/opencode/test/mcp/openai-compatible-args-patch.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test" +import { createOpenAICompatible } from "@ai-sdk/openai-compatible" +import { streamText, tool } from "ai" +import z from "zod" + +// Independent reproduction of the `@ai-sdk/openai-compatible@2.0.41` patch +// (`patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch`, branch +// fix/mcp-tool-args-stream). Exercises the REAL patched stream parser +// end-to-end: mock SSE → provider `doStream` → `ai` core tool execution. +// +// Defect being fixed: a provider that emits OVERLAPPING complete-JSON argument +// snapshots (OpenRouter buffered mode) triggered the parser's premature +// finalize on the FIRST parseable snapshot (`{}`), so the tool `execute` +// received an empty object. The patch tracks `lastValidDelta` = the LAST +// complete snapshot and falls back to it at flush when the concatenated +// accumulator is not parseable. + +interface Captured { + name: string + input: unknown +} + +function chunk(delta: Record, finishReason?: string): string { + const payload = { + id: "chatcmpl-stub", + object: "chat.completion.chunk", + choices: [{ delta, ...(finishReason ? { finish_reason: finishReason } : {}) }], + } + return `data: ${JSON.stringify(payload)}\n\n` +} + +function sseResponse(lines: string[]) { + const body = new ReadableStream({ + start(controller) { + for (const line of lines) controller.enqueue(new TextEncoder().encode(line)) + controller.close() + }, + }) + return new Response(body, { headers: { "content-type": "text/event-stream" } }) +} + +const FULL_ARGS = JSON.stringify({ + p0: "a", + p1: "b", + p2: "c", + p3: "d", + p4: "e", + p5: "f", +}) + +function overlappingSnapshotsLines(): string[] { + const snapshots = [ + "{}", + JSON.stringify({ p0: "a" }), + JSON.stringify({ p0: "a", p1: "b" }), + JSON.stringify({ p0: "a", p1: "b", p2: "c" }), + JSON.stringify({ p0: "a", p1: "b", p2: "c", p3: "d" }), + JSON.stringify({ p0: "a", p1: "b", p2: "c", p3: "d", p4: "e" }), + FULL_ARGS, + ] + return [ + chunk({ role: "assistant" }), + chunk({ + tool_calls: [ + { index: 0, id: "call_1", type: "function", function: { name: "calc", arguments: snapshots[0] } }, + ], + }), + ...snapshots.slice(1).map((args) => + chunk({ tool_calls: [{ index: 0, function: { arguments: args } }] }), + ), + chunk({}, "stop"), + "data: [DONE]\n\n", + ] +} + +function incrementalPrefixLines(): string[] { + return [ + chunk({ role: "assistant" }), + chunk({ + tool_calls: [ + { index: 0, id: "call_1", type: "function", function: { name: "calc", arguments: '{"p0":"a"' } }, + ], + }), + chunk({ tool_calls: [{ index: 0, function: { arguments: ',"p1":"b"' } }] }), + chunk({ tool_calls: [{ index: 0, function: { arguments: ',"p2":"c"' } }] }), + chunk({ tool_calls: [{ index: 0, function: { arguments: ',"p3":"d"' } }] }), + chunk({ tool_calls: [{ index: 0, function: { arguments: ',"p4":"e"' } }] }), + chunk({ tool_calls: [{ index: 0, function: { arguments: ',"p5":"f"}' } }] }), + chunk({}, "stop"), + "data: [DONE]\n\n", + ] +} + +async function run(lines: string[]): Promise { + const captured: Captured = { name: "", input: null } + const provider = createOpenAICompatible({ + baseURL: "http://mock/v1", + name: "mock", + apiKey: "test-key", + fetch: (async () => sseResponse(lines)) as any, + }) + const model = provider.languageModel("mock-model") + + const result = streamText({ + model, + prompt: "run calc", + toolChoice: "required", + tools: { + calc: tool({ + description: "Calc", + inputSchema: z.object({ + p0: z.string(), + p1: z.string(), + p2: z.string(), + p3: z.string(), + p4: z.string(), + p5: z.string(), + }), + execute: async (input) => { + captured.name = "calc" + captured.input = input + return "ok" + }, + }), + }, + }) + + // Consume the full stream so the tool loop runs to completion. + await result.toolResults + expect(captured.name).toBe("calc") + return captured +} + +describe("openai-compatible patch: lastValidDelta", () => { + test("OVERLAPPING complete-JSON snapshots → execute receives ALL 6 args", async () => { + const captured = await run(overlappingSnapshotsLines()) + expect(captured.input).toEqual({ + p0: "a", + p1: "b", + p2: "c", + p3: "d", + p4: "e", + p5: "f", + }) + }) + + test("incremental-prefix streaming still works → execute receives ALL 6 args", async () => { + const captured = await run(incrementalPrefixLines()) + expect(captured.input).toEqual({ + p0: "a", + p1: "b", + p2: "c", + p3: "d", + p4: "e", + p5: "f", + }) + }) +}) diff --git a/patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch b/patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch new file mode 100644 index 000000000..926230462 --- /dev/null +++ b/patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch @@ -0,0 +1,150 @@ +diff --git a/dist/index.js b/dist/index.js +index dca128d3a790378c51a24a16d92585178343b278..84f21b064e487c62377241fb0737321cd0968e76 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -791,6 +791,7 @@ + arguments: (_e = toolCallDelta.function.arguments) != null ? _e : "" + }, + hasFinished: false, ++ lastValidDelta: undefined, + thoughtSignature: (_h = (_g = (_f = toolCallDelta.extra_content) == null ? void 0 : _f.google) == null ? void 0 : _g.thought_signature) != null ? _h : void 0 + }; + const toolCall2 = toolCalls[index]; +@@ -803,24 +804,7 @@ + }); + } + if ((0, import_provider_utils2.isParsableJson)(toolCall2.function.arguments)) { +- controller.enqueue({ +- type: "tool-input-end", +- id: toolCall2.id +- }); +- controller.enqueue({ +- type: "tool-call", +- toolCallId: (_k = toolCall2.id) != null ? _k : (0, import_provider_utils2.generateId)(), +- toolName: toolCall2.function.name, +- input: toolCall2.function.arguments, +- ...toolCall2.thoughtSignature ? { +- providerMetadata: { +- [providerOptionsName]: { +- thoughtSignature: toolCall2.thoughtSignature +- } +- } +- } : {} +- }); +- toolCall2.hasFinished = true; ++ toolCall2.lastValidDelta = toolCall2.function.arguments; + } + } + continue; +@@ -837,25 +821,8 @@ + id: toolCall.id, + delta: (_o = toolCallDelta.function.arguments) != null ? _o : "" + }); +- if (((_p = toolCall.function) == null ? void 0 : _p.name) != null && ((_q = toolCall.function) == null ? void 0 : _q.arguments) != null && (0, import_provider_utils2.isParsableJson)(toolCall.function.arguments)) { +- controller.enqueue({ +- type: "tool-input-end", +- id: toolCall.id +- }); +- controller.enqueue({ +- type: "tool-call", +- toolCallId: (_r = toolCall.id) != null ? _r : (0, import_provider_utils2.generateId)(), +- toolName: toolCall.function.name, +- input: toolCall.function.arguments, +- ...toolCall.thoughtSignature ? { +- providerMetadata: { +- [providerOptionsName]: { +- thoughtSignature: toolCall.thoughtSignature +- } +- } +- } : {} +- }); +- toolCall.hasFinished = true; ++ if (((_p = toolCallDelta.function) == null ? void 0 : _p.arguments) != null && (0, import_provider_utils2.isParsableJson)(toolCallDelta.function.arguments)) { ++ toolCall.lastValidDelta = toolCallDelta.function.arguments; + } + } + } +@@ -879,7 +846,7 @@ + type: "tool-call", + toolCallId: (_a2 = toolCall.id) != null ? _a2 : (0, import_provider_utils2.generateId)(), + toolName: toolCall.function.name, +- input: toolCall.function.arguments, ++ input: (0, import_provider_utils2.isParsableJson)(toolCall.function.arguments) ? toolCall.function.arguments : (toolCall.lastValidDelta != null ? toolCall.lastValidDelta : toolCall.function.arguments), + ...toolCall.thoughtSignature ? { + providerMetadata: { + [providerOptionsName]: { +diff --git a/dist/index.mjs b/dist/index.mjs +index 3b1e1b6bdec5032e3b4fa5ffbcc8cdf3dfe1cc40..6a926f818251fd1f046e3aa816842d9517332ca9 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -778,6 +778,7 @@ + arguments: (_e = toolCallDelta.function.arguments) != null ? _e : "" + }, + hasFinished: false, ++ lastValidDelta: undefined, + thoughtSignature: (_h = (_g = (_f = toolCallDelta.extra_content) == null ? void 0 : _f.google) == null ? void 0 : _g.thought_signature) != null ? _h : void 0 + }; + const toolCall2 = toolCalls[index]; +@@ -790,24 +791,7 @@ + }); + } + if (isParsableJson(toolCall2.function.arguments)) { +- controller.enqueue({ +- type: "tool-input-end", +- id: toolCall2.id +- }); +- controller.enqueue({ +- type: "tool-call", +- toolCallId: (_k = toolCall2.id) != null ? _k : generateId(), +- toolName: toolCall2.function.name, +- input: toolCall2.function.arguments, +- ...toolCall2.thoughtSignature ? { +- providerMetadata: { +- [providerOptionsName]: { +- thoughtSignature: toolCall2.thoughtSignature +- } +- } +- } : {} +- }); +- toolCall2.hasFinished = true; ++ toolCall2.lastValidDelta = toolCall2.function.arguments; + } + } + continue; +@@ -824,25 +808,8 @@ + id: toolCall.id, + delta: (_o = toolCallDelta.function.arguments) != null ? _o : "" + }); +- if (((_p = toolCall.function) == null ? void 0 : _p.name) != null && ((_q = toolCall.function) == null ? void 0 : _q.arguments) != null && isParsableJson(toolCall.function.arguments)) { +- controller.enqueue({ +- type: "tool-input-end", +- id: toolCall.id +- }); +- controller.enqueue({ +- type: "tool-call", +- toolCallId: (_r = toolCall.id) != null ? _r : generateId(), +- toolName: toolCall.function.name, +- input: toolCall.function.arguments, +- ...toolCall.thoughtSignature ? { +- providerMetadata: { +- [providerOptionsName]: { +- thoughtSignature: toolCall.thoughtSignature +- } +- } +- } : {} +- }); +- toolCall.hasFinished = true; ++ if (((_p = toolCallDelta.function) == null ? void 0 : _p.arguments) != null && isParsableJson(toolCallDelta.function.arguments)) { ++ toolCall.lastValidDelta = toolCallDelta.function.arguments; + } + } + } +@@ -866,7 +833,7 @@ + type: "tool-call", + toolCallId: (_a2 = toolCall.id) != null ? _a2 : generateId(), + toolName: toolCall.function.name, +- input: toolCall.function.arguments, ++ input: isParsableJson(toolCall.function.arguments) ? toolCall.function.arguments : (toolCall.lastValidDelta != null ? toolCall.lastValidDelta : toolCall.function.arguments), + ...toolCall.thoughtSignature ? { + providerMetadata: { + [providerOptionsName]: { From 5ba78a272e99b8ccc7ac5666d97e709416baf360 Mon Sep 17 00:00:00 2001 From: fanhuanjie Date: Fri, 7 Aug 2026 23:29:16 +0800 Subject: [PATCH 135/135] fix(session): scope diffs to requested message - compute message-specific diffs from the user message and direct assistant reply\n- preserve cached summaries while normalizing git paths --- packages/opencode/src/plugin/index.ts | 2 +- packages/opencode/src/session/summary.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 9a4713633..b792bc874 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -314,7 +314,7 @@ export const layer = Layer.effect( ([exportName, v]) => typeof v === "function" && exportName.endsWith("Plugin"), )?.[1] as PluginInstance | undefined if (!overlay) continue - log.info("loading extension", { name }) + // log.info("loading extension", { name }) const init = yield* Effect.tryPromise({ try: () => overlay(input), catch: (err) => log.error("failed to load extension", { name, error: err }), diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index a3fbc6502..1fe5e73af 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -129,6 +129,21 @@ export const layer = Layer.effect( }) const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { + if (input.messageID) { + const all = yield* sessions.messages({ sessionID: input.sessionID, agentID: "*" }) + const target = all.find((item) => item.info.id === input.messageID) + if (!target || target.info.role !== "user") return [] + const diffs = + target.info.summary?.diffs ?? + (yield* computeDiff({ + messages: all.filter( + (item) => + item.info.id === input.messageID || + (item.info.role === "assistant" && item.info.parentID === input.messageID), + ), + })) + return diffs.map((item) => ({ ...item, file: unquoteGitPath(item.file) })) + } const diffs = yield* storage .read(["session_diff", input.sessionID]) .pipe(Effect.catch(() => Effect.succeed([] as Snapshot.FileDiff[])))