From 8b4e91a9c6f2e3cff061d64b8c53e388accdae45 Mon Sep 17 00:00:00 2001 From: astrid Date: Sat, 19 Sep 2026 20:54:55 -0400 Subject: [PATCH 01/13] feat(opencode2): register the ctx_* tools on the v2 plugin lane The package default-exports the union { id, server, setup }. A v1 host calls server(); OpenCode 2 calls setup() only, and OpenCode 2 has no v1 plugin compat lane -- none of the v1 hook identifiers exist in the 2.0.10 binary. createToolRegistry() was called only from server(), so under OpenCode 2 none of ctx_reduce / ctx_search / ctx_memory / ctx_note / ctx_expand were ever registered and the agent had no Magic Context tools. registerContext() now builds the registry and adds every tool through context.tool.transform(...) with options.codemode:false, so they surface as direct tools exactly as the v1 lane exposed them: - the zod arg shape is rendered to the JSON Schema the v2 tool domain expects, with a permissive fallback if a shape is unrenderable so a schema failure can never abort plugin setup; - the v1 ToolResult is bridged to the v2 Result shape (content/metadata); - the v2 execute context carries no `directory`, so it is closed over from context.location.directory (the only field the ctx_* tools read besides sessionID and agent). The registration is wrapped in try/catch: a tools failure degrades to the previous behaviour (context management still works) instead of breaking the lane. Verified on OpenCode 2.0.10: a fresh session lists the five tools and successfully calls ctx_search. --- packages/plugin/src/v2/hooks/context.ts | 71 +++++++++++++++++++++++++ packages/plugin/src/v2/hooks/types.ts | 15 ++++++ 2 files changed, 86 insertions(+) diff --git a/packages/plugin/src/v2/hooks/context.ts b/packages/plugin/src/v2/hooks/context.ts index 453456a1f..3d30d00b4 100644 --- a/packages/plugin/src/v2/hooks/context.ts +++ b/packages/plugin/src/v2/hooks/context.ts @@ -1,3 +1,4 @@ +import { tool, type ToolDefinition, type ToolResult } from "@opencode-ai/plugin"; import { loadPluginConfigDetailed } from "../../config"; import { isCompactionEnabled } from "../../config/agent-disable"; import { getProtectedTokensTierOverrides } from "../../config/project-security"; @@ -21,6 +22,8 @@ import { setRawMessageProvider } from "../../hooks/magic-context/read-session-ch import { preloadTokenizer } from "../../hooks/magic-context/read-session-formatting"; import { createTransform, type TransformDeps } from "../../hooks/magic-context/transform"; import { maybeSendUpgradeReminder } from "../../hooks/magic-context/upgrade-reminder"; +import { createToolRegistry } from "../../plugin/tool-registry"; +import type { PluginContext } from "../../plugin/types"; import { detectConflicts } from "../../shared/conflict-detector"; import { getDataDir } from "../../shared/data-path"; import { resolveHistorianModel } from "../../shared/model-resolution"; @@ -116,6 +119,33 @@ export function catalogModels(listed: unknown): Array<{ }); } +/** Build the JSON Schema OpenCode 2 expects for a tool's `input` from its zod arg shape. */ +function toolArgsJsonSchema(args: ToolDefinition["args"]): Record { + try { + const objectSchema = tool.schema.object(args); + const { $schema: _schema, ...rest } = tool.schema.toJSONSchema(objectSchema) as Record< + string, + unknown + >; + return rest; + } catch { + // A shape zod cannot render as JSON Schema must not take down plugin setup. + return { type: "object", properties: {}, additionalProperties: true }; + } +} + +/** Bridge a v1 `ToolResult` to the v2 `Tool.Result` shape (content/metadata). */ +function toV2ToolResult(result: ToolResult): { + content?: string; + metadata?: Record; +} { + if (typeof result === "string") return { content: result }; + return { + content: result.output ?? "", + ...(result.metadata ? { metadata: result.metadata } : {}), + }; +} + /** Rewrite Magic Context ctx_* tool descriptions for this draft's model. */ export function applyV2PromptSurfaceTools( draft: SessionContext, @@ -221,6 +251,47 @@ export async function registerContext(context: V2Context) { console.warn("[magic-context] v2 Channel 2 delivery deferred", error); } }); + // OpenCode 2 has no v1 plugin lane, so the v1 server() path that built + // createToolRegistry never runs and the ctx_* tools are otherwise absent. + // Register them on the v2 tool domain here. They are added with + // codemode:false so they surface as direct tools, matching how the v1 lane + // exposed them. + const registry = createToolRegistry({ + ctx: { directory } as PluginContext, + pluginConfig: config, + promptSurfaceRuntime, + registrationPromptSurface: config.prompt_surface, + }); + const registryEntries = Object.entries(registry); + if (registryEntries.length > 0 && context.tool.transform) { + try { + await context.tool.transform((editor) => { + for (const [name, definition] of registryEntries) { + editor.add({ + name, + description: definition.description, + input: toolArgsJsonSchema(definition.args), + options: { codemode: false }, + execute: async (input, toolContext) => { + const result = await definition.execute(input as never, { + sessionID: toolContext.sessionID, + messageID: toolContext.messageID, + agent: toolContext.agent, + directory, + worktree: directory, + abort: new AbortController().signal, + metadata: () => {}, + ask: async () => {}, + }); + return toV2ToolResult(result); + }, + }); + } + }); + } catch (error) { + console.warn("[magic-context] v2 ctx_* tool registration skipped", error); + } + } const read = (sessionID: string) => { const reader = new V2StoreReader( gaDatabasePath(getDataDir(), process.env.OPENCODE_CHANNEL ?? "latest"), diff --git a/packages/plugin/src/v2/hooks/types.ts b/packages/plugin/src/v2/hooks/types.ts index 13bfeec56..ab7bc08f5 100644 --- a/packages/plugin/src/v2/hooks/types.ts +++ b/packages/plugin/src/v2/hooks/types.ts @@ -84,6 +84,21 @@ export interface V2Context { tool: { transform?( callback: (editor: { + add(tool: { + name: string; + description: string; + input: unknown; + options?: { codemode?: boolean }; + execute( + input: unknown, + context: { + sessionID: string; + agent: string; + messageID: string; + id: string; + }, + ): Promise<{ content?: string; metadata?: Record }>; + }): void; update(id: string, update: (tool: { description: string }) => void): void; }) => void, ): Promise; From 753e7d85cac57cf6501a71fa984179122f4918bb Mon Sep 17 00:00:00 2001 From: astrid Date: Sat, 19 Sep 2026 21:05:47 -0400 Subject: [PATCH 02/13] fix(opencode2): start the RPC server on the v2 lane OpenCode 2 never runs the v1 server() lane, so MagicContextRpcServer was never constructed on this host and the port-file discovery directory stayed empty. The v2 TUI is a pure RPC client (no direct SQLite access), so the sidebar and /ctx-status rendered zeros. Construct and start the RPC server from registerContext, reusing the v2 lane's draft-authoritative live maps (model/variant/agent plus the Channel 1 and refresh sets) so the snapshot/status handlers resolve the session's active model. client/rustModeModuleClient stay undefined: only the recomp/upgrade notify paths need them, and those stay inert on this lane. Start is deferred one task (v1-lane parity) and stopped in dispose(). Verified on an isolated `opencode run --standalone` host: port file written under rpc//, /health OK, sidebar-snapshot returns snapshot data, and the port file is removed on dispose. --- packages/plugin/src/v2/hooks/context.ts | 49 ++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/plugin/src/v2/hooks/context.ts b/packages/plugin/src/v2/hooks/context.ts index 3d30d00b4..441808fb5 100644 --- a/packages/plugin/src/v2/hooks/context.ts +++ b/packages/plugin/src/v2/hooks/context.ts @@ -1,4 +1,4 @@ -import { tool, type ToolDefinition, type ToolResult } from "@opencode-ai/plugin"; +import { type ToolDefinition, type ToolResult, tool } from "@opencode-ai/plugin"; import { loadPluginConfigDetailed } from "../../config"; import { isCompactionEnabled } from "../../config/agent-disable"; import { getProtectedTokensTierOverrides } from "../../config/project-security"; @@ -17,15 +17,20 @@ import { createToolExecuteAfterHook, } from "../../hooks/magic-context/hook-handlers"; import { materializeM0 } from "../../hooks/magic-context/inject-compartments"; +import { + createLiveSessionState, + type LiveSessionState, +} from "../../hooks/magic-context/live-session-state"; import { resolveOpenCodeProtectedTailBoundary } from "../../hooks/magic-context/protected-tail-boundary"; import { setRawMessageProvider } from "../../hooks/magic-context/read-session-chunk"; import { preloadTokenizer } from "../../hooks/magic-context/read-session-formatting"; import { createTransform, type TransformDeps } from "../../hooks/magic-context/transform"; import { maybeSendUpgradeReminder } from "../../hooks/magic-context/upgrade-reminder"; +import { registerRpcHandlers } from "../../plugin/rpc-handlers"; import { createToolRegistry } from "../../plugin/tool-registry"; import type { PluginContext } from "../../plugin/types"; import { detectConflicts } from "../../shared/conflict-detector"; -import { getDataDir } from "../../shared/data-path"; +import { getDataDir, getMagicContextStorageDir } from "../../shared/data-path"; import { resolveHistorianModel } from "../../shared/model-resolution"; import type { PromptSurfaceConfig } from "../../shared/prompt-surface"; import { @@ -34,6 +39,7 @@ import { type PromptSurfaceRuntime, } from "../../shared/prompt-surface-runtime"; import { pushNotification } from "../../shared/rpc-notifications"; +import { MagicContextRpcServer } from "../../shared/rpc-server"; import { v2CompactionMarkerStrategy } from "../fold/markers"; import { FoldOwner, foldDigest } from "../fold/owner"; import { restoreRow } from "../fold/restore"; @@ -620,8 +626,47 @@ export async function registerContext(context: V2Context) { console.warn("[magic-context] v2 context unavailable", error); } }); + // OpenCode 2 never runs the v1 server() lane, so the RPC server that the + // terminal TUI's sidebar/status reads depend on would never start: the v2 + // TUI is a pure RPC client (no direct SQLite access), so without a listener + // the sidebar renders zeros. Start the same surface here and hand it the v2 + // lane's draft-authoritative live maps so the snapshot/status handlers + // resolve the session's active model, variant and agent. + const rpcLiveSessionState: LiveSessionState = { + ...createLiveSessionState(), + liveModelBySession: liveModels, + variantBySession: variants, + agentBySession: agents, + channel1StateBySession: channel1, + historyRefreshSessions, + pendingMaterializationSessions, + }; + const storageDir = getMagicContextStorageDir(); + const rpcServer = new MagicContextRpcServer(storageDir, directory); + let rpcStopped = false; + registerRpcHandlers(rpcServer, { + directory, + config, + // The v2 host context exposes no SDK client, so the recomp/upgrade + // notify paths stay inert; the read-only snapshot handlers need none. + client: undefined, + liveSessionState: rpcLiveSessionState, + rustModeModuleClient: undefined, + storageDir, + }); + // start() is async but its Bun.serve + discovery-file prefix is synchronous; + // run it in the next task so those filesystem calls stay outside the host's + // deadline-bound plugin construction, matching the v1 lane. + setTimeout(() => { + if (rpcStopped) return; + void rpcServer + .start() + .catch((error) => console.warn("[magic-context] v2 RPC server failed to start", error)); + }, 0); return { async dispose() { + rpcStopped = true; + rpcServer.stop(); await dreamTrigger?.dispose(); for (const release of rawProviders.values()) release(); rawProviders.clear(); From d552baae2c0c0879b53e6f5382e8fa777716d764 Mon Sep 17 00:00:00 2001 From: astrid Date: Sat, 19 Sep 2026 21:19:42 -0400 Subject: [PATCH 03/13] fix(opencode2): register the /ctx-* commands from the app slot OpenCode 2 runs plugin setup() outside the TUI component tree, so context.keymap.layer() threw "Keymap.Provider is missing" and /ctx-status and /ctx-recomp were never registered. Keep the direct call as the first attempt (hosts that do run setup in-tree), and on that exact error claim the always-mounted "app" slot: its render executes inside the keymap provider, which is where the host's own built-in plugins register their layers. Registration is idempotent across renders, the claim is released on cleanup, and a permanent gap warns once without touching the sidebar. Confirmed live: /ctx-status appears and renders data on OpenCode 2.0.10. --- .../plugin/src/v2/tui/host-contract.test.ts | 61 ++++++++++-- packages/plugin/src/v2/tui/index.ts | 96 +++++++++++++------ packages/plugin/src/v2/tui/types.ts | 23 ++++- 3 files changed, 137 insertions(+), 43 deletions(-) diff --git a/packages/plugin/src/v2/tui/host-contract.test.ts b/packages/plugin/src/v2/tui/host-contract.test.ts index 35a3dd736..a377dc6c5 100644 --- a/packages/plugin/src/v2/tui/host-contract.test.ts +++ b/packages/plugin/src/v2/tui/host-contract.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { resolve } from "node:path"; import { Host } from "@opencode/plugin/host"; import { setupWithJsx } from "./index"; -import type { V2SidebarState, V2TuiContext } from "./types"; +import type { V2SidebarState, V2SlotClaim, V2TuiContext } from "./types"; const temporary: string[] = []; afterEach(() => { @@ -13,7 +13,7 @@ afterEach(() => { }); function v2Context() { - const claims: Array<{ render: (input: { sessionID: string }) => unknown }> = []; + const claims: V2SlotClaim[] = []; const layers: Array[0]>> = []; const cleanups: Array<() => void> = []; const state: V2SidebarState = { snapshots: {} }; @@ -125,8 +125,10 @@ test("GA 2.0.5 resolves ./tui and executes the union setup contract", async () = expect(typeof loaded.default.setup).toBe("function"); const fixture = v2Context(); const cleanup = await setupWithJsx(fixture.context, (type, props) => ({ type, props })); - expect(fixture.claims).toHaveLength(1); - expect(fixture.claims[0]!.render({ sessionID: "ses-v2-tui" })).toEqual({ + expect(fixture.claims.map((claim) => claim.append)).toEqual(["sidebar.content"]); + const sidebarClaim = fixture.claims[0]!; + if (sidebarClaim.append !== "sidebar.content") throw new Error("expected the sidebar claim"); + expect(sidebarClaim.render({ sessionID: "ses-v2-tui" })).toEqual({ type: "text", props: { children: expect.stringContaining("Magic Context") }, }); @@ -137,19 +139,62 @@ test("GA 2.0.5 resolves ./tui and executes the union setup contract", async () = cleanup(); }); -test("GA 2.0.5 records its unbound keymap.layer gap without losing the sidebar", async () => { +test("GA 2.0.5 registers the keymap layer from the app slot when setup runs outside the provider", async () => { const fixture = v2Context(); + let providerAvailable = false; Object.assign(fixture.context.keymap, { - layer: () => { - throw new Error("Keymap.Provider is missing"); + layer: (input: () => unknown) => { + if (!providerAvailable) throw new Error("Keymap.Provider is missing"); + fixture.layers.push(input() as never); }, }); const cleanup = await setupWithJsx(fixture.context, (type, props) => ({ type, props })); - expect(fixture.claims).toHaveLength(1); + expect(fixture.claims.map((claim) => claim.append)).toEqual(["sidebar.content", "app"]); expect(fixture.layers).toHaveLength(0); + + // The app slot render executes inside the component tree, where the provider resolves. + providerAvailable = true; + const appClaim = fixture.claims.find((claim) => claim.append === "app"); + if (appClaim?.append !== "app") throw new Error("expected the app slot claim"); + appClaim.render({}); + expect( + fixture.layers.map((layer) => layer.commands.map((command) => command.slash.name)), + ).toEqual([["ctx-status", "ctx-recomp"]]); + // Repeated renders must not stack duplicate layers. + appClaim.render({}); + expect(fixture.layers).toHaveLength(1); cleanup(); }); +test("GA 2.0.5 keeps the sidebar when the app-slot keymap registration also fails", async () => { + const fixture = v2Context(); + Object.assign(fixture.context.keymap, { + layer: () => { + throw new Error("Keymap.Provider is missing"); + }, + }); + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args.map(String).join(" ")); + }; + try { + const cleanup = await setupWithJsx(fixture.context, (type, props) => ({ type, props })); + expect(fixture.claims.map((claim) => claim.append)).toEqual(["sidebar.content", "app"]); + const appClaim = fixture.claims.find((claim) => claim.append === "app"); + if (appClaim?.append !== "app") throw new Error("expected the app slot claim"); + appClaim.render({}); + appClaim.render({}); + expect(fixture.layers).toHaveLength(0); + expect( + warnings.filter((line) => line.includes("keymap.layer is unavailable")), + ).toHaveLength(1); + cleanup(); + } finally { + console.warn = originalWarn; + } +}); + test("OpenCode 1.18.30 TUI loader projection executes unchanged sidebar registration", async () => { // v1.18.30 packages/opencode/src/plugin/shared.ts:272-304 reads only id, // server and tui, rejects a simultaneous server+tui pair, and returns the diff --git a/packages/plugin/src/v2/tui/index.ts b/packages/plugin/src/v2/tui/index.ts index 0562cbfad..4d5a97ce3 100644 --- a/packages/plugin/src/v2/tui/index.ts +++ b/packages/plugin/src/v2/tui/index.ts @@ -13,7 +13,7 @@ import { startNotificationSocket, stopNotificationSocket, } from "../../tui/data/notification-socket"; -import type { V2SidebarState, V2TuiContext } from "./types"; +import type { V2KeymapLayer, V2SidebarState, V2TuiContext } from "./types"; const SIDEBAR_REFRESH_MS = 1_000; const inflight = new Set(); @@ -170,38 +170,71 @@ export async function setupWithJsx(context: V2TuiContext, jsx: JsxFactory): Prom }, }); - try { - context.keymap.layer(() => ({ - mode: "global", - commands: [ - { - id: "magic-context.status", - title: "Magic Context: Status", - group: "Magic Context", - palette: true, - slash: { name: "ctx-status", arguments: true }, - run: async (input) => { - await showStatus(input?.trim().toLowerCase() === "diagnostics"); - }, + // The keymap layer owns /ctx-status + /ctx-recomp. OpenCode 2 runs plugin + // setup() outside the TUI component tree, where context.keymap.layer() + // throws "Keymap.Provider is missing" (the provider is a Solid context). + // Try the direct call first (hosts that do run setup in-tree), then fall + // back to the app slot: its render executes inside the component tree, the + // same place the host's own built-in plugins register their layers. + const buildKeymapLayer = (): V2KeymapLayer => ({ + mode: "global", + commands: [ + { + id: "magic-context.status", + title: "Magic Context: Status", + group: "Magic Context", + palette: true, + slash: { name: "ctx-status", arguments: true }, + run: async (input) => { + await showStatus(input?.trim().toLowerCase() === "diagnostics"); }, - { - id: "magic-context.recomp", - title: "Magic Context: Recomp", - group: "Magic Context", - palette: true, - slash: { name: "ctx-recomp" }, - run: async () => { - await showRecomp(); - }, + }, + { + id: "magic-context.recomp", + title: "Magic Context: Recomp", + group: "Magic Context", + palette: true, + slash: { name: "ctx-recomp" }, + run: async () => { + await showRecomp(); }, - ], - })); - } catch (error) { - if (!(error instanceof Error) || error.message !== "Keymap.Provider is missing") - throw error; - console.warn( - "[magic-context] OpenCode 2.0.5 keymap.layer is unavailable during plugin setup; /ctx-status and /ctx-recomp were not registered", - ); + }, + ], + }); + let keymapLayerRegistered = false; + let keymapGapLogged = false; + const registerKeymapLayer = (): boolean => { + if (keymapLayerRegistered) return true; + try { + context.keymap.layer(buildKeymapLayer); + keymapLayerRegistered = true; + return true; + } catch (error) { + if (!(error instanceof Error) || error.message !== "Keymap.Provider is missing") + throw error; + return false; + } + }; + let unregisterKeymapSlot: (() => void) | undefined; + if (!registerKeymapLayer()) { + unregisterKeymapSlot = context.ui.slot({ + append: "app", + render: () => { + let registered = false; + try { + registered = registerKeymapLayer(); + } catch (error) { + console.warn("[magic-context] keymap.layer registration failed", error); + } + if (!registered && !keymapGapLogged) { + keymapGapLogged = true; + console.warn( + "[magic-context] OpenCode 2 keymap.layer is unavailable; /ctx-status and /ctx-recomp were not registered", + ); + } + return null; + }, + }); } const stopListening = context.data.listen(({ details }) => { @@ -255,6 +288,7 @@ export async function setupWithJsx(context: V2TuiContext, jsx: JsxFactory): Prom return () => { unregisterSlot(); + unregisterKeymapSlot?.(); stopListening(); stopNotificationSocket(); closeRpc(); diff --git a/packages/plugin/src/v2/tui/types.ts b/packages/plugin/src/v2/tui/types.ts index 7c3d13d50..0d08a5000 100644 --- a/packages/plugin/src/v2/tui/types.ts +++ b/packages/plugin/src/v2/tui/types.ts @@ -39,10 +39,7 @@ export interface V2TuiContext { }; readonly ui: { readonly router: { current(): V2TuiRoute }; - readonly slot: (claim: { - readonly append: "sidebar.content"; - readonly render: (input: { readonly sessionID: string }) => unknown; - }) => () => void; + readonly slot: (claim: V2SlotClaim) => () => void; readonly toast: { show(options: { readonly title?: string; @@ -62,6 +59,24 @@ export interface V2TuiContext { }; } +/** + * A slot claim's `render` runs inside the host's component tree; `app` is the + * always-mounted root slot, which is where `keymap.layer()` can be called from + * when plugin `setup()` runs outside the keymap provider (see index.ts). + */ +export type V2SlotClaim = + | { + readonly append: "sidebar.content"; + readonly render: (input: { readonly sessionID: string }) => unknown; + } + | { + readonly append: "app"; + readonly render: (input: Readonly>) => unknown; + }; + +/** The layer object `context.keymap.layer()` accepts, derived from the context type. */ +export type V2KeymapLayer = ReturnType[0]>; + export interface V2SidebarState { snapshots: Record; } From 94d70a33cb2affd2bb3232de2e522f07e999d4a5 Mon Sep 17 00:00:00 2001 From: astrid Date: Sat, 19 Sep 2026 21:31:24 -0400 Subject: [PATCH 04/13] feat(opencode2): support compaction-off mode on the v2 lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit registerContext returned early when compaction was disabled, so such projects got no ctx_* tools, no RPC surface and no memory/docs injection on OpenCode 2 — unlike the v1 lane, where compaction-off is a supported mode. Keep the lane active: the tool registry (already excludes ctx_reduce in this mode), the memory/docs transform (compactionOff: true) and the RPC server all run, while compaction-only machinery stays out of the way — the host checkpoint hook, fold observe/restore, historian runs and the >=95% unsafe interrupt are skipped so the host's native compaction owns the window. Storage failures no longer abort turns in this mode. The v2 TUI sidebar/status now render the compaction-off state (native compaction context label plus memories/notes/archived-compartment rows), mirroring the v1 TUI and using the shared COMPACTION_ENABLED_PATH constant. Verified on an isolated `opencode run --standalone` host scoped by XDG_CONFIG_HOME to a compaction-off config: ctx_search was called and the run exited cleanly, and the RPC sidebar-snapshot reported compaction_enabled:false. A compaction-on run still answers normally. --- packages/plugin/src/v2/hooks/context.ts | 187 ++++++++++-------- packages/plugin/src/v2/tui/index.ts | 24 ++- .../plugin/src/v2/tui/sidebar-text.test.ts | 57 ++++++ 3 files changed, 182 insertions(+), 86 deletions(-) create mode 100644 packages/plugin/src/v2/tui/sidebar-text.test.ts diff --git a/packages/plugin/src/v2/hooks/context.ts b/packages/plugin/src/v2/hooks/context.ts index 441808fb5..9433da039 100644 --- a/packages/plugin/src/v2/hooks/context.ts +++ b/packages/plugin/src/v2/hooks/context.ts @@ -171,9 +171,14 @@ export function applyV2PromptSurfaceTools( export async function registerContext(context: V2Context) { const directory = context.location.directory; const config = loadPluginConfigDetailed(directory).config; - if (!config.enabled || !isCompactionEnabled(config)) return; + if (!config.enabled) return; + // Compaction-off mode: Magic Context still provides tools, memory/docs + // injection and the RPC surface, but every compaction-only path + // (host-checkpoint intercept, folds, historian, unsafe interrupts) stays + // out of the way so the host's native compaction owns the window. + const compactionEnabled = isCompactionEnabled(config); const conflicts = detectConflicts(directory, { - compactionEnabled: true, + compactionEnabled, hostGeneration: "v2", }); if (conflicts.hasConflict) { @@ -341,7 +346,8 @@ export async function registerContext(context: V2Context) { const limit = limits.get(modelKey); if (tokens && limit && Number.isFinite(limit) && limit > 0) { const inputTokens = tokens.input + tokens.cache.read + tokens.cache.write; - unsafe = inputTokens / limit >= 0.95; + // Native compaction owns the window when MC compaction is off. + unsafe = compactionEnabled && inputTokens / limit >= 0.95; const completed = latest?.data.time?.completed; if (typeof completed === "number") updateSessionMeta(db, draft.sessionID, { lastResponseTime: completed }); @@ -356,7 +362,9 @@ export async function registerContext(context: V2Context) { } } catch (error) { console.warn("[magic-context] v2 refuseIfUnsafe", error); - unsafe = true; + // A storage failure in compaction-off mode must not abort the turn: + // there is no MC recovery path to run. + unsafe = compactionEnabled; } if (unsafe) await interruptBeforeProvider(context.session, draft.sessionID); return unsafe; @@ -382,36 +390,40 @@ export async function registerContext(context: V2Context) { }, }).m0Text; }; - await context.session.hook("compaction", async (draft) => { - const reader = new V2StoreReader( - gaDatabasePath(getDataDir(), process.env.OPENCODE_CHANNEL ?? "latest"), - ); - try { - const rows = reader.history(draft.sessionID); - const ids = new Set(draft.messages.map((message) => message.id)); - const watermark = Math.max( - -1, - ...rows.filter((row) => ids.has(row.id)).map((row) => row.seq), + if (compactionEnabled) + await context.session.hook("compaction", async (draft) => { + const reader = new V2StoreReader( + gaDatabasePath(getDataDir(), process.env.OPENCODE_CHANNEL ?? "latest"), ); - const running = rows - .filter((row) => row.type === "compaction" && row.data.status === "running") - .at(-1); - const fold = await folds.supply({ - sessionID: draft.sessionID, - watermark, - runningCut: running?.seq, - materialize: () => materialize(draft), - }); - draft.result = { summary: fold.submitted }; - } catch (cause) { - await interruptBeforeProvider(context.session, draft.sessionID); - throw new V2ContextRefusal("Magic Context could not preserve the host checkpoint.", { - cause, - }); - } finally { - reader.close(); - } - }); + try { + const rows = reader.history(draft.sessionID); + const ids = new Set(draft.messages.map((message) => message.id)); + const watermark = Math.max( + -1, + ...rows.filter((row) => ids.has(row.id)).map((row) => row.seq), + ); + const running = rows + .filter((row) => row.type === "compaction" && row.data.status === "running") + .at(-1); + const fold = await folds.supply({ + sessionID: draft.sessionID, + watermark, + runningCut: running?.seq, + materialize: () => materialize(draft), + }); + draft.result = { summary: fold.submitted }; + } catch (cause) { + await interruptBeforeProvider(context.session, draft.sessionID); + throw new V2ContextRefusal( + "Magic Context could not preserve the host checkpoint.", + { + cause, + }, + ); + } finally { + reader.close(); + } + }); await context.session.hook("context", async (draft) => { if (hiddenChildHook.apply(draft)) return; liveModels.set(draft.sessionID, { @@ -490,6 +502,7 @@ export async function registerContext(context: V2Context) { executeThresholdPercentage: config.execute_threshold_percentage, }), contextUsageMap: usage, + compactionOff: !compactionEnabled, protectedTokens: config.protected_tokens, protectedTokenTierOverrides: getProtectedTokensTierOverrides(config), executeThresholdPercentage: config.execute_threshold_percentage, @@ -504,7 +517,9 @@ export async function registerContext(context: V2Context) { projectPath: directory, hiddenCompletionExecutor, historianRunnable: - hiddenCompletionExecutor !== undefined && config.historian?.disable !== true, + compactionEnabled && + hiddenCompletionExecutor !== undefined && + config.historian?.disable !== true, historianModel: historianModels.primary, fallbackModels: historianModels.fallbacks, historianTimeoutMs: config.historian_timeout_ms, @@ -523,60 +538,64 @@ export async function registerContext(context: V2Context) { if (message.id && (await isAdmittedSynthetic(context, draft.sessionID, message.id))) admitted.add(message.id); } - const reader = new V2StoreReader( - gaDatabasePath(getDataDir(), process.env.OPENCODE_CHANNEL ?? "latest"), - ); let checkpoint: SessionContext["messages"][number] | undefined; let submitted: string | undefined; - try { - const cut = reader.latestCompaction(draft.sessionID); - const incoming = cut && draft.messages.find((message) => message.id === cut.id); - postFold = cut !== undefined; - if (cut && !incoming) - throw new Error("The host checkpoint disappeared from the context draft"); - if (cut && incoming) { - const identity = await folds.observe({ - sessionID: draft.sessionID, - cutSeq: cut.seq, - summary: cut.data.summary ?? "", - rendered: incoming, - onHard: (reason) => { - console.warn( - `[magic-context] HARD reason=${reason} session=${draft.sessionID}`, - ); - materialize(draft); - pendingMaterializationSessions.add(draft.sessionID); - }, - }); - checkpoint = structuredClone(identity.rendered ?? incoming); - submitted = identity.rendered - ? (identity.renderedSummary ?? identity.submitted) - : (cut.data.summary ?? ""); - const all = reader.history(draft.sessionID); - const boundaryID = ( - db - .prepare( - "SELECT cached_m0_last_baseline_end_message_id AS id FROM session_meta WHERE session_id = ?", + if (compactionEnabled) { + const reader = new V2StoreReader( + gaDatabasePath(getDataDir(), process.env.OPENCODE_CHANNEL ?? "latest"), + ); + try { + const cut = reader.latestCompaction(draft.sessionID); + const incoming = cut && draft.messages.find((message) => message.id === cut.id); + postFold = cut !== undefined; + if (cut && !incoming) + throw new Error("The host checkpoint disappeared from the context draft"); + if (cut && incoming) { + const identity = await folds.observe({ + sessionID: draft.sessionID, + cutSeq: cut.seq, + summary: cut.data.summary ?? "", + rendered: incoming, + onHard: (reason) => { + console.warn( + `[magic-context] HARD reason=${reason} session=${draft.sessionID}`, + ); + materialize(draft); + pendingMaterializationSessions.add(draft.sessionID); + }, + }); + checkpoint = structuredClone(identity.rendered ?? incoming); + submitted = identity.rendered + ? (identity.renderedSummary ?? identity.submitted) + : (cut.data.summary ?? ""); + const all = reader.history(draft.sessionID); + const boundaryID = ( + db + .prepare( + "SELECT cached_m0_last_baseline_end_message_id AS id FROM session_meta WHERE session_id = ?", + ) + .get(draft.sessionID) as { id: string | null } | null + )?.id; + const boundary = all.find((row) => row.id === boundaryID)?.seq ?? -1; + const present = new Set(draft.messages.map((message) => message.id)); + const restored = all + .filter( + (row) => + row.seq > boundary && + row.seq <= cut.seq && + !present.has(row.id), ) - .get(draft.sessionID) as { id: string | null } | null - )?.id; - const boundary = all.find((row) => row.id === boundaryID)?.seq ?? -1; - const present = new Set(draft.messages.map((message) => message.id)); - const restored = all - .filter( - (row) => - row.seq > boundary && row.seq <= cut.seq && !present.has(row.id), - ) - .flatMap((row) => restoreRow(row, draft.model)); - draft.messages.splice( - 0, - draft.messages.length, - ...restored, - ...draft.messages.filter((message) => message !== incoming), - ); + .flatMap((row) => restoreRow(row, draft.model)); + draft.messages.splice( + 0, + draft.messages.length, + ...restored, + ...draft.messages.filter((message) => message !== incoming), + ); + } + } finally { + reader.close(); } - } finally { - reader.close(); } const mapped = adaptPayload(draft, admitted); await transform({}, mapped); diff --git a/packages/plugin/src/v2/tui/index.ts b/packages/plugin/src/v2/tui/index.ts index 4d5a97ce3..32f9133a9 100644 --- a/packages/plugin/src/v2/tui/index.ts +++ b/packages/plugin/src/v2/tui/index.ts @@ -1,5 +1,7 @@ import { jsx } from "@opentui/solid/jsx-runtime"; +import { COMPACTION_ENABLED_PATH } from "../../config/agent-disable"; import type { SidebarSnapshot, StatusDetail } from "../../shared/rpc-types"; +import { compactionOffSidebarRows, nativeCompactionContextLabel } from "../../tui/compaction-off"; import { closeRpc, getCompartmentCount, @@ -25,8 +27,20 @@ function compactTokens(value: number): string { return String(value); } -function sidebarText(snapshot: SidebarSnapshot | undefined): string { +/** Exported for test access; mirrors the v1 sidebar's compaction-off rows. */ +export function sidebarText(snapshot: SidebarSnapshot | undefined): string { if (!snapshot) return "Magic Context · loading…"; + if (snapshot.compaction_enabled === false) { + return [ + "Magic Context", + nativeCompactionContextLabel(snapshot), + ...compactionOffSidebarRows(snapshot).map((row) => `${row.label} ${row.value}`), + ...(snapshot.readySmartNoteCount > 0 + ? [`Smart Notes ${snapshot.readySmartNoteCount} ready`] + : []), + ...(snapshot.lastTransformError ? [`Warning: ${snapshot.lastTransformError}`] : []), + ].join("\n"); + } const pressure = snapshot.contextLimit > 0 ? `${snapshot.usagePercentage.toFixed(1)}% · ${compactTokens(snapshot.inputTokens)}/${compactTokens(snapshot.contextLimit)}` @@ -41,12 +55,18 @@ function sidebarText(snapshot: SidebarSnapshot | undefined): string { ].join("\n"); } -function statusText(detail: StatusDetail): string { +/** Exported for test access. */ +export function statusText(detail: StatusDetail): string { const context = detail.contextLimit > 0 ? `${detail.usagePercentage.toFixed(1)}% (${compactTokens(detail.inputTokens)}/${compactTokens(detail.contextLimit)} tokens)` : `${compactTokens(detail.inputTokens)} tokens`; return [ + ...(detail.compaction_enabled === false + ? [ + `Compaction: disabled (${COMPACTION_ENABLED_PATH}: false) — native compaction owns the context window.`, + ] + : []), `Context: ${context}`, `Historian: ${detail.historianRunning ? "running" : "idle"}`, `Compartments: ${detail.compartmentCount}`, diff --git a/packages/plugin/src/v2/tui/sidebar-text.test.ts b/packages/plugin/src/v2/tui/sidebar-text.test.ts new file mode 100644 index 000000000..01290205a --- /dev/null +++ b/packages/plugin/src/v2/tui/sidebar-text.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from "bun:test"; +import { COMPACTION_ENABLED_PATH } from "../../config/agent-disable"; +import type { SidebarSnapshot, StatusDetail } from "../../shared/rpc-types"; +import { sidebarText, statusText } from "./index"; + +function snapshot(overrides: Partial): SidebarSnapshot { + return { + sessionId: "ses-test", + usagePercentage: 42, + inputTokens: 4200, + contextLimit: 10000, + systemPromptTokens: 0, + compartmentCount: 3, + memoryCount: 7, + memoryBlockCount: 2, + pendingOpsCount: 1, + historianRunning: false, + lastTransformError: null, + ...overrides, + } as SidebarSnapshot; +} + +test("compaction-off sidebar mirrors the v1 rows and native context label", () => { + const text = sidebarText( + snapshot({ + compaction_enabled: false, + archivedCompartmentCount: 4, + sessionNoteCount: 2, + readySmartNoteCount: 1, + }), + ); + expect(text).toContain("Context: 42.0% · native compaction"); + expect(text).toContain("Memories 7"); + expect(text).toContain("Notes 2"); + expect(text).toContain("Archived compartments 4"); + expect(text).toContain("Smart Notes 1 ready"); + expect(text).not.toContain("Historian"); +}); + +test("compaction-on sidebar keeps the historian/compartment line", () => { + const text = sidebarText(snapshot({ compaction_enabled: true })); + expect(text).toContain("Historian idle · C:3"); + expect(text).toContain("Memories 2/7 · Q:1"); + expect(text).not.toContain("native compaction"); +}); + +test("status dialog prefixes the compaction-off notice", () => { + const detail = { + ...snapshot({ compaction_enabled: false }), + } as unknown as StatusDetail; + expect(statusText(detail)).toContain( + `Compaction: disabled (${COMPACTION_ENABLED_PATH}: false) — native compaction owns the context window.`, + ); + expect(statusText({ ...detail, compaction_enabled: true } as StatusDetail)).not.toContain( + "Compaction: disabled", + ); +}); From 7e2c045629e9767aa026e6716c31f33c959d9e60 Mon Sep 17 00:00:00 2001 From: astrid Date: Sat, 19 Sep 2026 21:49:00 -0400 Subject: [PATCH 05/13] fix(opencode2): repair the v2 sidebar/status context numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps made the sidebar and status dialog show 0.0% · 0/200k: 1. Nothing persisted context usage: last_context_percentage/last_input_tokens are written by the v1 event handler, which the v2 lane does not run. The v2 lane now persists them (plus last_usage_context_limit and last_observed_model_key) alongside the usage map it already maintained. 2. MC's model-limit cache was never warmed on this lane, and its harness-scoped persisted file started empty, so every limit resolved to the generic 200k default. registerContext now seeds the shared cache from context.model.list() (the host's own resolved catalog) one task later, which also persists the last-known-good file for cold starts. 3. findLastAssistantModelFromOpenCodeDb read only the frozen v1 `message` table, so a v2 session without a live entry resolved no model at all. It now prefers session_message when present and falls back to v1 for legacy sessions an OpenCode 2 migration left behind. Verified on an isolated standalone host: session_meta gains real usage (1.36% / 13,569 tokens), model-context-limits-opencode2.json is written with 136 entries, and a sidebar-snapshot for a session without a live entry resolves contextLimit 934464 instead of 0 or 200000. --- .../magic-context/read-session-db.test.ts | 100 ++++++++++++++++++ .../hooks/magic-context/read-session-db.ts | 58 +++++++--- packages/plugin/src/v2/hooks/context.ts | 20 +++- .../src/v2/hooks/model-limit-cache.test.ts | 56 ++++++++++ .../plugin/src/v2/hooks/model-limit-cache.ts | 68 ++++++++++++ 5 files changed, 286 insertions(+), 16 deletions(-) create mode 100644 packages/plugin/src/v2/hooks/model-limit-cache.test.ts create mode 100644 packages/plugin/src/v2/hooks/model-limit-cache.ts diff --git a/packages/plugin/src/hooks/magic-context/read-session-db.test.ts b/packages/plugin/src/hooks/magic-context/read-session-db.test.ts index aefe65e1e..6b5559b30 100644 --- a/packages/plugin/src/hooks/magic-context/read-session-db.test.ts +++ b/packages/plugin/src/hooks/magic-context/read-session-db.test.ts @@ -598,6 +598,57 @@ function createOpenCodeDb(rows: MessageRow[]): void { } } +function insertV2SessionMessages( + rows: Array<{ + id: string; + sessionId: string; + type: "user" | "assistant" | "compaction"; + seq: number; + model?: { providerID?: string; id?: string }; + agent?: string; + }>, +): void { + const dbPath = join(process.env.XDG_DATA_HOME!, "opencode", "opencode.db"); + mkdirSync(dirname(dbPath), { recursive: true }); + const db = new Database(dbPath); + try { + db.exec(` + -- A migrated store keeps the v1 tables beside the v2 schema; the + -- read-only session DB guard classifies message+part as v1. + CREATE TABLE IF NOT EXISTS part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS session_message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + type TEXT NOT NULL, + seq INTEGER NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + `); + const insert = db.prepare( + `INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ); + const now = Date.now(); + for (const row of rows) { + const data: Record = {}; + if (row.model !== undefined) data.model = row.model; + if (row.agent !== undefined) data.agent = row.agent; + insert.run(row.id, row.sessionId, row.type, row.seq, now, now, JSON.stringify(data)); + } + } finally { + closeQuietly(db); + } +} + describe("latestPersistedMessageForRecovery", () => { it("reports when the latest assistant child has completed", () => { useTempDataHome("read-session-db-recovery-completed-"); @@ -626,6 +677,55 @@ describe("latestPersistedMessageForRecovery", () => { }); describe("findLastAssistantModelFromOpenCodeDb", () => { + it("prefers the v2 session_message table on a migrated store", () => { + useTempDataHome("read-session-db-v2-preference-"); + createOpenCodeDb([ + { + id: "msg_stale", + sessionId: "ses_A", + role: "assistant", + providerID: "anthropic", + modelID: "claude-sonnet-4.5", + timeCreated: 1000, + }, + ]); + insertV2SessionMessages([ + { id: "sms_user", sessionId: "ses_A", type: "user", seq: 1 }, + { + id: "sms_asst", + sessionId: "ses_A", + type: "assistant", + seq: 2, + model: { providerID: "commandcode", id: "deepseek/deepseek-v4.1-flash" }, + agent: "build", + }, + ]); + expect(findLastAssistantModelFromOpenCodeDb("ses_A")).toEqual({ + providerID: "commandcode", + modelID: "deepseek/deepseek-v4.1-flash", + agent: "build", + }); + }); + + it("falls back to the v1 table when the v2 table has no assistant rows", () => { + useTempDataHome("read-session-db-v2-fallback-"); + createOpenCodeDb([ + { + id: "msg_legacy", + sessionId: "ses_A", + role: "assistant", + providerID: "anthropic", + modelID: "claude-opus-4-7", + timeCreated: 1000, + }, + ]); + insertV2SessionMessages([{ id: "sms_user", sessionId: "ses_A", type: "user", seq: 1 }]); + expect(findLastAssistantModelFromOpenCodeDb("ses_A")).toEqual({ + providerID: "anthropic", + modelID: "claude-opus-4-7", + }); + }); + it("returns null for a session with no assistant messages", () => { useTempDataHome("read-session-db-no-assistant-"); createOpenCodeDb([ diff --git a/packages/plugin/src/hooks/magic-context/read-session-db.ts b/packages/plugin/src/hooks/magic-context/read-session-db.ts index 45aa6e60b..3fd721c2b 100644 --- a/packages/plugin/src/hooks/magic-context/read-session-db.ts +++ b/packages/plugin/src/hooks/magic-context/read-session-db.ts @@ -596,20 +596,50 @@ export function findLastAssistantModelFromOpenCodeDb( ): { providerID: string; modelID: string; agent?: string } | null { try { return withReadOnlySessionDb((db) => { - const row = db - .prepare( - `SELECT json_extract(data, '$.providerID') as providerID, - json_extract(data, '$.modelID') as modelID, - json_extract(data, '$.agent') as agent - FROM message - WHERE session_id = ? - AND json_extract(data, '$.role') = 'assistant' - AND json_extract(data, '$.providerID') IS NOT NULL - AND json_extract(data, '$.modelID') IS NOT NULL - ORDER BY time_created DESC - LIMIT 1`, - ) - .get(sessionId) as (AssistantModelRow & { agent?: string | null }) | null; + // A v2 host writes assistant models on `session_message`. On a migrated + // store the frozen v1 `message` table still exists but holds only + // pre-migration rows, so prefer the v2 table and fall back to v1 only + // when v2 has nothing for this session (a legacy session untouched + // since the migration). + const hasV2Messages = Boolean( + db + .prepare( + "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'session_message' LIMIT 1", + ) + .get(), + ); + const v2Row = hasV2Messages + ? (db + .prepare( + `SELECT json_extract(data, '$.model.providerID') as providerID, + json_extract(data, '$.model.id') as modelID, + json_extract(data, '$.agent') as agent + FROM session_message + WHERE session_id = ? + AND type = 'assistant' + AND json_extract(data, '$.model.providerID') IS NOT NULL + AND json_extract(data, '$.model.id') IS NOT NULL + ORDER BY seq DESC + LIMIT 1`, + ) + .get(sessionId) as (AssistantModelRow & { agent?: string | null }) | null) + : null; + const row = + v2Row ?? + (db + .prepare( + `SELECT json_extract(data, '$.providerID') as providerID, + json_extract(data, '$.modelID') as modelID, + json_extract(data, '$.agent') as agent + FROM message + WHERE session_id = ? + AND json_extract(data, '$.role') = 'assistant' + AND json_extract(data, '$.providerID') IS NOT NULL + AND json_extract(data, '$.modelID') IS NOT NULL + ORDER BY time_created DESC + LIMIT 1`, + ) + .get(sessionId) as (AssistantModelRow & { agent?: string | null }) | null); if (!row || typeof row.providerID !== "string" || typeof row.modelID !== "string") { return null; } diff --git a/packages/plugin/src/v2/hooks/context.ts b/packages/plugin/src/v2/hooks/context.ts index 9433da039..42bd79a97 100644 --- a/packages/plugin/src/v2/hooks/context.ts +++ b/packages/plugin/src/v2/hooks/context.ts @@ -48,6 +48,7 @@ import { gaDatabasePath, V2StoreReader } from "../store-reader"; import { deliverPendingChannel2, isAdmittedSynthetic } from "./channel2"; import { startDreamTrigger } from "./dream-trigger"; import { HiddenChildHook, registerHiddenChildAgents } from "./hidden-child"; +import { warmModelLimitCacheFromCatalog } from "./model-limit-cache"; import { adaptPayload, HEAD_IDS } from "./payload"; import { interruptBeforeProvider, V2ContextRefusal } from "./refusal"; import { rawMessages } from "./store"; @@ -349,8 +350,16 @@ export async function registerContext(context: V2Context) { // Native compaction owns the window when MC compaction is off. unsafe = compactionEnabled && inputTokens / limit >= 0.95; const completed = latest?.data.time?.completed; - if (typeof completed === "number") - updateSessionMeta(db, draft.sessionID, { lastResponseTime: completed }); + // The v1 lane persists usage from its event handler; the v2 lane + // has no event handler, so persist the same fields here or the + // sidebar/status surface stays at the 0 defaults. + updateSessionMeta(db, draft.sessionID, { + ...(typeof completed === "number" ? { lastResponseTime: completed } : {}), + lastContextPercentage: (inputTokens / limit) * 100, + lastInputTokens: inputTokens, + lastUsageContextLimit: limit, + lastObservedModelKey: modelKey, + }); usage.set(draft.sessionID, { usage: { inputTokens, percentage: (inputTokens / limit) * 100 }, hasUsageTokens: true, @@ -645,6 +654,13 @@ export async function registerContext(context: V2Context) { console.warn("[magic-context] v2 context unavailable", error); } }); + // The v1 lane warms MC's model-limit cache from its SDK client at boot; the + // v2 lane must seed it from the host catalog, or every limit resolved here + // falls back to the generic 200k default (sidebar denominator, history + // budgets and window geometry then disagree with the transform's own math). + setTimeout(() => { + void warmModelLimitCacheFromCatalog(context); + }, 0); // OpenCode 2 never runs the v1 server() lane, so the RPC server that the // terminal TUI's sidebar/status reads depend on would never start: the v2 // TUI is a pure RPC client (no direct SQLite access), so without a listener diff --git a/packages/plugin/src/v2/hooks/model-limit-cache.test.ts b/packages/plugin/src/v2/hooks/model-limit-cache.test.ts new file mode 100644 index 000000000..802b1c848 --- /dev/null +++ b/packages/plugin/src/v2/hooks/model-limit-cache.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "bun:test"; +import { catalogProvidersPayload } from "./model-limit-cache"; + +test("groups raw catalog rows by provider and keeps their metadata", () => { + const payload = catalogProvidersPayload([ + { + id: "deepseek/deepseek-v4.1-flash", + providerID: "commandcode", + limit: { context: 1_000_000, output: 65_536 }, + }, + { + id: "deepseek/deepseek-v4-flash", + providerID: "commandcode", + limit: { context: 1_000_000 }, + }, + { + id: "muse-spark-1.3-contributor", + providerID: "opencode-go", + limit: { context: 200_000 }, + }, + ]); + expect(payload.map((provider) => provider.id)).toEqual(["commandcode", "opencode-go"]); + const commandcode = payload[0]!; + expect(Object.keys(commandcode.models)).toEqual([ + "deepseek/deepseek-v4.1-flash", + "deepseek/deepseek-v4-flash", + ]); + expect(commandcode.models["deepseek/deepseek-v4.1-flash"]).toEqual({ + id: "deepseek/deepseek-v4.1-flash", + providerID: "commandcode", + limit: { context: 1_000_000, output: 65_536 }, + }); +}); + +test("accepts the { data } list envelope and skips malformed rows", () => { + const payload = catalogProvidersPayload({ + data: [ + { id: "a", providerID: "p", limit: { context: 100_000 } }, + null, + { id: "b" }, + 42, + { providerID: "p" }, + ], + }); + expect(payload).toEqual([ + { + id: "p", + models: { a: { id: "a", providerID: "p", limit: { context: 100_000 } } }, + }, + ]); +}); + +test("returns an empty payload for unusable input", () => { + expect(catalogProvidersPayload(null)).toEqual([]); + expect(catalogProvidersPayload({})).toEqual([]); +}); diff --git a/packages/plugin/src/v2/hooks/model-limit-cache.ts b/packages/plugin/src/v2/hooks/model-limit-cache.ts new file mode 100644 index 000000000..f446ecd25 --- /dev/null +++ b/packages/plugin/src/v2/hooks/model-limit-cache.ts @@ -0,0 +1,68 @@ +import { getErrorMessage } from "../../shared/error-message"; +import { sessionLog } from "../../shared/logger"; +import { refreshModelLimitsFromApi } from "../../shared/models-dev-cache"; +import type { V2Context } from "./types"; + +/** Once-per-process latch: the model-limit cache is process-global. */ +let warmStarted = false; + +/** + * Build the `config.providers()` payload `refreshModelLimitsFromApi` consumes + * from the v2 host's own model catalog. Each raw catalog row is passed through + * (limit, capabilities, modalities, …) so the shared cache applies exactly the + * same sane-filtering and output-reservation logic as the v1 boot warm. + */ +export function catalogProvidersPayload(listed: unknown): Array<{ + id: string; + models: Record>; +}> { + const rows = Array.isArray(listed) + ? listed + : listed && typeof listed === "object" && Array.isArray((listed as { data?: unknown }).data) + ? (listed as { data: unknown[] }).data + : []; + const byProvider = new Map>>(); + for (const row of rows) { + if (!row || typeof row !== "object") continue; + const entry = row as { id?: unknown; providerID?: unknown }; + if (typeof entry.id !== "string" || typeof entry.providerID !== "string") continue; + const models = byProvider.get(entry.providerID) ?? {}; + models[entry.id] = entry as Record; + byProvider.set(entry.providerID, models); + } + return [...byProvider.entries()].map(([id, models]) => ({ id, models })); +} + +/** + * Seed Magic Context's model-limit cache from the v2 host catalog. + * + * The v1 lane warms `models-dev-cache` from its SDK client at boot. The v2 lane + * has no SDK client and its harness-scoped persisted file starts empty, so every + * limit resolved on this lane fell back to the generic 200k default — the + * sidebar denominator, history budgets and window geometry all disagreed with + * the transform's own catalog math. `context.model.list()` is the same resolved + * catalog the host itself uses, so feeding it through the shared refresh keeps + * one source of truth and persists a last-known-good file for cold starts. + */ +export async function warmModelLimitCacheFromCatalog(context: V2Context): Promise { + if (warmStarted) return; + warmStarted = true; + try { + await refreshModelLimitsFromApi( + { + config: { + providers: async () => ({ + data: { + providers: catalogProvidersPayload( + await Promise.resolve(context.model.list()), + ), + }, + }), + }, + }, + { retries: 3, retryDelayMs: 1000 }, + ); + } catch (error) { + sessionLog("global", `v2 model-limit cache warm failed: ${getErrorMessage(error)}`); + } +} From d198f78b4dc2c7f4a96894701287f2c3f8eb1a3e Mon Sep 17 00:00:00 2001 From: astrid Date: Sat, 19 Sep 2026 21:55:07 -0400 Subject: [PATCH 06/13] fix(opencode2): re-apply persisted usage after the transform pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v2 lane persists usage in refuseIfUnsafe, which runs before the transform; the transform's first-pass reset then zeroes last_context_percentage and last_input_tokens mid-pass, so the first turn after a process restart still showed 0.0% · 0 in the sidebar even with the persistence in place. Capture the measured usage and re-apply it after the pass (the v1 lane's event handler writes after the pass for the same reason). The early write stays so the unsafe-abort path, which returns before the transform, still records. Verified by continuing an existing session from a new standalone process (the first-transform-pass case): 1.41% / 14,066 tokens persisted instead of 0. --- packages/plugin/src/v2/hooks/context.ts | 39 ++++++++++++++++++------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/plugin/src/v2/hooks/context.ts b/packages/plugin/src/v2/hooks/context.ts index 42bd79a97..72f344488 100644 --- a/packages/plugin/src/v2/hooks/context.ts +++ b/packages/plugin/src/v2/hooks/context.ts @@ -322,6 +322,20 @@ export async function registerContext(context: V2Context) { getCount: (sessionID: string) => read(sessionID).length, }); let transform: ReturnType | undefined; + // Usage measured from the most recent assistant response. The transform's + // first-pass reset zeroes the persisted usage fields mid-pass, so the same + // values are re-applied after the pass — the v1 lane's event handler writes + // after the pass too, which is why its sidebar never shows the reset. + let measuredUsage: + | { inputTokens: number; limit: number; modelKey: string; completed?: number } + | undefined; + const usageMetaPatch = (value: NonNullable) => ({ + ...(value.completed !== undefined ? { lastResponseTime: value.completed } : {}), + lastContextPercentage: (value.inputTokens / value.limit) * 100, + lastInputTokens: value.inputTokens, + lastUsageContextLimit: value.limit, + lastObservedModelKey: value.modelKey, + }); const refuseIfUnsafe = async (draft: SessionContext): Promise => { let unsafe = false; try { @@ -350,16 +364,15 @@ export async function registerContext(context: V2Context) { // Native compaction owns the window when MC compaction is off. unsafe = compactionEnabled && inputTokens / limit >= 0.95; const completed = latest?.data.time?.completed; - // The v1 lane persists usage from its event handler; the v2 lane - // has no event handler, so persist the same fields here or the - // sidebar/status surface stays at the 0 defaults. - updateSessionMeta(db, draft.sessionID, { - ...(typeof completed === "number" ? { lastResponseTime: completed } : {}), - lastContextPercentage: (inputTokens / limit) * 100, - lastInputTokens: inputTokens, - lastUsageContextLimit: limit, - lastObservedModelKey: modelKey, - }); + measuredUsage = { + inputTokens, + limit, + modelKey, + ...(typeof completed === "number" ? { completed } : {}), + }; + // Early write covers the abort path (returned before the + // transform); the post-pass write below wins on normal turns. + updateSessionMeta(db, draft.sessionID, usageMetaPatch(measuredUsage)); usage.set(draft.sessionID, { usage: { inputTokens, percentage: (inputTokens / limit) * 100 }, hasUsageTokens: true, @@ -617,6 +630,12 @@ export async function registerContext(context: V2Context) { channel1.get(draft.sessionID), ); } + // Re-apply the usage fields the transform's first-pass reset zeroed + // mid-pass (the v1 lane's event handler writes after the pass too, which + // is why its sidebar never shows the reset). + if (db && measuredUsage) { + updateSessionMeta(db, draft.sessionID, usageMetaPatch(measuredUsage)); + } if (checkpoint && submitted !== undefined) { const head = draft.messages.find((message) => message.id === HEAD_IDS[0]); const baseline = head?.content.find((part) => part.type === "text")?.text; From 0060ce72bbe4e786897b08a76edb5bf0170e66c1 Mon Sep 17 00:00:00 2001 From: astrid Date: Sat, 19 Sep 2026 22:27:49 -0400 Subject: [PATCH 07/13] feat(opencode2): wire /ctx-dream into the v2 lane The v1 lane runs /ctx-dream through its host command template; the v2 lane has no command path, so it becomes a third slash command in the TUI keymap layer (next to /ctx-status and /ctx-recomp), backed by a new "dream" RPC. - The RPC runs runManualDream with the same executor wiring as the event-driven dream trigger, using the requesting session as the hidden children's parent. A pass can outlive the request timeout, so it starts in the background and pushes a show-result-dialog notification with the summary when it finishes (toasts cover validation, unavailable, and permanent-failure cases). - summarizeManualDream moved into the dreamer feature directory so the v1 command output and the v2 notification render identically. - "/ctx-dream " validates against CANONICAL_DREAM_TASKS and force-runs that one task, matching v1 semantics. Verified on an isolated standalone host: an unknown task returns the valid-task list; a no-arg run is accepted and seeds task_schedule_state for the project. Confirmed live in the TUI. --- .../magic-context/dreamer/manual-summary.ts | 49 ++++++++++++ .../hooks/magic-context/command-handler.ts | 36 +-------- .../src/tui-compiled/data/context-db.ts | 15 ++++ packages/plugin/src/tui/data/context-db.ts | 15 ++++ packages/plugin/src/v2/hooks/context.ts | 78 +++++++++++++++++++ .../plugin/src/v2/hooks/dream-manual.test.ts | 56 +++++++++++++ packages/plugin/src/v2/hooks/dream-manual.ts | 67 ++++++++++++++++ .../plugin/src/v2/tui/host-contract.test.ts | 3 +- packages/plugin/src/v2/tui/index.ts | 42 ++++++++-- 9 files changed, 318 insertions(+), 43 deletions(-) create mode 100644 packages/plugin/src/features/magic-context/dreamer/manual-summary.ts create mode 100644 packages/plugin/src/v2/hooks/dream-manual.test.ts create mode 100644 packages/plugin/src/v2/hooks/dream-manual.ts diff --git a/packages/plugin/src/features/magic-context/dreamer/manual-summary.ts b/packages/plugin/src/features/magic-context/dreamer/manual-summary.ts new file mode 100644 index 000000000..072d0a44b --- /dev/null +++ b/packages/plugin/src/features/magic-context/dreamer/manual-summary.ts @@ -0,0 +1,49 @@ +import { formatDreamTaskBacklogs } from "./task-registry"; +import type { ManualRunResult } from "./task-scheduler"; + +/** + * Render a manual `/ctx-dream` run for the user. Shared by the v1 command + * output and the v2 RPC notification so the two surfaces stay identical. + */ +export function summarizeManualDream(summary: ManualRunResult): string { + const lines: string[] = ["## /ctx-dream", ""]; + if (summary.ran.length > 0) lines.push(`Ran: ${summary.ran.join(", ")}`); + if ((summary.details?.length ?? 0) > 0) { + lines.push("Details:", ...(summary.details ?? []).map((detail) => `- ${detail}`)); + } + if (summary.failed.length > 0) lines.push(`Failed: ${summary.failed.join(", ")}`); + if ((summary.failureDetails?.length ?? 0) > 0) { + lines.push( + "Failure details:", + ...(summary.failureDetails ?? []).map((detail) => `- ${detail}`), + ); + } + if (summary.skippedNoWork.length > 0) + lines.push(`Skipped (no work): ${summary.skippedNoWork.join(", ")}`); + if (summary.deferredBusy.length > 0) + lines.push( + // "Busy" means the task's DOMAIN lease is held — usually a sibling + // task (e.g. a scheduled verify blocking a manual curate), not + // this task itself. Say so, or the message reads as a lie. + `Busy: ${summary.deferredBusy.join(", ")} — another dream task holds this domain's lease; retry in a minute`, + ); + if (Object.keys(summary.backlogBefore ?? {}).length > 0) { + lines.push( + "", + "Backlog at run start:", + formatDreamTaskBacklogs(summary.backlogBefore ?? {}), + ); + } + if (Object.keys(summary.backlogAfter ?? {}).length > 0) { + lines.push("", "Backlog at run end:", formatDreamTaskBacklogs(summary.backlogAfter ?? {})); + } + if ( + summary.ran.length === 0 && + summary.failed.length === 0 && + summary.skippedNoWork.length === 0 && + summary.deferredBusy.length === 0 + ) { + lines.push("No enabled dream tasks to run."); + } + return lines.join("\n"); +} diff --git a/packages/plugin/src/hooks/magic-context/command-handler.ts b/packages/plugin/src/hooks/magic-context/command-handler.ts index 060e5ecac..6fae2cd71 100644 --- a/packages/plugin/src/hooks/magic-context/command-handler.ts +++ b/packages/plugin/src/hooks/magic-context/command-handler.ts @@ -3,6 +3,7 @@ import { COMPACTION_ENABLED_PATH } from "../../config/agent-disable"; import type { DreamerConfig, MagicContextConfig } from "../../config/schema/magic-context"; import type { ResolvedTransformMode } from "../../config/transform-mode"; import type { MagicContextBuiltinCommandName } from "../../features/builtin-commands/commands"; +import { summarizeManualDream } from "../../features/magic-context/dreamer/manual-summary"; import { getDreamTaskBacklogs } from "../../features/magic-context/dreamer/task-gates"; import { CANONICAL_DREAM_TASKS, @@ -375,41 +376,6 @@ function readDreamTaskBacklogsSafely( } } -function summarizeManualDream(s: ManualDreamSummary): string { - const lines: string[] = ["## /ctx-dream", ""]; - if (s.ran.length > 0) lines.push(`Ran: ${s.ran.join(", ")}`); - if ((s.details?.length ?? 0) > 0) { - lines.push("Details:", ...(s.details ?? []).map((detail) => `- ${detail}`)); - } - if (s.failed.length > 0) lines.push(`Failed: ${s.failed.join(", ")}`); - if ((s.failureDetails?.length ?? 0) > 0) { - lines.push("Failure details:", ...(s.failureDetails ?? []).map((detail) => `- ${detail}`)); - } - if (s.skippedNoWork.length > 0) lines.push(`Skipped (no work): ${s.skippedNoWork.join(", ")}`); - if (s.deferredBusy.length > 0) - lines.push( - // "Busy" means the task's DOMAIN lease is held — usually a sibling - // task (e.g. a scheduled verify blocking a manual curate), not - // this task itself. Say so, or the message reads as a lie. - `Busy: ${s.deferredBusy.join(", ")} — another dream task holds this domain's lease; retry in a minute`, - ); - if (Object.keys(s.backlogBefore ?? {}).length > 0) { - lines.push("", "Backlog at run start:", formatDreamTaskBacklogs(s.backlogBefore ?? {})); - } - if (Object.keys(s.backlogAfter ?? {}).length > 0) { - lines.push("", "Backlog at run end:", formatDreamTaskBacklogs(s.backlogAfter ?? {})); - } - if ( - s.ran.length === 0 && - s.failed.length === 0 && - s.skippedNoWork.length === 0 && - s.deferredBusy.length === 0 - ) { - lines.push("No enabled dream tasks to run."); - } - return lines.join("\n"); -} - async function executeDreaming( deps: { db: Database; diff --git a/packages/plugin/src/tui-compiled/data/context-db.ts b/packages/plugin/src/tui-compiled/data/context-db.ts index 4c569b346..cb387473c 100644 --- a/packages/plugin/src/tui-compiled/data/context-db.ts +++ b/packages/plugin/src/tui-compiled/data/context-db.ts @@ -246,6 +246,21 @@ export async function requestRecomp(sessionId: string): Promise { } } +/** Start a manual `/ctx-dream` run (optionally one named task) via RPC. The + * server starts the pass in the background and pushes the summary when done. */ +export async function requestDream(sessionId: string, task?: string): Promise { + if (!rpcClient) return false; + try { + const result = await rpcClient.call<{ ok: boolean }>("dream", { + sessionId, + ...(task ? { task } : {}), + }); + return result.ok ?? false; + } catch { + return false; + } +} + /** Run `/ctx-session-upgrade` for the session (full recomp + once-per-project * memory migration). Fired from the upgrade dialog's "Run upgrade now" action. */ export async function requestUpgrade(sessionId: string): Promise { diff --git a/packages/plugin/src/tui/data/context-db.ts b/packages/plugin/src/tui/data/context-db.ts index 4c569b346..cb387473c 100644 --- a/packages/plugin/src/tui/data/context-db.ts +++ b/packages/plugin/src/tui/data/context-db.ts @@ -246,6 +246,21 @@ export async function requestRecomp(sessionId: string): Promise { } } +/** Start a manual `/ctx-dream` run (optionally one named task) via RPC. The + * server starts the pass in the background and pushes the summary when done. */ +export async function requestDream(sessionId: string, task?: string): Promise { + if (!rpcClient) return false; + try { + const result = await rpcClient.call<{ ok: boolean }>("dream", { + sessionId, + ...(task ? { task } : {}), + }); + return result.ok ?? false; + } catch { + return false; + } +} + /** Run `/ctx-session-upgrade` for the session (full recomp + once-per-project * memory migration). Fired from the upgrade dialog's "Run upgrade now" action. */ export async function requestUpgrade(sessionId: string): Promise { diff --git a/packages/plugin/src/v2/hooks/context.ts b/packages/plugin/src/v2/hooks/context.ts index 72f344488..5df16c184 100644 --- a/packages/plugin/src/v2/hooks/context.ts +++ b/packages/plugin/src/v2/hooks/context.ts @@ -2,6 +2,7 @@ import { type ToolDefinition, type ToolResult, tool } from "@opencode-ai/plugin" import { loadPluginConfigDetailed } from "../../config"; import { isCompactionEnabled } from "../../config/agent-disable"; import { getProtectedTokensTierOverrides } from "../../config/project-security"; +import { summarizeManualDream } from "../../features/magic-context/dreamer/manual-summary"; import { resolveProjectIdentity } from "../../features/magic-context/memory/project-identity"; import { createScheduler } from "../../features/magic-context/scheduler"; import { @@ -31,6 +32,7 @@ import { createToolRegistry } from "../../plugin/tool-registry"; import type { PluginContext } from "../../plugin/types"; import { detectConflicts } from "../../shared/conflict-detector"; import { getDataDir, getMagicContextStorageDir } from "../../shared/data-path"; +import { getErrorMessage } from "../../shared/error-message"; import { resolveHistorianModel } from "../../shared/model-resolution"; import type { PromptSurfaceConfig } from "../../shared/prompt-surface"; import { @@ -46,6 +48,7 @@ import { restoreRow } from "../fold/restore"; import { createV2HiddenCompletionExecutor } from "../hidden-completion"; import { gaDatabasePath, V2StoreReader } from "../store-reader"; import { deliverPendingChannel2, isAdmittedSynthetic } from "./channel2"; +import { resolveManualDreamTask, runManualDreamNow } from "./dream-manual"; import { startDreamTrigger } from "./dream-trigger"; import { HiddenChildHook, registerHiddenChildAgents } from "./hidden-child"; import { warmModelLimitCacheFromCatalog } from "./model-limit-cache"; @@ -708,6 +711,81 @@ export async function registerContext(context: V2Context) { rustModeModuleClient: undefined, storageDir, }); + // Manual /ctx-dream: the v1 lane runs it through its OpenCode command + // template; v2 has no command path, so the TUI's slash command reaches it + // here. A full dream pass outlives the RPC request timeout, so start it in + // the background and push the summary as a dialog notification when done. + const manualDreamer = + config.dreamer && config.dreamer.disable !== true ? config.dreamer : undefined; + rpcServer.handle("dream", async (params) => { + const sessionId = String(params.sessionId ?? ""); + if (!sessionId) return { ok: false, error: "no session" }; + const requested = resolveManualDreamTask(params.task); + if (requested.error) { + pushNotification("toast", { message: requested.error, variant: "warning" }, sessionId); + return { ok: false, error: requested.error }; + } + if (!manualDreamer || !hiddenCompletionExecutor) { + pushNotification( + "toast", + { message: "Dreaming is not configured for this project.", variant: "warning" }, + sessionId, + ); + return { ok: false, error: "dreamer unavailable" }; + } + db ??= openDatabase(); + if (!db || !isDatabasePersisted(db)) { + pushNotification( + "toast", + { + message: "Dreaming is unavailable: context storage is not durable.", + variant: "error", + }, + sessionId, + ); + return { ok: false, error: "storage unavailable" }; + } + const runDb = db; + const runExecutor = hiddenCompletionExecutor; + pushNotification( + "toast", + { + message: "Dream run started; the summary appears when it finishes.", + variant: "info", + }, + sessionId, + ); + void runManualDreamNow({ + db: runDb, + dreamer: manualDreamer, + projectIdentity: resolveProjectIdentity(directory) ?? directory, + directory, + language: config.language, + mural: config.mural, + executor: runExecutor, + sessionId, + ...(requested.task !== undefined ? { task: requested.task } : {}), + }) + .then((summary) => { + pushNotification( + "action", + { + action: "show-result-dialog", + title: "Magic Context dream run", + message: summarizeManualDream(summary), + }, + sessionId, + ); + }) + .catch((error) => { + pushNotification( + "toast", + { message: `Dream run failed: ${getErrorMessage(error)}`, variant: "error" }, + sessionId, + ); + }); + return { ok: true }; + }); // start() is async but its Bun.serve + discovery-file prefix is synchronous; // run it in the next task so those filesystem calls stay outside the host's // deadline-bound plugin construction, matching the v1 lane. diff --git a/packages/plugin/src/v2/hooks/dream-manual.test.ts b/packages/plugin/src/v2/hooks/dream-manual.test.ts new file mode 100644 index 000000000..0b6d6a420 --- /dev/null +++ b/packages/plugin/src/v2/hooks/dream-manual.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "bun:test"; +import { summarizeManualDream } from "../../features/magic-context/dreamer/manual-summary"; +import { resolveManualDreamTask } from "./dream-manual"; + +test("no argument runs every enabled task", () => { + expect(resolveManualDreamTask(undefined)).toEqual({}); + expect(resolveManualDreamTask(" ")).toEqual({}); + expect(resolveManualDreamTask(42)).toEqual({}); +}); + +test("accepts a canonical task name", () => { + expect(resolveManualDreamTask("verify")).toEqual({ task: "verify" }); + expect(resolveManualDreamTask(" map-memories ")).toEqual({ task: "map-memories" }); +}); + +test("rejects an unknown task with the valid list", () => { + const result = resolveManualDreamTask("nope"); + expect(result.task).toBeUndefined(); + expect(result.error).toContain('Unknown task "nope"'); + expect(result.error).toContain("map-memories"); +}); + +test("summarizes a manual run like the v1 command output", () => { + const message = summarizeManualDream({ + ran: ["verify"], + skippedNoWork: ["curate"], + deferredBusy: ["map-memories"], + failed: ["retrospective"], + failureDetails: ["retrospective: model unavailable"], + details: ["verify: 3 memories checked"], + backlogBefore: { verify: { pending: 3, total: 3 } }, + backlogAfter: { verify: { pending: 0, total: 3 } }, + }); + expect(message).toContain("Ran: verify"); + expect(message).toContain("Skipped (no work): curate"); + expect(message).toContain("Busy: map-memories"); + expect(message).toContain("Failed: retrospective"); + expect(message).toContain("- retrospective: model unavailable"); + expect(message).toContain("Backlog at run start:"); + expect(message).toContain("- verify: 3 pending / 3 total"); + expect(message).toContain("Backlog at run end:"); +}); + +test("summarizes an idle run", () => { + const message = summarizeManualDream({ + ran: [], + skippedNoWork: [], + deferredBusy: [], + failed: [], + failureDetails: [], + details: [], + backlogBefore: {}, + backlogAfter: {}, + }); + expect(message).toContain("No enabled dream tasks to run."); +}); diff --git a/packages/plugin/src/v2/hooks/dream-manual.ts b/packages/plugin/src/v2/hooks/dream-manual.ts new file mode 100644 index 000000000..9fdce56c5 --- /dev/null +++ b/packages/plugin/src/v2/hooks/dream-manual.ts @@ -0,0 +1,67 @@ +import type { DreamerConfig } from "../../config/schema/magic-context"; +import { buildDreamTaskRuntimeConfigs } from "../../features/magic-context/dreamer/task-config"; +import { createDreamTaskExecutor } from "../../features/magic-context/dreamer/task-executor"; +import { + CANONICAL_DREAM_TASKS, + type DreamTaskName, + isCanonicalDreamTask, +} from "../../features/magic-context/dreamer/task-registry"; +import { + type ManualRunResult, + runManualDream, +} from "../../features/magic-context/dreamer/task-scheduler"; +import type { ContextDatabase } from "../../features/magic-context/storage"; +import type { HiddenCompletionExecutor } from "../../hooks/magic-context/compartment-runner-types"; + +/** Validate the optional `/ctx-dream ` argument (mirrors the v1 command). */ +export function resolveManualDreamTask(raw: unknown): { task?: DreamTaskName; error?: string } { + const requested = typeof raw === "string" ? raw.trim() : ""; + if (!requested) return {}; + if (!isCanonicalDreamTask(requested)) { + return { + error: `Unknown task "${requested}". Valid tasks: ${CANONICAL_DREAM_TASKS.join(", ")}.`, + }; + } + return { task: requested }; +} + +/** + * Run the manual dream pass for a project on the v2 lane. + * + * The v1 lane drives this from its command handler; v2 has no host command + * template path, so the TUI's `/ctx-dream` slash command reaches it through the + * "dream" RPC. Uses the same scheduler entry point and executor wiring as the + * event-driven `startDreamTrigger`, with the requesting session as the hidden + * children's parent. + */ +export async function runManualDreamNow(args: { + db: ContextDatabase; + dreamer: DreamerConfig; + projectIdentity: string; + directory: string; + language?: string; + mural?: { enabled: boolean; model?: string }; + executor: HiddenCompletionExecutor; + sessionId: string; + task?: DreamTaskName; +}): Promise { + return runManualDream({ + db: args.db, + projectIdentity: args.projectIdentity, + tasks: buildDreamTaskRuntimeConfigs( + args.dreamer, + "opencode", + args.language, + args.mural?.model, + ), + executor: createDreamTaskExecutor({ + hiddenCompletionExecutor: args.executor, + parentSessionId: args.sessionId, + sessionDirectory: args.directory, + openOpenCodeDb: () => null, + language: args.language, + mural: args.mural, + }), + ...(args.task !== undefined ? { task: args.task } : {}), + }); +} diff --git a/packages/plugin/src/v2/tui/host-contract.test.ts b/packages/plugin/src/v2/tui/host-contract.test.ts index a377dc6c5..e4375af34 100644 --- a/packages/plugin/src/v2/tui/host-contract.test.ts +++ b/packages/plugin/src/v2/tui/host-contract.test.ts @@ -135,6 +135,7 @@ test("GA 2.0.5 resolves ./tui and executes the union setup contract", async () = expect(fixture.layers[0]!.commands.map((command) => command.slash.name)).toEqual([ "ctx-status", "ctx-recomp", + "ctx-dream", ]); cleanup(); }); @@ -159,7 +160,7 @@ test("GA 2.0.5 registers the keymap layer from the app slot when setup runs outs appClaim.render({}); expect( fixture.layers.map((layer) => layer.commands.map((command) => command.slash.name)), - ).toEqual([["ctx-status", "ctx-recomp"]]); + ).toEqual([["ctx-status", "ctx-recomp", "ctx-dream"]]); // Repeated renders must not stack duplicate layers. appClaim.render({}); expect(fixture.layers).toHaveLength(1); diff --git a/packages/plugin/src/v2/tui/index.ts b/packages/plugin/src/v2/tui/index.ts index 32f9133a9..5157f6f98 100644 --- a/packages/plugin/src/v2/tui/index.ts +++ b/packages/plugin/src/v2/tui/index.ts @@ -8,6 +8,7 @@ import { initRpcClient, loadSidebarSnapshot, loadStatusDetail, + requestDream, requestRecomp, } from "../../tui/data/context-db"; import { @@ -182,6 +183,22 @@ export async function setupWithJsx(context: V2TuiContext, jsx: JsxFactory): Prom return requested; }; + const showDream = async (task?: string) => { + const target = currentSessionID(context); + if (!target) { + context.ui.toast.show({ message: "No active session", variant: "warning" }); + return false; + } + const started = await requestDream(target, task); + context.ui.toast.show({ + message: started + ? "Dream run started; the summary appears when it finishes" + : "Dream request failed", + variant: started ? "info" : "error", + }); + return started; + }; + const unregisterSlot = context.ui.slot({ append: "sidebar.content", render: ({ sessionID }) => { @@ -190,12 +207,13 @@ export async function setupWithJsx(context: V2TuiContext, jsx: JsxFactory): Prom }, }); - // The keymap layer owns /ctx-status + /ctx-recomp. OpenCode 2 runs plugin - // setup() outside the TUI component tree, where context.keymap.layer() - // throws "Keymap.Provider is missing" (the provider is a Solid context). - // Try the direct call first (hosts that do run setup in-tree), then fall - // back to the app slot: its render executes inside the component tree, the - // same place the host's own built-in plugins register their layers. + // The keymap layer owns /ctx-status + /ctx-recomp + /ctx-dream. OpenCode 2 + // runs plugin setup() outside the TUI component tree, where + // context.keymap.layer() throws "Keymap.Provider is missing" (the provider is + // a Solid context). Try the direct call first (hosts that do run setup + // in-tree), then fall back to the app slot: its render executes inside the + // component tree, the same place the host's own built-in plugins register + // their layers. const buildKeymapLayer = (): V2KeymapLayer => ({ mode: "global", commands: [ @@ -219,6 +237,16 @@ export async function setupWithJsx(context: V2TuiContext, jsx: JsxFactory): Prom await showRecomp(); }, }, + { + id: "magic-context.dream", + title: "Magic Context: Dream", + group: "Magic Context", + palette: true, + slash: { name: "ctx-dream", arguments: true }, + run: async (input) => { + await showDream(input?.trim() || undefined); + }, + }, ], }); let keymapLayerRegistered = false; @@ -249,7 +277,7 @@ export async function setupWithJsx(context: V2TuiContext, jsx: JsxFactory): Prom if (!registered && !keymapGapLogged) { keymapGapLogged = true; console.warn( - "[magic-context] OpenCode 2 keymap.layer is unavailable; /ctx-status and /ctx-recomp were not registered", + "[magic-context] OpenCode 2 keymap.layer is unavailable; /ctx-status, /ctx-recomp and /ctx-dream were not registered", ); } return null; From 2c5c9448d85bb7f7e2feaa039e0cb3a0d6288d6e Mon Sep 17 00:00:00 2001 From: astrid Date: Sat, 19 Sep 2026 22:36:36 -0400 Subject: [PATCH 08/13] fix(opencode2): address the round-one v2 lane review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six fixes from the review (no blocking findings): - Pressure math and the persisted percentage now resolve the output-reserved usable window via resolveContextLimit — the same denominator the sidebar, history budgets and geometry use. The raw catalog window previously made the sidebar show 1.36% next to 13,569/934,464. - EmergencyFailClosedError / FailClosedBlockingError from the transform are treated as blocking (interrupt + refusal) instead of being swallowed with a warning and sending the unmodified oversized prompt; compaction-off stays inert, matching the v1 wrapper. - The measured-usage handoff is keyed by session, attributes the reading to the assistant row's own model, and skips the post-pass re-apply when that model differs from the pass's — a model switch must not resurrect stale per-model usage. - findLastAssistantModelFromOpenCodeDb gates its v2 query on isOpenCodeV2Store (1.18 also ships session_message), guards each query individually, and falls back to the v1 table; tests pin the 1.18 store shape. - Recomp/historian can use the lane's hidden-completion executor through ManagedRecompContext (v1 path unchanged), so a host without an SDK client no longer toasts success while the runner throws "Hidden completion client is unavailable". The upgrade's SDK-only memory migration is skipped when no client exists. - The model-limit warm retries after a failed attempt instead of latching forever (retry per pass until the cache holds entries), and tokens.cache reads are guarded for partial rows. Verified: full suite 4950 pass, typecheck clean; an isolated run persists percentage 1.4511 = 13,560/934,464 (was 1.356 = /1,000,000). --- .../magic-context/read-session-db.test.ts | 37 ++++++ .../hooks/magic-context/read-session-db.ts | 115 ++++++++++------- .../magic-context/recomp-orchestrator.ts | 8 +- packages/plugin/src/plugin/rpc-handlers.ts | 12 +- packages/plugin/src/v2/hooks/context.ts | 120 +++++++++++++----- .../src/v2/hooks/model-limit-cache.test.ts | 54 +++++++- .../plugin/src/v2/hooks/model-limit-cache.ts | 28 +++- 7 files changed, 293 insertions(+), 81 deletions(-) diff --git a/packages/plugin/src/hooks/magic-context/read-session-db.test.ts b/packages/plugin/src/hooks/magic-context/read-session-db.test.ts index 6b5559b30..4190eb3d7 100644 --- a/packages/plugin/src/hooks/magic-context/read-session-db.test.ts +++ b/packages/plugin/src/hooks/magic-context/read-session-db.test.ts @@ -607,6 +607,7 @@ function insertV2SessionMessages( model?: { providerID?: string; id?: string }; agent?: string; }>, + options: { v2Marker?: boolean } = {}, ): void { const dbPath = join(process.env.XDG_DATA_HOME!, "opencode", "opencode.db"); mkdirSync(dirname(dbPath), { recursive: true }); @@ -632,7 +633,11 @@ function insertV2SessionMessages( time_updated INTEGER NOT NULL, data TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS session_v2 ( + id TEXT PRIMARY KEY + ); `); + if (options.v2Marker === false) db.exec("DROP TABLE IF EXISTS session_v2"); const insert = db.prepare( `INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?, ?)`, @@ -726,6 +731,38 @@ describe("findLastAssistantModelFromOpenCodeDb", () => { }); }); + it("does not consult the v2 table on a 1.18 store that also ships session_message", () => { + useTempDataHome("read-session-db-v1-session-message-"); + createOpenCodeDb([ + { + id: "msg_live", + sessionId: "ses_A", + role: "assistant", + providerID: "anthropic", + modelID: "claude-opus-4-7", + timeCreated: 2000, + }, + ]); + // 1.18 stores ship session_message but no session_v2 marker; a stale + // v2-shaped row must not win over the live v1 row. + insertV2SessionMessages( + [ + { + id: "sms_stale", + sessionId: "ses_A", + type: "assistant", + seq: 9, + model: { providerID: "stale", id: "stale-model" }, + }, + ], + { v2Marker: false }, + ); + expect(findLastAssistantModelFromOpenCodeDb("ses_A")).toEqual({ + providerID: "anthropic", + modelID: "claude-opus-4-7", + }); + }); + it("returns null for a session with no assistant messages", () => { useTempDataHome("read-session-db-no-assistant-"); createOpenCodeDb([ diff --git a/packages/plugin/src/hooks/magic-context/read-session-db.ts b/packages/plugin/src/hooks/magic-context/read-session-db.ts index 3fd721c2b..ba4e1da04 100644 --- a/packages/plugin/src/hooks/magic-context/read-session-db.ts +++ b/packages/plugin/src/hooks/magic-context/read-session-db.ts @@ -591,55 +591,82 @@ export function getMessageTimesFromOpenCodeDb( return result; } +function isV2SessionMessageStore(db: Database): boolean { + try { + const names = new Set( + ( + db + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('message', 'part', 'session_message', 'session_v2')", + ) + .all() as Array<{ name?: unknown }> + ).flatMap((row) => (typeof row.name === "string" ? [row.name] : [])), + ); + if (!names.has("session_message")) return false; + // session_v2 is written only by an OpenCode 2 host; a native v2 store has + // no v1 message tables at all. OpenCode 1.18 ships session_message beside + // message+part, so those tables must not be mistaken for a v2 store. + if (names.has("session_v2")) return true; + return !(names.has("message") && names.has("part")); + } catch { + return false; + } +} + export function findLastAssistantModelFromOpenCodeDb( sessionId: string, ): { providerID: string; modelID: string; agent?: string } | null { try { return withReadOnlySessionDb((db) => { - // A v2 host writes assistant models on `session_message`. On a migrated - // store the frozen v1 `message` table still exists but holds only - // pre-migration rows, so prefer the v2 table and fall back to v1 only - // when v2 has nothing for this session (a legacy session untouched - // since the migration). - const hasV2Messages = Boolean( - db - .prepare( - "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'session_message' LIMIT 1", - ) - .get(), - ); - const v2Row = hasV2Messages - ? (db - .prepare( - `SELECT json_extract(data, '$.model.providerID') as providerID, - json_extract(data, '$.model.id') as modelID, - json_extract(data, '$.agent') as agent - FROM session_message - WHERE session_id = ? - AND type = 'assistant' - AND json_extract(data, '$.model.providerID') IS NOT NULL - AND json_extract(data, '$.model.id') IS NOT NULL - ORDER BY seq DESC - LIMIT 1`, - ) - .get(sessionId) as (AssistantModelRow & { agent?: string | null }) | null) - : null; - const row = - v2Row ?? - (db - .prepare( - `SELECT json_extract(data, '$.providerID') as providerID, - json_extract(data, '$.modelID') as modelID, - json_extract(data, '$.agent') as agent - FROM message - WHERE session_id = ? - AND json_extract(data, '$.role') = 'assistant' - AND json_extract(data, '$.providerID') IS NOT NULL - AND json_extract(data, '$.modelID') IS NOT NULL - ORDER BY time_created DESC - LIMIT 1`, - ) - .get(sessionId) as (AssistantModelRow & { agent?: string | null }) | null); + // An OpenCode 1.18 store also ships a `session_message` table (pinned in + // opencode-db-path tests), so its mere presence is NOT a v2 signal. Only + // an OpenCode 2 store (native, or a v1 store it migrated) is queried as + // v2; each query is individually guarded so a foreign shape falls through + // to the other generation instead of aborting the whole lookup. + const queryV2 = (): (AssistantModelRow & { agent?: string | null }) | null => { + try { + return db + .prepare( + `SELECT json_extract(data, '$.model.providerID') as providerID, + json_extract(data, '$.model.id') as modelID, + json_extract(data, '$.agent') as agent + FROM session_message + WHERE session_id = ? + AND type = 'assistant' + AND json_extract(data, '$.model.providerID') IS NOT NULL + AND json_extract(data, '$.model.id') IS NOT NULL + ORDER BY seq DESC + LIMIT 1`, + ) + .get(sessionId) as (AssistantModelRow & { agent?: string | null }) | null; + } catch { + return null; + } + }; + const queryV1 = (): (AssistantModelRow & { agent?: string | null }) | null => { + try { + return db + .prepare( + `SELECT json_extract(data, '$.providerID') as providerID, + json_extract(data, '$.modelID') as modelID, + json_extract(data, '$.agent') as agent + FROM message + WHERE session_id = ? + AND json_extract(data, '$.role') = 'assistant' + AND json_extract(data, '$.providerID') IS NOT NULL + AND json_extract(data, '$.modelID') IS NOT NULL + ORDER BY time_created DESC + LIMIT 1`, + ) + .get(sessionId) as (AssistantModelRow & { agent?: string | null }) | null; + } catch { + return null; + } + }; + // The frozen v1 `message` table on a migrated store holds only + // pre-migration rows, so a v2 store prefers its own table and falls back + // to v1 only for a legacy session untouched since the migration. + const row = (isV2SessionMessageStore(db) ? queryV2() : null) ?? queryV1(); if (!row || typeof row.providerID !== "string" || typeof row.modelID !== "string") { return null; } diff --git a/packages/plugin/src/hooks/magic-context/recomp-orchestrator.ts b/packages/plugin/src/hooks/magic-context/recomp-orchestrator.ts index 2194fd273..dcad2791c 100644 --- a/packages/plugin/src/hooks/magic-context/recomp-orchestrator.ts +++ b/packages/plugin/src/hooks/magic-context/recomp-orchestrator.ts @@ -21,7 +21,7 @@ import { executeContextRecompWithResult, type PartialRecompRange, } from "./compartment-runner"; -import type { RecompProgress } from "./compartment-runner-types"; +import type { HiddenCompletionExecutor, RecompProgress } from "./compartment-runner-types"; import type { LiveSessionState } from "./live-session-state"; import { dropSlot } from "./lkg-slot"; import type { NotificationParams } from "./send-session-notification"; @@ -59,6 +59,11 @@ function resolveLiveModelKey( * hook config. */ export interface ManagedRecompContext { client: PluginContext["client"]; + /** + * Executor seam for hosts without an SDK client (OpenCode 2): the recomp / + * historian runner uses it instead of building the v1 client-backed executor. + */ + hiddenCompletionExecutor?: HiddenCompletionExecutor; db: Database; liveSessionState: LiveSessionState; /** Plugin-startup directory — last-resort fallback for session-dir resolution. */ @@ -234,6 +239,7 @@ export function setRecompTerminal( function buildRecompDeps(ctx: ManagedRecompContext, sessionId: string) { return { client: ctx.client, + hiddenCompletionExecutor: ctx.hiddenCompletionExecutor, db: ctx.db, sessionId, historianChunkTokens: ctx.historianChunkTokens, diff --git a/packages/plugin/src/plugin/rpc-handlers.ts b/packages/plugin/src/plugin/rpc-handlers.ts index 94814fb21..3a7e2aecf 100644 --- a/packages/plugin/src/plugin/rpc-handlers.ts +++ b/packages/plugin/src/plugin/rpc-handlers.ts @@ -43,6 +43,7 @@ import { emptyWorkMetricsCarry, type WorkMetricsCarry, } from "../features/magic-context/work-metrics"; +import type { HiddenCompletionExecutor } from "../hooks/magic-context/compartment-runner-types"; import { getEmbedDrainUiStatus } from "../hooks/magic-context/embed-session-state"; import { resolveContextLimit, @@ -1299,6 +1300,11 @@ export function registerRpcHandlers( client: unknown; liveSessionState: LiveSessionState; rustModeModuleClient?: RustModeModuleClient; + /** + * Hosts without an SDK client (OpenCode 2) hand the recomp/historian + * runner their own completion executor through this seam. + */ + hiddenCompletionExecutor?: HiddenCompletionExecutor; storageDir?: string; getDebugMemoryHolders?: () => RuntimeDebugMemoryHolders | undefined; }, @@ -1437,6 +1443,7 @@ export function registerRpcHandlers( const historianModel = resolveHistorianModel(config, "opencode"); return { client: args.client as ManagedRecompContext["client"], + hiddenCompletionExecutor: args.hiddenCompletionExecutor, db, liveSessionState, directory, @@ -1448,7 +1455,10 @@ export function registerRpcHandlers( autoPromote: config.memory?.auto_promote ?? true, historianModel: historianModel.primary, fallbackModels: historianModel.fallbacks, - runMigration: config.memory?.enabled !== false && !!historianModel.primary?.model, + runMigration: + args.client !== undefined && + config.memory?.enabled !== false && + !!historianModel.primary?.model, userMemoriesEnabled: userMemoryCollectionEnabled(config.dreamer), historianTwoPass: config.historian?.two_pass === true, getNotificationParams, diff --git a/packages/plugin/src/v2/hooks/context.ts b/packages/plugin/src/v2/hooks/context.ts index 5df16c184..e76d8e14c 100644 --- a/packages/plugin/src/v2/hooks/context.ts +++ b/packages/plugin/src/v2/hooks/context.ts @@ -3,6 +3,7 @@ import { loadPluginConfigDetailed } from "../../config"; import { isCompactionEnabled } from "../../config/agent-disable"; import { getProtectedTokensTierOverrides } from "../../config/project-security"; import { summarizeManualDream } from "../../features/magic-context/dreamer/manual-summary"; +import { isFailClosedBlockingError } from "../../features/magic-context/fail-closed-block"; import { resolveProjectIdentity } from "../../features/magic-context/memory/project-identity"; import { createScheduler } from "../../features/magic-context/scheduler"; import { @@ -13,6 +14,8 @@ import { } from "../../features/magic-context/storage"; import { createTagger } from "../../features/magic-context/tagger"; import { assertExecutableToolInput } from "../../hooks/magic-context/dropped-input-guard"; +import { EmergencyFailClosedError } from "../../hooks/magic-context/emergency-fail-closed"; +import { resolveContextLimit } from "../../hooks/magic-context/event-resolvers"; import { createChatMessageHook, createToolExecuteAfterHook, @@ -51,7 +54,7 @@ import { deliverPendingChannel2, isAdmittedSynthetic } from "./channel2"; import { resolveManualDreamTask, runManualDreamNow } from "./dream-manual"; import { startDreamTrigger } from "./dream-trigger"; import { HiddenChildHook, registerHiddenChildAgents } from "./hidden-child"; -import { warmModelLimitCacheFromCatalog } from "./model-limit-cache"; +import { modelLimitCacheWarm, warmModelLimitCacheFromCatalog } from "./model-limit-cache"; import { adaptPayload, HEAD_IDS } from "./payload"; import { interruptBeforeProvider, V2ContextRefusal } from "./refusal"; import { rawMessages } from "./store"; @@ -192,8 +195,6 @@ export async function registerContext(context: V2Context) { return; } const folds = new FoldOwner(context.storage); - const limits = new Map(); - const queriedModels = new Set(); // Draft-authoritative model/variant/agent. Not the v1 event-driven map. const liveModels: NonNullable = new Map(); const promptSurfaceRuntime = createPromptSurfaceRuntime({ @@ -292,6 +293,11 @@ export async function registerContext(context: V2Context) { sessionID: toolContext.sessionID, messageID: toolContext.messageID, agent: toolContext.agent, + // The v2 Tool.Context carries no directory, so the + // plugin's launch directory is the closest scope + // available. A session launched from outside the + // project can therefore resolve a different project + // identity than its own cwd (v1 uses toolContext.directory). directory, worktree: directory, abort: new AbortController().signal, @@ -325,14 +331,20 @@ export async function registerContext(context: V2Context) { getCount: (sessionID: string) => read(sessionID).length, }); let transform: ReturnType | undefined; - // Usage measured from the most recent assistant response. The transform's - // first-pass reset zeroes the persisted usage fields mid-pass, so the same - // values are re-applied after the pass — the v1 lane's event handler writes - // after the pass too, which is why its sidebar never shows the reset. - let measuredUsage: - | { inputTokens: number; limit: number; modelKey: string; completed?: number } - | undefined; - const usageMetaPatch = (value: NonNullable) => ({ + type MeasuredUsage = { + inputTokens: number; + limit: number; + modelKey: string; + completed?: number; + }; + // Usage measured from the most recent assistant response, keyed by session so + // sibling sessions in the same project cannot overwrite each other's reading. + // The transform's first-pass reset zeroes the persisted usage fields mid-pass, + // so the same values are re-applied after the pass — the v1 lane's event + // handler writes after the pass too, which is why its sidebar never shows the + // reset. + const measuredUsageBySession = new Map(); + const usageMetaPatch = (value: MeasuredUsage) => ({ ...(value.completed !== undefined ? { lastResponseTime: value.completed } : {}), lastContextPercentage: (value.inputTokens / value.limit) * 100, lastInputTokens: value.inputTokens, @@ -354,28 +366,43 @@ export async function registerContext(context: V2Context) { .filter((row) => row.type === "assistant") .at(-1); const tokens = latest?.data.tokens; - const modelKey = `${draft.model.providerID}/${draft.model.id}`; - if (!queriedModels.has(modelKey)) { - const catalog = await Promise.resolve(context.model.list()); - for (const model of catalogModels(catalog)) - limits.set(`${model.providerID}/${model.id}`, model.limit.context); - queriedModels.add(modelKey); - } - const limit = limits.get(modelKey); - if (tokens && limit && Number.isFinite(limit) && limit > 0) { - const inputTokens = tokens.input + tokens.cache.read + tokens.cache.write; + // Attribute the reading to the model that produced the response (the + // store row carries it) so a model switch cannot re-attribute the old + // response's tokens to the new model. Falls back to the draft model. + const rowModel = latest?.data.model; + const measuredProviderID = + typeof rowModel?.providerID === "string" + ? rowModel.providerID + : draft.model.providerID; + const measuredModelID = + typeof rowModel?.id === "string" ? rowModel.id : draft.model.id; + const modelKey = `${measuredProviderID}/${measuredModelID}`; + // Resolve the same output-reserved usable window every other consumer + // (sidebar, history budgets, geometry) divides by. Reading the raw + // catalog window here made the persisted percentage and the 95% check + // disagree with the same sidebar's denominator. + const limit = resolveContextLimit(measuredProviderID, measuredModelID, { + db, + sessionID: draft.sessionID, + }); + if (tokens && Number.isFinite(limit) && limit > 0) { + const inputTokens = + (tokens.input ?? 0) + + (tokens.cache?.read ?? 0) + + (tokens.cache?.write ?? 0); // Native compaction owns the window when MC compaction is off. unsafe = compactionEnabled && inputTokens / limit >= 0.95; const completed = latest?.data.time?.completed; - measuredUsage = { + const measured: MeasuredUsage = { inputTokens, limit, modelKey, ...(typeof completed === "number" ? { completed } : {}), }; + measuredUsageBySession.set(draft.sessionID, measured); // Early write covers the abort path (returned before the // transform); the post-pass write below wins on normal turns. - updateSessionMeta(db, draft.sessionID, usageMetaPatch(measuredUsage)); + updateSessionMeta(db, draft.sessionID, usageMetaPatch(measured)); usage.set(draft.sessionID, { usage: { inputTokens, percentage: (inputTokens / limit) * 100 }, hasUsageTokens: true, @@ -457,6 +484,11 @@ export async function registerContext(context: V2Context) { }); variants.set(draft.sessionID, draft.model.variant); agents.set(draft.sessionID, draft.agent); + if (!modelLimitCacheWarm()) { + // The boot warm can fail while the host catalog is still starting up; + // retry once per pass until the shared cache actually holds entries. + void warmModelLimitCacheFromCatalog(context); + } applyV2PromptSurfaceTools(draft, promptSurfaceRuntime, config.prompt_surface); if (context.tool.transform) { const modelKey = `${draft.model.providerID}/${draft.model.id}`; @@ -635,9 +667,16 @@ export async function registerContext(context: V2Context) { } // Re-apply the usage fields the transform's first-pass reset zeroed // mid-pass (the v1 lane's event handler writes after the pass too, which - // is why its sidebar never shows the reset). - if (db && measuredUsage) { - updateSessionMeta(db, draft.sessionID, usageMetaPatch(measuredUsage)); + // is why its sidebar never shows the reset). Skip when the response came + // from another model: a model switch deliberately clears the stale + // per-model usage that the transform just reset. + if (db) { + const measured = measuredUsageBySession.get(draft.sessionID); + measuredUsageBySession.delete(draft.sessionID); + const passModelKey = `${draft.model.providerID}/${draft.model.id}`; + if (measured && measured.modelKey === passModelKey) { + updateSessionMeta(db, draft.sessionID, usageMetaPatch(measured)); + } } if (checkpoint && submitted !== undefined) { const head = draft.messages.find((message) => message.id === HEAD_IDS[0]); @@ -665,15 +704,32 @@ export async function registerContext(context: V2Context) { } } catch (error) { if (error instanceof V2ContextRefusal) throw error; - if (postFold) { + if (error instanceof EmergencyFailClosedError || isFailClosedBlockingError(error)) { + // Intentional loud aborts from the transform. The v2 lane has no SDK + // client to drive the emergency notification, but the turn must still + // be refused rather than sending the unmodified oversized prompt. + // Compaction-off is inert (native compaction owns the window), + // matching the v1 wrapper's behavior. + if (compactionEnabled) { + await interruptBeforeProvider(context.session, draft.sessionID); + throw new V2ContextRefusal("Magic Context refused to send an unsafe prompt.", { + cause: error, + }); + } + console.warn( + "[magic-context] compaction-off: fail-closed inert, passing through", + error, + ); + } else if (postFold) { await interruptBeforeProvider(context.session, draft.sessionID); throw new V2ContextRefusal( "Magic Context could not restore the unarchived host history.", { cause: error }, ); + } else { + // Another plugin can poison the shared draft. Do not fail an otherwise viable turn. + console.warn("[magic-context] v2 context unavailable", error); } - // Another plugin can poison the shared draft. Do not fail an otherwise viable turn. - console.warn("[magic-context] v2 context unavailable", error); } }); // The v1 lane warms MC's model-limit cache from its SDK client at boot; the @@ -704,11 +760,13 @@ export async function registerContext(context: V2Context) { registerRpcHandlers(rpcServer, { directory, config, - // The v2 host context exposes no SDK client, so the recomp/upgrade - // notify paths stay inert; the read-only snapshot handlers need none. + // The v2 host context exposes no SDK client, so the notify paths stay + // inert; the recomp/historian runner reaches the lane's own completion + // executor through the shared seam instead. client: undefined, liveSessionState: rpcLiveSessionState, rustModeModuleClient: undefined, + hiddenCompletionExecutor, storageDir, }); // Manual /ctx-dream: the v1 lane runs it through its OpenCode command diff --git a/packages/plugin/src/v2/hooks/model-limit-cache.test.ts b/packages/plugin/src/v2/hooks/model-limit-cache.test.ts index 802b1c848..0b5c6e00c 100644 --- a/packages/plugin/src/v2/hooks/model-limit-cache.test.ts +++ b/packages/plugin/src/v2/hooks/model-limit-cache.test.ts @@ -1,5 +1,26 @@ import { expect, test } from "bun:test"; -import { catalogProvidersPayload } from "./model-limit-cache"; +import { clearModelsDevCache } from "../../shared/models-dev-cache"; +import { + catalogProvidersPayload, + modelLimitCacheWarm, + resetModelLimitCacheWarmForTest, + warmModelLimitCacheFromCatalog, +} from "./model-limit-cache"; +import type { V2Context } from "./types"; + +function catalogContext( + models: Array>, + counter?: { calls: number }, +): V2Context { + return { + model: { + list: () => { + if (counter) counter.calls += 1; + return models; + }, + }, + } as unknown as V2Context; +} test("groups raw catalog rows by provider and keeps their metadata", () => { const payload = catalogProvidersPayload([ @@ -54,3 +75,34 @@ test("returns an empty payload for unusable input", () => { expect(catalogProvidersPayload(null)).toEqual([]); expect(catalogProvidersPayload({})).toEqual([]); }); + +test("retries the warm after a failed attempt", async () => { + clearModelsDevCache(); + resetModelLimitCacheWarmForTest(); + const counter = { calls: 0 }; + const context = catalogContext([], counter); + await warmModelLimitCacheFromCatalog(context, { retries: 0, retryDelayMs: 0 }); + expect(modelLimitCacheWarm()).toBe(false); + await warmModelLimitCacheFromCatalog(context, { retries: 0, retryDelayMs: 0 }); + expect(counter.calls).toBe(2); + clearModelsDevCache(); + resetModelLimitCacheWarmForTest(); +}); + +test("latches once the cache holds entries", async () => { + clearModelsDevCache(); + resetModelLimitCacheWarmForTest(); + await warmModelLimitCacheFromCatalog( + catalogContext([{ id: "m", providerID: "p", limit: { context: 200_000 } }]), + { retries: 0, retryDelayMs: 0 }, + ); + expect(modelLimitCacheWarm()).toBe(true); + const counter = { calls: 0 }; + await warmModelLimitCacheFromCatalog( + catalogContext([{ id: "other", providerID: "q", limit: { context: 200_000 } }], counter), + { retries: 0, retryDelayMs: 0 }, + ); + expect(counter.calls).toBe(0); + clearModelsDevCache(); + resetModelLimitCacheWarmForTest(); +}); diff --git a/packages/plugin/src/v2/hooks/model-limit-cache.ts b/packages/plugin/src/v2/hooks/model-limit-cache.ts index f446ecd25..dde0cd871 100644 --- a/packages/plugin/src/v2/hooks/model-limit-cache.ts +++ b/packages/plugin/src/v2/hooks/model-limit-cache.ts @@ -1,11 +1,22 @@ import { getErrorMessage } from "../../shared/error-message"; import { sessionLog } from "../../shared/logger"; -import { refreshModelLimitsFromApi } from "../../shared/models-dev-cache"; +import { getModelsDevCacheState, refreshModelLimitsFromApi } from "../../shared/models-dev-cache"; import type { V2Context } from "./types"; /** Once-per-process latch: the model-limit cache is process-global. */ let warmStarted = false; +/** True once the shared cache holds model entries (persisted seed or fresh warm). */ +export function modelLimitCacheWarm(): boolean { + const state = getModelsDevCacheState(); + return state.apiLoaded && state.apiCount > 0; +} + +/** Test-only: clear the once-per-process latch. */ +export function resetModelLimitCacheWarmForTest(): void { + warmStarted = false; +} + /** * Build the `config.providers()` payload `refreshModelLimitsFromApi` consumes * from the v2 host's own model catalog. Each raw catalog row is passed through @@ -44,7 +55,10 @@ export function catalogProvidersPayload(listed: unknown): Array<{ * catalog the host itself uses, so feeding it through the shared refresh keeps * one source of truth and persists a last-known-good file for cold starts. */ -export async function warmModelLimitCacheFromCatalog(context: V2Context): Promise { +export async function warmModelLimitCacheFromCatalog( + context: V2Context, + options: { retries?: number; retryDelayMs?: number } = {}, +): Promise { if (warmStarted) return; warmStarted = true; try { @@ -60,9 +74,17 @@ export async function warmModelLimitCacheFromCatalog(context: V2Context): Promis }), }, }, - { retries: 3, retryDelayMs: 1000 }, + { + retries: options.retries ?? 3, + retryDelayMs: options.retryDelayMs ?? 1000, + }, ); } catch (error) { sessionLog("global", `v2 model-limit cache warm failed: ${getErrorMessage(error)}`); + } finally { + // Latch only while the cache actually holds entries: a warm that failed + // because the host catalog was not ready yet must retry on a later turn + // (v2 has no after-auth re-warm like the v1 event handler). + if (!modelLimitCacheWarm()) warmStarted = false; } } From 58fbe49401066b09fddbbcbb11dcda1ff4e922ea Mon Sep 17 00:00:00 2001 From: astrid Date: Sat, 19 Sep 2026 22:53:04 -0400 Subject: [PATCH 09/13] fix(opencode2): address the round-two v2 lane review findings Round two (frozen PR worktree) reported no blocking findings; these close the three worth-fixing items and four nits: - The e2e host-contract scan no longer trips on Map/Set `.delete(` (anchored on `session.` lifecycle calls) and allows the deliberate fresh createLiveSessionState() the RPC-server fix needs. The opencode2 contract file passes 10/10; the rest of that suite fails environmentally here (macOS opens /Library/Preferences/Logging paths the hermetic runner forbids). - hidden-completion.ts guards tokens.cache reads, matching the context.ts fix: a partial usage row no longer aborts the whole hidden (historian/dreamer) run. - refuseIfUnsafe uses V2StoreReader.latestAssistant (indexed LIMIT 1) instead of paging the entire session history on every pass. - Usage attribution: a store row without model metadata records no model key and skips the post-pass re-apply, so a switch turn cannot resurrect stale tokens. - /ctx-dream: availability is checked before task validation (v1 messaging), the duplicate server-side "started" toast is gone, requiresTools tasks are reported as unsupported (no tool loop) instead of failed, and a missing context.tool.transform now logs instead of silently skipping registration. Verified: plugin suite 4953/0; e2e adapters-s2-contracts 10/10; typecheck clean. --- .../opencode2/adapters-s2-contracts.test.ts | 14 +++- packages/plugin/src/v2/hidden-completion.ts | 4 +- packages/plugin/src/v2/hooks/context.ts | 75 ++++++++++-------- .../plugin/src/v2/hooks/dream-manual.test.ts | 31 +++++++- packages/plugin/src/v2/hooks/dream-manual.ts | 76 +++++++++++++++---- 5 files changed, 148 insertions(+), 52 deletions(-) diff --git a/packages/e2e-tests/tests/opencode2/adapters-s2-contracts.test.ts b/packages/e2e-tests/tests/opencode2/adapters-s2-contracts.test.ts index 4443eddc5..422777f10 100644 --- a/packages/e2e-tests/tests/opencode2/adapters-s2-contracts.test.ts +++ b/packages/e2e-tests/tests/opencode2/adapters-s2-contracts.test.ts @@ -72,9 +72,19 @@ test("I14 sdk_renames: v2 supplies all four host seams, v1 defaults retain funct join(root, "packages/plugin/src/v2", file), "utf8", ); - expect(source).not.toMatch(/\.\s*(abort|delete|promptAsync)\s*\(/); + // Host session lifecycle calls are forbidden on the v2 lane. Anchoring on + // `session.` keeps Map/Set `.delete(` (e.g. measuredUsageBySession.delete) + // from tripping this guard. + expect(source).not.toMatch(/session\.(abort|delete|promptAsync)\s*\(/); expect(source).not.toMatch(/import\s+(?!type\b).*from\s+["']@opencode\//); - expect(source).not.toMatch(/live-session-state/); + if (file === "hooks/context.ts") { + // The RPC-server fix builds a FRESH LiveSessionState (createLiveSessionState + // is a factory) for the shared RPC handlers while keeping the lane's own + // draft-authoritative maps; importing the v1 state module is deliberate here. + expect(source).toMatch(/createLiveSessionState\(\)/); + } else { + expect(source).not.toMatch(/live-session-state/); + } } }); diff --git a/packages/plugin/src/v2/hidden-completion.ts b/packages/plugin/src/v2/hidden-completion.ts index 11f0f0d5b..8dc400011 100644 --- a/packages/plugin/src/v2/hidden-completion.ts +++ b/packages/plugin/src/v2/hidden-completion.ts @@ -619,8 +619,8 @@ export async function createV2HiddenCompletionExecutor( ? { input: tokens.input, output: tokens.output, - cacheRead: tokens.cache.read, - cacheWrite: tokens.cache.write, + cacheRead: tokens.cache?.read ?? 0, + cacheWrite: tokens.cache?.write ?? 0, } : meter(system, promptText(request), text ?? ""), lengthCapped: ["length", "max_tokens"].includes(row.data.finish ?? ""), diff --git a/packages/plugin/src/v2/hooks/context.ts b/packages/plugin/src/v2/hooks/context.ts index e76d8e14c..afc51e5f3 100644 --- a/packages/plugin/src/v2/hooks/context.ts +++ b/packages/plugin/src/v2/hooks/context.ts @@ -312,6 +312,10 @@ export async function registerContext(context: V2Context) { } catch (error) { console.warn("[magic-context] v2 ctx_* tool registration skipped", error); } + } else if (registryEntries.length > 0) { + console.warn( + "[magic-context] v2 host exposes no tool.transform; ctx_* tools were not registered", + ); } const read = (sessionID: string) => { const reader = new V2StoreReader( @@ -334,7 +338,8 @@ export async function registerContext(context: V2Context) { type MeasuredUsage = { inputTokens: number; limit: number; - modelKey: string; + /** Absent when the store row carries no model metadata (legacy rows). */ + modelKey?: string; completed?: number; }; // Usage measured from the most recent assistant response, keyed by session so @@ -349,7 +354,7 @@ export async function registerContext(context: V2Context) { lastContextPercentage: (value.inputTokens / value.limit) * 100, lastInputTokens: value.inputTokens, lastUsageContextLimit: value.limit, - lastObservedModelKey: value.modelKey, + ...(value.modelKey !== undefined ? { lastObservedModelKey: value.modelKey } : {}), }); const refuseIfUnsafe = async (draft: SessionContext): Promise => { let unsafe = false; @@ -361,22 +366,22 @@ export async function registerContext(context: V2Context) { gaDatabasePath(getDataDir(), process.env.OPENCODE_CHANNEL ?? "latest"), ); try { - const latest = reader - .history(draft.sessionID) - .filter((row) => row.type === "assistant") - .at(-1); + const latest = reader.latestAssistant(draft.sessionID); const tokens = latest?.data.tokens; // Attribute the reading to the model that produced the response (the - // store row carries it) so a model switch cannot re-attribute the old - // response's tokens to the new model. Falls back to the draft model. + // store row carries it). A row without model metadata is + // indistinguishable from a model switch, so it records no model key + // and the post-pass re-apply is skipped for it. const rowModel = latest?.data.model; - const measuredProviderID = - typeof rowModel?.providerID === "string" - ? rowModel.providerID - : draft.model.providerID; - const measuredModelID = - typeof rowModel?.id === "string" ? rowModel.id : draft.model.id; - const modelKey = `${measuredProviderID}/${measuredModelID}`; + const rowProviderID = + typeof rowModel?.providerID === "string" ? rowModel.providerID : undefined; + const rowModelID = typeof rowModel?.id === "string" ? rowModel.id : undefined; + const measuredProviderID = rowProviderID ?? draft.model.providerID; + const measuredModelID = rowModelID ?? draft.model.id; + const modelKey = + rowProviderID !== undefined && rowModelID !== undefined + ? `${measuredProviderID}/${measuredModelID}` + : undefined; // Resolve the same output-reserved usable window every other consumer // (sidebar, history budgets, geometry) divides by. Reading the raw // catalog window here made the persisted percentage and the 95% check @@ -674,7 +679,11 @@ export async function registerContext(context: V2Context) { const measured = measuredUsageBySession.get(draft.sessionID); measuredUsageBySession.delete(draft.sessionID); const passModelKey = `${draft.model.providerID}/${draft.model.id}`; - if (measured && measured.modelKey === passModelKey) { + if ( + measured && + measured.modelKey !== undefined && + measured.modelKey === passModelKey + ) { updateSessionMeta(db, draft.sessionID, usageMetaPatch(measured)); } } @@ -778,11 +787,8 @@ export async function registerContext(context: V2Context) { rpcServer.handle("dream", async (params) => { const sessionId = String(params.sessionId ?? ""); if (!sessionId) return { ok: false, error: "no session" }; - const requested = resolveManualDreamTask(params.task); - if (requested.error) { - pushNotification("toast", { message: requested.error, variant: "warning" }, sessionId); - return { ok: false, error: requested.error }; - } + // Availability first, matching the v1 command's messaging: with dreaming + // disabled, any argument reports "not configured" rather than task errors. if (!manualDreamer || !hiddenCompletionExecutor) { pushNotification( "toast", @@ -791,6 +797,11 @@ export async function registerContext(context: V2Context) { ); return { ok: false, error: "dreamer unavailable" }; } + const requested = resolveManualDreamTask(params.task); + if (requested.error) { + pushNotification("toast", { message: requested.error, variant: "warning" }, sessionId); + return { ok: false, error: requested.error }; + } db ??= openDatabase(); if (!db || !isDatabasePersisted(db)) { pushNotification( @@ -805,14 +816,8 @@ export async function registerContext(context: V2Context) { } const runDb = db; const runExecutor = hiddenCompletionExecutor; - pushNotification( - "toast", - { - message: "Dream run started; the summary appears when it finishes.", - variant: "info", - }, - sessionId, - ); + // The TUI toasts on `{ok:true}`; only the completion dialog is pushed here + // so the command does not produce two "started" messages. void runManualDreamNow({ db: runDb, dreamer: manualDreamer, @@ -824,13 +829,21 @@ export async function registerContext(context: V2Context) { sessionId, ...(requested.task !== undefined ? { task: requested.task } : {}), }) - .then((summary) => { + .then(({ summary, unsupportedTasks }) => { + const message = [ + summarizeManualDream(summary), + unsupportedTasks.length > 0 + ? `Unsupported on this host (no tool loop): ${unsupportedTasks.join(", ")}` + : undefined, + ] + .filter((line) => line !== undefined) + .join("\n\n"); pushNotification( "action", { action: "show-result-dialog", title: "Magic Context dream run", - message: summarizeManualDream(summary), + message, }, sessionId, ); diff --git a/packages/plugin/src/v2/hooks/dream-manual.test.ts b/packages/plugin/src/v2/hooks/dream-manual.test.ts index 0b6d6a420..e14e0ac14 100644 --- a/packages/plugin/src/v2/hooks/dream-manual.test.ts +++ b/packages/plugin/src/v2/hooks/dream-manual.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import { summarizeManualDream } from "../../features/magic-context/dreamer/manual-summary"; -import { resolveManualDreamTask } from "./dream-manual"; +import type { DreamTaskRuntimeConfig } from "../../features/magic-context/dreamer/task-scheduler"; +import { resolveManualDreamTask, selectRunnableDreamTasks } from "./dream-manual"; test("no argument runs every enabled task", () => { expect(resolveManualDreamTask(undefined)).toEqual({}); @@ -54,3 +55,31 @@ test("summarizes an idle run", () => { }); expect(message).toContain("No enabled dream tasks to run."); }); + +test("selectRunnableDreamTasks reports requiresTools tasks as unsupported without a tool loop", () => { + const tasks = [ + { task: "verify", schedule: "0 3 * * *" }, + { task: "classify-memories", schedule: "0 3 * * *" }, + { task: "curate", schedule: "" }, + ] as DreamTaskRuntimeConfig[]; + const selection = selectRunnableDreamTasks({ tasks, toolsSupported: false }); + expect(selection.unsupported).toEqual(["verify"]); + // curate requires tools too, so it is dropped from the runnable set even + // though it is not enabled (schedule ""). + expect(selection.runnable.map((config) => config.task)).toEqual(["classify-memories"]); +}); + +test("selectRunnableDreamTasks keeps every task when the host has a tool loop", () => { + const tasks = [{ task: "verify", schedule: "0 3 * * *" }] as DreamTaskRuntimeConfig[]; + expect(selectRunnableDreamTasks({ tasks, toolsSupported: true })).toEqual({ + runnable: tasks, + unsupported: [], + }); +}); + +test("an explicitly requested tool-requiring task is reported unsupported, not run", () => { + const tasks = [{ task: "verify", schedule: "" }] as DreamTaskRuntimeConfig[]; + expect( + selectRunnableDreamTasks({ tasks, toolsSupported: false, requestedTask: "verify" }), + ).toEqual({ runnable: [], unsupported: ["verify"] }); +}); diff --git a/packages/plugin/src/v2/hooks/dream-manual.ts b/packages/plugin/src/v2/hooks/dream-manual.ts index 9fdce56c5..eab3f1c9c 100644 --- a/packages/plugin/src/v2/hooks/dream-manual.ts +++ b/packages/plugin/src/v2/hooks/dream-manual.ts @@ -3,10 +3,12 @@ import { buildDreamTaskRuntimeConfigs } from "../../features/magic-context/dream import { createDreamTaskExecutor } from "../../features/magic-context/dreamer/task-executor"; import { CANONICAL_DREAM_TASKS, + DREAM_TASK_CAPABILITIES, type DreamTaskName, isCanonicalDreamTask, } from "../../features/magic-context/dreamer/task-registry"; import { + type DreamTaskRuntimeConfig, type ManualRunResult, runManualDream, } from "../../features/magic-context/dreamer/task-scheduler"; @@ -25,6 +27,40 @@ export function resolveManualDreamTask(raw: unknown): { task?: DreamTaskName; er return { task: requested }; } +/** + * Split the configured tasks into what this host can actually run and what needs + * a tool loop it does not have. Without this split a v2 no-arg run reports every + * `requiresTools` task as a failure instead of an unsupported-on-this-host line. + */ +export function selectRunnableDreamTasks(args: { + tasks: readonly DreamTaskRuntimeConfig[]; + toolsSupported: boolean; + requestedTask?: DreamTaskName; +}): { runnable: DreamTaskRuntimeConfig[]; unsupported: DreamTaskName[] } { + const requiresTools = (task: DreamTaskName) => DREAM_TASK_CAPABILITIES[task].requiresTools; + if (args.toolsSupported) return { runnable: [...args.tasks], unsupported: [] }; + if (args.requestedTask !== undefined) { + return requiresTools(args.requestedTask) + ? { runnable: [], unsupported: [args.requestedTask] } + : { runnable: [...args.tasks], unsupported: [] }; + } + // A no-arg run only considers enabled tasks (schedule != ""), so only those + // are worth reporting as unsupported. + const unsupported = args.tasks + .filter((config) => config.schedule.trim() !== "" && requiresTools(config.task)) + .map((config) => config.task); + return { + runnable: args.tasks.filter((config) => !requiresTools(config.task)), + unsupported, + }; +} + +export interface ManualDreamOutcome { + summary: ManualRunResult; + /** Selected tasks skipped because this host has no tool loop. */ + unsupportedTasks: DreamTaskName[]; +} + /** * Run the manual dream pass for a project on the v2 lane. * @@ -44,24 +80,32 @@ export async function runManualDreamNow(args: { executor: HiddenCompletionExecutor; sessionId: string; task?: DreamTaskName; -}): Promise { - return runManualDream({ +}): Promise { + const tasks = buildDreamTaskRuntimeConfigs( + args.dreamer, + "opencode", + args.language, + args.mural?.model, + ); + const executor = createDreamTaskExecutor({ + hiddenCompletionExecutor: args.executor, + parentSessionId: args.sessionId, + sessionDirectory: args.directory, + openOpenCodeDb: () => null, + language: args.language, + mural: args.mural, + }); + const selection = selectRunnableDreamTasks({ + tasks, + toolsSupported: args.executor.capabilities.tools === true, + ...(args.task !== undefined ? { requestedTask: args.task } : {}), + }); + const summary = await runManualDream({ db: args.db, projectIdentity: args.projectIdentity, - tasks: buildDreamTaskRuntimeConfigs( - args.dreamer, - "opencode", - args.language, - args.mural?.model, - ), - executor: createDreamTaskExecutor({ - hiddenCompletionExecutor: args.executor, - parentSessionId: args.sessionId, - sessionDirectory: args.directory, - openOpenCodeDb: () => null, - language: args.language, - mural: args.mural, - }), + tasks: selection.runnable, + executor, ...(args.task !== undefined ? { task: args.task } : {}), }); + return { summary, unsupportedTasks: selection.unsupported }; } From d4c903dde3cb20f15c082165d32dbcfc25ff47b6 Mon Sep 17 00:00:00 2001 From: astrid Date: Sat, 19 Sep 2026 23:06:36 -0400 Subject: [PATCH 10/13] fix(opencode2): address the round-three v2 lane review findings Round three reported one major and three minor findings; these close them: - Switch-turn admission: the 0.95 guard now measures against the OUTGOING model's window while the persisted reading stays attributed to the model that produced it (new pure helper v2/hooks/usage-reading.ts + tests). Previously a switch from a small to a larger window could refuse on the old model's ratio forever, because the refused turn never let the transform observe the switch. - The scheduled dreamer path applies the same requiresTools capability filter as the manual one, so a v2 host no longer records tool-requiring tasks as failed on every slot. - Session teardown: a session.deleted subscription clears the lane's per-session maps, releases the raw-message provider and drops the sidebar snapshot cache, mirroring the v1 lane (previously everything lived until plugin disposal). - Regression tests: usage-reading (same-model / switch / model-less / partial cache), a cache-less completion row, and latestAssistant ordering. - Nits: the requested-task branch of selectRunnableDreamTasks no longer returns tool-requiring tasks as runnable; the unsupported-only /ctx-dream dialog no longer prints the contradictory "No enabled dream tasks to run."; guarded tokens.input/output in hidden-completion. Verified: plugin suite 4959/0; e2e adapters-s2-contracts + store-reader 15/15; typecheck clean. --- .../tests/opencode2/store-reader.test.ts | 23 +++ .../plugin/src/v2/hidden-completion.test.ts | 27 +++- packages/plugin/src/v2/hidden-completion.ts | 4 +- packages/plugin/src/v2/hooks/context.ts | 131 +++++++++++++----- packages/plugin/src/v2/hooks/dream-manual.ts | 10 +- packages/plugin/src/v2/hooks/dream-trigger.ts | 14 +- .../plugin/src/v2/hooks/usage-reading.test.ts | 79 +++++++++++ packages/plugin/src/v2/hooks/usage-reading.ts | 61 ++++++++ 8 files changed, 303 insertions(+), 46 deletions(-) create mode 100644 packages/plugin/src/v2/hooks/usage-reading.test.ts create mode 100644 packages/plugin/src/v2/hooks/usage-reading.ts diff --git a/packages/e2e-tests/tests/opencode2/store-reader.test.ts b/packages/e2e-tests/tests/opencode2/store-reader.test.ts index e47a57243..64721cb86 100644 --- a/packages/e2e-tests/tests/opencode2/store-reader.test.ts +++ b/packages/e2e-tests/tests/opencode2/store-reader.test.ts @@ -116,6 +116,29 @@ test("session_message_reader seq pages idle boundaries and checkpoint window", ( expect(() => new V2StoreReader(join(root, "missing.db"))).toThrow(); }); +test("latestAssistant selects the newest assistant row by seq and ignores other types", () => { + const { root } = isolation(); + const path = join(root, "latest-assistant.db"); + const writer = new Database(path); + writer.exec( + "CREATE TABLE session_message(id TEXT PRIMARY KEY, session_id TEXT, type TEXT, seq INTEGER, data TEXT)", + ); + const insert = writer.prepare("INSERT INTO session_message VALUES (?, ?, ?, ?, ?)"); + insert.run("m1", "ses-A", "assistant", 1, JSON.stringify({ model: { providerID: "p", id: "old" } })); + insert.run("m2", "ses-A", "user", 2, JSON.stringify({})); + insert.run("m3", "ses-B", "assistant", 3, JSON.stringify({ model: { providerID: "p", id: "other" } })); + insert.run("m4", "ses-A", "assistant", 4, JSON.stringify({ model: { providerID: "p", id: "new" } })); + const reader = new V2StoreReader(path); + try { + expect(reader.latestAssistant("ses-A")?.id).toBe("m4"); + expect(reader.latestAssistant("ses-B")?.id).toBe("m3"); + expect(reader.latestAssistant("ses-missing")).toBeUndefined(); + } finally { + reader.close(); + writer.close(); + } +}); + test("I11 v1/v2 readers feed the same transform core with pinned host differences", () => { const { root } = isolation(); const sessionID = "ses-golden"; diff --git a/packages/plugin/src/v2/hidden-completion.test.ts b/packages/plugin/src/v2/hidden-completion.test.ts index 0b8ddb386..a27886027 100644 --- a/packages/plugin/src/v2/hidden-completion.test.ts +++ b/packages/plugin/src/v2/hidden-completion.test.ts @@ -66,6 +66,7 @@ class Rows { options: { modelID?: string; usage?: boolean; + cache?: boolean; error?: unknown; finish?: string; } = {}, @@ -87,7 +88,7 @@ class Rows { input: 101, output: 11, reasoning: 3, - cache: { read: 7, write: 5 }, + ...(options.cache === false ? {} : { cache: { read: 7, write: 5 } }), }, }), time: { created: Date.now(), completed: Date.now() }, @@ -118,6 +119,7 @@ async function setup(generation = "host-generation-1") { let failPrompt = false; let delayRowMs = 0; let omitUsage = false; + let omitCache = false; let completion = "editor completion"; const host: HiddenChildHost = { @@ -159,6 +161,7 @@ async function setup(generation = "host-generation-1") { const write = () => rows.append(input.sessionID, completion, { usage: !omitUsage, + cache: !omitCache, modelID: child.model.id, }); if (delayRowMs > 0) setTimeout(write, delayRowMs); @@ -203,6 +206,9 @@ async function setup(generation = "host-generation-1") { setOmitUsage(value: boolean) { omitUsage = value; }, + setOmitCache(value: boolean) { + omitCache = value; + }, setCompletion(value: string) { completion = value; }, @@ -380,6 +386,25 @@ describe("OpenCode 2 hidden child completion", () => { } }); + test("tolerates a completed row whose token cache counters are missing", async () => { + const state = await setup(); + try { + state.setOmitCache(true); + const handle = await state.executor.open(run); + await state.executor.attempt(handle, request()); + const completion = await state.executor.collect(handle, 50); + expect(completion.usage).toEqual({ + input: 101, + output: 11, + cacheRead: 0, + cacheWrite: 0, + }); + await close(state.executor, handle, true); + } finally { + state.db.close(); + } + }); + test("abort interrupts and retires the child before the next open", async () => { const state = await setup(); try { diff --git a/packages/plugin/src/v2/hidden-completion.ts b/packages/plugin/src/v2/hidden-completion.ts index 8dc400011..7a225d428 100644 --- a/packages/plugin/src/v2/hidden-completion.ts +++ b/packages/plugin/src/v2/hidden-completion.ts @@ -617,8 +617,8 @@ export async function createV2HiddenCompletionExecutor( reasoning: null, usage: tokens ? { - input: tokens.input, - output: tokens.output, + input: tokens.input ?? 0, + output: tokens.output ?? 0, cacheRead: tokens.cache?.read ?? 0, cacheWrite: tokens.cache?.write ?? 0, } diff --git a/packages/plugin/src/v2/hooks/context.ts b/packages/plugin/src/v2/hooks/context.ts index afc51e5f3..f3cd5f818 100644 --- a/packages/plugin/src/v2/hooks/context.ts +++ b/packages/plugin/src/v2/hooks/context.ts @@ -31,6 +31,7 @@ import { preloadTokenizer } from "../../hooks/magic-context/read-session-formatt import { createTransform, type TransformDeps } from "../../hooks/magic-context/transform"; import { maybeSendUpgradeReminder } from "../../hooks/magic-context/upgrade-reminder"; import { registerRpcHandlers } from "../../plugin/rpc-handlers"; +import { clearSidebarSnapshotCache } from "../../plugin/sidebar-snapshot-cache"; import { createToolRegistry } from "../../plugin/tool-registry"; import type { PluginContext } from "../../plugin/types"; import { detectConflicts } from "../../shared/conflict-detector"; @@ -59,6 +60,7 @@ import { adaptPayload, HEAD_IDS } from "./payload"; import { interruptBeforeProvider, V2ContextRefusal } from "./refusal"; import { rawMessages } from "./store"; import type { SessionContext, V2Context } from "./types"; +import { resolveUsageReading } from "./usage-reading"; export function createHostSeams( context: V2Context, @@ -356,6 +358,53 @@ export async function registerContext(context: V2Context) { lastUsageContextLimit: value.limit, ...(value.modelKey !== undefined ? { lastObservedModelKey: value.modelKey } : {}), }); + // The v1 lane clears per-session state on session.deleted; without this the + // lane's maps grow for every session until plugin disposal. + const clearSessionState = (sessionID: string) => { + liveModels.delete(sessionID); + variants.delete(sessionID); + agents.delete(sessionID); + usage.delete(sessionID); + channel1.delete(sessionID); + historyRefreshSessions.delete(sessionID); + pendingMaterializationSessions.delete(sessionID); + lastHeuristicsTurnId.delete(sessionID); + measuredUsageBySession.delete(sessionID); + rawProviders.get(sessionID)?.(); + rawProviders.delete(sessionID); + clearSidebarSnapshotCache(sessionID); + }; + const sessionCleanupController = new AbortController(); + const sessionCleanupDone = (async () => { + try { + for await (const value of context.event.subscribe({ + signal: sessionCleanupController.signal, + })) { + if (sessionCleanupController.signal.aborted) break; + const event = value as { + type?: string; + data?: { + sessionID?: unknown; + sessionId?: unknown; + info?: { id?: unknown }; + }; + }; + if (event.type !== "session.deleted") continue; + const sessionID = [ + event.data?.sessionID, + event.data?.sessionId, + event.data?.info?.id, + ].find( + (candidate): candidate is string => + typeof candidate === "string" && candidate.length > 0, + ); + if (sessionID) clearSessionState(sessionID); + } + } catch (error) { + if (!sessionCleanupController.signal.aborted) + console.warn("[magic-context] v2 session cleanup subscription failed", error); + } + })(); const refuseIfUnsafe = async (draft: SessionContext): Promise => { let unsafe = false; try { @@ -367,49 +416,43 @@ export async function registerContext(context: V2Context) { ); try { const latest = reader.latestAssistant(draft.sessionID); - const tokens = latest?.data.tokens; - // Attribute the reading to the model that produced the response (the - // store row carries it). A row without model metadata is - // indistinguishable from a model switch, so it records no model key - // and the post-pass re-apply is skipped for it. - const rowModel = latest?.data.model; - const rowProviderID = - typeof rowModel?.providerID === "string" ? rowModel.providerID : undefined; - const rowModelID = typeof rowModel?.id === "string" ? rowModel.id : undefined; - const measuredProviderID = rowProviderID ?? draft.model.providerID; - const measuredModelID = rowModelID ?? draft.model.id; - const modelKey = - rowProviderID !== undefined && rowModelID !== undefined - ? `${measuredProviderID}/${measuredModelID}` - : undefined; - // Resolve the same output-reserved usable window every other consumer - // (sidebar, history budgets, geometry) divides by. Reading the raw - // catalog window here made the persisted percentage and the 95% check - // disagree with the same sidebar's denominator. - const limit = resolveContextLimit(measuredProviderID, measuredModelID, { - db, - sessionID: draft.sessionID, + const usageDb = db; + const reading = resolveUsageReading({ + rowModel: latest?.data.model, + draftModel: { providerID: draft.model.providerID, id: draft.model.id }, + tokens: latest?.data.tokens, + completed: latest?.data.time?.completed, + limitFor: (providerID, modelID) => + resolveContextLimit(providerID, modelID, { + db: usageDb, + sessionID: draft.sessionID, + }), }); - if (tokens && Number.isFinite(limit) && limit > 0) { - const inputTokens = - (tokens.input ?? 0) + - (tokens.cache?.read ?? 0) + - (tokens.cache?.write ?? 0); - // Native compaction owns the window when MC compaction is off. - unsafe = compactionEnabled && inputTokens / limit >= 0.95; - const completed = latest?.data.time?.completed; + if (reading) { + // Admission is measured against the OUTGOING model's window (see + // resolveUsageReading): refusing on the previous model's ratio + // after a switch would loop, because the refused turn never lets + // the transform observe the switch. Native compaction owns the + // window when MC compaction is off. + unsafe = + compactionEnabled && reading.inputTokens / reading.admissionLimit >= 0.95; const measured: MeasuredUsage = { - inputTokens, - limit, - modelKey, - ...(typeof completed === "number" ? { completed } : {}), + inputTokens: reading.inputTokens, + limit: reading.limit, + modelKey: reading.modelKey, + ...(reading.completed !== undefined + ? { completed: reading.completed } + : {}), }; measuredUsageBySession.set(draft.sessionID, measured); // Early write covers the abort path (returned before the // transform); the post-pass write below wins on normal turns. - updateSessionMeta(db, draft.sessionID, usageMetaPatch(measured)); + updateSessionMeta(usageDb, draft.sessionID, usageMetaPatch(measured)); usage.set(draft.sessionID, { - usage: { inputTokens, percentage: (inputTokens / limit) * 100 }, + usage: { + inputTokens: reading.inputTokens, + percentage: (reading.inputTokens / reading.limit) * 100, + }, hasUsageTokens: true, updatedAt: Date.now(), }); @@ -830,8 +873,20 @@ export async function registerContext(context: V2Context) { ...(requested.task !== undefined ? { task: requested.task } : {}), }) .then(({ summary, unsupportedTasks }) => { + // With nothing runnable and only an unsupported task requested, the + // summary's "No enabled dream tasks to run." would contradict the + // unsupported line — show just the unsupported line then. + const hasSummaryContent = + summary.ran.length > 0 || + summary.failed.length > 0 || + summary.skippedNoWork.length > 0 || + summary.deferredBusy.length > 0 || + Object.keys(summary.backlogBefore ?? {}).length > 0 || + Object.keys(summary.backlogAfter ?? {}).length > 0; const message = [ - summarizeManualDream(summary), + hasSummaryContent || unsupportedTasks.length === 0 + ? summarizeManualDream(summary) + : undefined, unsupportedTasks.length > 0 ? `Unsupported on this host (no tool loop): ${unsupportedTasks.join(", ")}` : undefined, @@ -870,6 +925,8 @@ export async function registerContext(context: V2Context) { async dispose() { rpcStopped = true; rpcServer.stop(); + sessionCleanupController.abort(); + await sessionCleanupDone; await dreamTrigger?.dispose(); for (const release of rawProviders.values()) release(); rawProviders.clear(); diff --git a/packages/plugin/src/v2/hooks/dream-manual.ts b/packages/plugin/src/v2/hooks/dream-manual.ts index eab3f1c9c..d9d64e538 100644 --- a/packages/plugin/src/v2/hooks/dream-manual.ts +++ b/packages/plugin/src/v2/hooks/dream-manual.ts @@ -40,9 +40,13 @@ export function selectRunnableDreamTasks(args: { const requiresTools = (task: DreamTaskName) => DREAM_TASK_CAPABILITIES[task].requiresTools; if (args.toolsSupported) return { runnable: [...args.tasks], unsupported: [] }; if (args.requestedTask !== undefined) { - return requiresTools(args.requestedTask) - ? { runnable: [], unsupported: [args.requestedTask] } - : { runnable: [...args.tasks], unsupported: [] }; + if (requiresTools(args.requestedTask)) { + return { runnable: [], unsupported: [args.requestedTask] }; + } + return { + runnable: args.tasks.filter((config) => config.task === args.requestedTask), + unsupported: [], + }; } // A no-arg run only considers enabled tasks (schedule != ""), so only those // are worth reporting as unsupported. diff --git a/packages/plugin/src/v2/hooks/dream-trigger.ts b/packages/plugin/src/v2/hooks/dream-trigger.ts index c9ee44b8a..c09cab29a 100644 --- a/packages/plugin/src/v2/hooks/dream-trigger.ts +++ b/packages/plugin/src/v2/hooks/dream-trigger.ts @@ -4,6 +4,7 @@ import { createDreamTaskExecutor } from "../../features/magic-context/dreamer/ta import { runDueTasksForProject } from "../../features/magic-context/dreamer/task-scheduler"; import { openDatabase } from "../../features/magic-context/storage"; import type { HiddenCompletionExecutor } from "../../hooks/magic-context/compartment-runner-types"; +import { selectRunnableDreamTasks } from "./dream-manual"; import type { V2Context } from "./types"; /** The event carrier only wakes the shared scheduler; it never implements another @@ -30,15 +31,22 @@ export function startDreamTrigger( const db = openDatabase(); if (!db) continue; try { - await runDueTasksForProject({ - db, - projectIdentity: args.projectIdentity(), + // The scheduled path must apply the same capability filter as the + // manual one: without it every requiresTools task is attempted on + // hosts with no tool loop and permanently recorded as failed. + const { runnable } = selectRunnableDreamTasks({ tasks: buildDreamTaskRuntimeConfigs( args.config, "opencode", args.language, args.mural?.model, ), + toolsSupported: args.executor.capabilities.tools === true, + }); + await runDueTasksForProject({ + db, + projectIdentity: args.projectIdentity(), + tasks: runnable, executor: createDreamTaskExecutor({ hiddenCompletionExecutor: args.executor, parentSessionId: event.data.sessionID, diff --git a/packages/plugin/src/v2/hooks/usage-reading.test.ts b/packages/plugin/src/v2/hooks/usage-reading.test.ts new file mode 100644 index 000000000..3f936cf70 --- /dev/null +++ b/packages/plugin/src/v2/hooks/usage-reading.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from "bun:test"; +import { resolveUsageReading } from "./usage-reading"; + +const windows: Record = { old: 200_000, new: 1_000_000 }; +const limitFor = (_providerID: string, modelID: string) => windows[modelID] ?? 0; + +test("a same-model reading uses one window for attribution and admission", () => { + const reading = resolveUsageReading({ + rowModel: { providerID: "p", id: "old" }, + draftModel: { providerID: "p", id: "old" }, + tokens: { input: 195_000, cache: { read: 0, write: 0 } }, + completed: 123, + limitFor, + }); + expect(reading).toEqual({ + inputTokens: 195_000, + limit: 200_000, + admissionLimit: 200_000, + modelKey: "p/old", + completed: 123, + }); + expect(reading!.inputTokens / reading!.admissionLimit).toBeGreaterThanOrEqual(0.95); +}); + +test("a switch to a larger model admits on the new window instead of refusing on the old", () => { + const reading = resolveUsageReading({ + rowModel: { providerID: "p", id: "old" }, + draftModel: { providerID: "p", id: "new" }, + tokens: { input: 195_000, cache: { read: 0, write: 0 } }, + limitFor, + }); + // The reading stays attributed to the producing model... + expect(reading?.limit).toBe(200_000); + expect(reading?.modelKey).toBe("p/old"); + // ...but the admission ratio is measured against the outgoing window. + expect(reading?.admissionLimit).toBe(1_000_000); + expect(reading!.inputTokens / reading!.admissionLimit).toBeLessThan(0.95); +}); + +test("a row without model metadata records no modelKey and admits on the draft window", () => { + const reading = resolveUsageReading({ + draftModel: { providerID: "p", id: "new" }, + tokens: { input: 10 }, + limitFor, + }); + expect(reading).toEqual({ + inputTokens: 10, + limit: 1_000_000, + admissionLimit: 1_000_000, + }); +}); + +test("partial cache objects and missing token fields count as zero", () => { + const reading = resolveUsageReading({ + rowModel: { providerID: "p", id: "old" }, + draftModel: { providerID: "p", id: "old" }, + tokens: { input: 5 }, + limitFor, + }); + expect(reading?.inputTokens).toBe(5); +}); + +test("returns undefined without tokens or with a non-positive window", () => { + expect( + resolveUsageReading({ + rowModel: { providerID: "p", id: "old" }, + draftModel: { providerID: "p", id: "old" }, + limitFor, + }), + ).toBeUndefined(); + expect( + resolveUsageReading({ + rowModel: { providerID: "p", id: "missing" }, + draftModel: { providerID: "p", id: "missing" }, + tokens: { input: 1 }, + limitFor, + }), + ).toBeUndefined(); +}); diff --git a/packages/plugin/src/v2/hooks/usage-reading.ts b/packages/plugin/src/v2/hooks/usage-reading.ts new file mode 100644 index 000000000..21990d3ac --- /dev/null +++ b/packages/plugin/src/v2/hooks/usage-reading.ts @@ -0,0 +1,61 @@ +/** + * Turn the last assistant store row into a usage reading. + * + * The reading is attributed to the model that produced the response (the row's + * own model), while the admission check — "will the next request fit?" — must + * use the OUTGOING draft model's window: on a model switch, refusing on the old + * model's ratio would loop forever because the refused turn never lets the + * transform observe the switch. + */ +export interface UsageReadingInput { + rowModel?: { providerID?: unknown; id?: unknown }; + draftModel: { providerID: string; id: string }; + tokens?: { + input?: number; + output?: number; + cache?: { read?: number; write?: number }; + }; + completed?: number; + /** Resolve the output-reserved usable window for a model. */ + limitFor: (providerID: string, modelID: string) => number; +} + +export interface UsageReading { + inputTokens: number; + /** Window of the model that produced the reading (persisted attribution). */ + limit: number; + /** Window the next request will hit (admission check denominator). */ + admissionLimit: number; + /** Absent when the row carries no model metadata (legacy rows). */ + modelKey?: string; + completed?: number; +} + +export function resolveUsageReading(input: UsageReadingInput): UsageReading | undefined { + const { tokens } = input; + if (!tokens) return undefined; + const rowProviderID = + typeof input.rowModel?.providerID === "string" ? input.rowModel.providerID : undefined; + const rowModelID = typeof input.rowModel?.id === "string" ? input.rowModel.id : undefined; + const measuredProviderID = rowProviderID ?? input.draftModel.providerID; + const measuredModelID = rowModelID ?? input.draftModel.id; + const inputTokens = + (tokens.input ?? 0) + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0); + const limit = input.limitFor(measuredProviderID, measuredModelID); + if (!Number.isFinite(limit) || limit <= 0) return undefined; + const sameModel = + measuredProviderID === input.draftModel.providerID && + measuredModelID === input.draftModel.id; + const draftLimit = sameModel + ? limit + : input.limitFor(input.draftModel.providerID, input.draftModel.id); + return { + inputTokens, + limit, + admissionLimit: Number.isFinite(draftLimit) && draftLimit > 0 ? draftLimit : limit, + ...(rowProviderID !== undefined && rowModelID !== undefined + ? { modelKey: `${measuredProviderID}/${measuredModelID}` } + : {}), + ...(input.completed !== undefined ? { completed: input.completed } : {}), + }; +} From 6fdab938bbdd905799beb9d625be58a7d468376a Mon Sep 17 00:00:00 2001 From: astrid Date: Sat, 19 Sep 2026 23:20:27 -0400 Subject: [PATCH 11/13] fix(opencode2): address the round-four v2 lane review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round four reported three worth-fixing items plus nits; these close them: - The dream scheduler pruned task rows against the CALLER's task list, so the round-three capability filter deleted all ten requiresTools canonical rows (last_run_at / next_due_at / watermarks) on the first execution event of a v2 host — project-scoped data v1 would inherit the loss of. planDueTasks now prunes against CANONICAL_DREAM_TASKS; a capability filter may select what to run but must never define what is canonical. - Usage readings coerce non-numeric token fields and completed timestamps to absent: a JSON null `time.completed` previously became lastResponseTime: null -> "" and got the whole session_meta row rejected, cascading to defaults (lost cacheTtl/usage, scheduler always-execute). - The lane's tagger is hoisted out of the transform deps and cleaned on session.deleted (v1 parity), and a deleted-session tombstone stops an in-flight pass from re-registering state for a session deleted mid-pass. Also: the hidden-completion usage falls back to the local meter when a completed row carries a tokens object without numeric input/output; the e2e latestAssistant test shuffles its inserts so seq ordering is actually proven; new usage-reading cases pin the large->small refusal direction and the non-numeric/null guards. Verified: plugin suite 4961/0; e2e adapters-s2-contracts + store-reader 15/15; typecheck clean. --- .../tests/opencode2/store-reader.test.ts | 5 ++-- .../magic-context/dreamer/task-scheduler.ts | 14 +++++------ packages/plugin/src/v2/hidden-completion.ts | 21 ++++++++++------- packages/plugin/src/v2/hooks/context.ts | 9 +++++++- .../plugin/src/v2/hooks/usage-reading.test.ts | 23 +++++++++++++++++++ packages/plugin/src/v2/hooks/usage-reading.ts | 8 +++++-- 6 files changed, 60 insertions(+), 20 deletions(-) diff --git a/packages/e2e-tests/tests/opencode2/store-reader.test.ts b/packages/e2e-tests/tests/opencode2/store-reader.test.ts index 64721cb86..607223cf7 100644 --- a/packages/e2e-tests/tests/opencode2/store-reader.test.ts +++ b/packages/e2e-tests/tests/opencode2/store-reader.test.ts @@ -124,10 +124,11 @@ test("latestAssistant selects the newest assistant row by seq and ignores other "CREATE TABLE session_message(id TEXT PRIMARY KEY, session_id TEXT, type TEXT, seq INTEGER, data TEXT)", ); const insert = writer.prepare("INSERT INTO session_message VALUES (?, ?, ?, ?, ?)"); - insert.run("m1", "ses-A", "assistant", 1, JSON.stringify({ model: { providerID: "p", id: "old" } })); + // Insert order deliberately shuffled so ORDER BY rowid cannot stand in for seq. + insert.run("m4", "ses-A", "assistant", 4, JSON.stringify({ model: { providerID: "p", id: "new" } })); insert.run("m2", "ses-A", "user", 2, JSON.stringify({})); + insert.run("m1", "ses-A", "assistant", 1, JSON.stringify({ model: { providerID: "p", id: "old" } })); insert.run("m3", "ses-B", "assistant", 3, JSON.stringify({ model: { providerID: "p", id: "other" } })); - insert.run("m4", "ses-A", "assistant", 4, JSON.stringify({ model: { providerID: "p", id: "new" } })); const reader = new V2StoreReader(path); try { expect(reader.latestAssistant("ses-A")?.id).toBe("m4"); diff --git a/packages/plugin/src/features/magic-context/dreamer/task-scheduler.ts b/packages/plugin/src/features/magic-context/dreamer/task-scheduler.ts index 903a44b4b..15f1b633b 100644 --- a/packages/plugin/src/features/magic-context/dreamer/task-scheduler.ts +++ b/packages/plugin/src/features/magic-context/dreamer/task-scheduler.ts @@ -18,6 +18,7 @@ import { } from "./storage-task-schedule"; import { evaluateTaskGate, getDreamTaskBacklogs } from "./task-gates"; import { + CANONICAL_DREAM_TASKS, compareTaskOrder, type DreamTaskBacklog, type DreamTaskBacklogMap, @@ -172,13 +173,12 @@ export function planDueTasks( ): DueTask[] { // GC retired task rows: improve, consolidate, and archive-stale were replaced // by verify/curate, while render-mural was removed when the scheduler switched - // to its deterministic task set. Since `tasks` contains the full canonical set, - // any stored row outside it is obsolete. Cheap and idempotent. - const pruned = pruneNonCanonicalTaskRows( - db, - projectIdentity, - tasks.map((t) => t.task), - ); + // to its deterministic task set. Prune against the canonical set — NOT the + // passed task list: callers may filter that list for execution (e.g. a host + // without a tool loop), and a capability filter must never delete canonical + // schedule rows (last_run_at / next_due_at / watermarks) for the whole project. + // Cheap and idempotent. + const pruned = pruneNonCanonicalTaskRows(db, projectIdentity, CANONICAL_DREAM_TASKS); if (pruned > 0) { log(`[dreamer] pruned ${pruned} retired task row(s) for ${projectIdentity}`); } diff --git a/packages/plugin/src/v2/hidden-completion.ts b/packages/plugin/src/v2/hidden-completion.ts index 7a225d428..9c7961131 100644 --- a/packages/plugin/src/v2/hidden-completion.ts +++ b/packages/plugin/src/v2/hidden-completion.ts @@ -612,17 +612,22 @@ export async function createV2HiddenCompletionExecutor( ? request.body.system : run.identity.system; const tokens = row.data.tokens; + const tokenNumber = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) ? value : undefined; + const reportedInput = tokenNumber(tokens?.input); + const reportedOutput = tokenNumber(tokens?.output); run.completion = { text, reasoning: null, - usage: tokens - ? { - input: tokens.input ?? 0, - output: tokens.output ?? 0, - cacheRead: tokens.cache?.read ?? 0, - cacheWrite: tokens.cache?.write ?? 0, - } - : meter(system, promptText(request), text ?? ""), + usage: + reportedInput !== undefined || reportedOutput !== undefined + ? { + input: reportedInput ?? 0, + output: reportedOutput ?? 0, + cacheRead: tokenNumber(tokens?.cache?.read) ?? 0, + cacheWrite: tokenNumber(tokens?.cache?.write) ?? 0, + } + : meter(system, promptText(request), text ?? ""), lengthCapped: ["length", "max_tokens"].includes(row.data.finish ?? ""), providerId: row.data.model?.providerID ?? requested.providerID, modelId: row.data.model?.id ?? requested.modelID, diff --git a/packages/plugin/src/v2/hooks/context.ts b/packages/plugin/src/v2/hooks/context.ts index f3cd5f818..ccabdba54 100644 --- a/packages/plugin/src/v2/hooks/context.ts +++ b/packages/plugin/src/v2/hooks/context.ts @@ -351,6 +351,7 @@ export async function registerContext(context: V2Context) { // handler writes after the pass too, which is why its sidebar never shows the // reset. const measuredUsageBySession = new Map(); + const tagger = createTagger(); const usageMetaPatch = (value: MeasuredUsage) => ({ ...(value.completed !== undefined ? { lastResponseTime: value.completed } : {}), lastContextPercentage: (value.inputTokens / value.limit) * 100, @@ -360,7 +361,9 @@ export async function registerContext(context: V2Context) { }); // The v1 lane clears per-session state on session.deleted; without this the // lane's maps grow for every session until plugin disposal. + const deletedSessions = new Set(); const clearSessionState = (sessionID: string) => { + deletedSessions.add(sessionID); liveModels.delete(sessionID); variants.delete(sessionID); agents.delete(sessionID); @@ -370,6 +373,7 @@ export async function registerContext(context: V2Context) { pendingMaterializationSessions.delete(sessionID); lastHeuristicsTurnId.delete(sessionID); measuredUsageBySession.delete(sessionID); + tagger.cleanup(sessionID); rawProviders.get(sessionID)?.(); rawProviders.delete(sessionID); clearSidebarSnapshotCache(sessionID); @@ -526,6 +530,9 @@ export async function registerContext(context: V2Context) { }); await context.session.hook("context", async (draft) => { if (hiddenChildHook.apply(draft)) return; + // A deletion that raced an in-flight pass must not let this pass re-register + // the cleared session's state (there is no second deletion event). + if (deletedSessions.has(draft.sessionID)) return; liveModels.set(draft.sessionID, { providerID: draft.model.providerID, modelID: draft.model.id, @@ -602,7 +609,7 @@ export async function registerContext(context: V2Context) { ); transform ??= createTransform({ db, - tagger: createTagger(), + tagger, scheduler: createScheduler({ executeThresholdPercentage: config.execute_threshold_percentage, }), diff --git a/packages/plugin/src/v2/hooks/usage-reading.test.ts b/packages/plugin/src/v2/hooks/usage-reading.test.ts index 3f936cf70..becd3e426 100644 --- a/packages/plugin/src/v2/hooks/usage-reading.test.ts +++ b/packages/plugin/src/v2/hooks/usage-reading.test.ts @@ -60,6 +60,29 @@ test("partial cache objects and missing token fields count as zero", () => { expect(reading?.inputTokens).toBe(5); }); +test("a switch to a smaller model still refuses on the new window", () => { + const reading = resolveUsageReading({ + rowModel: { providerID: "p", id: "new" }, + draftModel: { providerID: "p", id: "old" }, + tokens: { input: 195_000, cache: { read: 0, write: 0 } }, + limitFor, + }); + expect(reading?.admissionLimit).toBe(200_000); + expect(reading!.inputTokens / reading!.admissionLimit).toBeGreaterThanOrEqual(0.95); +}); + +test("non-numeric token fields and a null completed value are treated as absent", () => { + const reading = resolveUsageReading({ + rowModel: { providerID: "p", id: "old" }, + draftModel: { providerID: "p", id: "old" }, + tokens: { input: "nope", cache: { read: null, write: undefined } } as never, + completed: null as never, + limitFor, + }); + expect(reading?.inputTokens).toBe(0); + expect(reading?.completed).toBeUndefined(); +}); + test("returns undefined without tokens or with a non-positive window", () => { expect( resolveUsageReading({ diff --git a/packages/plugin/src/v2/hooks/usage-reading.ts b/packages/plugin/src/v2/hooks/usage-reading.ts index 21990d3ac..2edcbb75b 100644 --- a/packages/plugin/src/v2/hooks/usage-reading.ts +++ b/packages/plugin/src/v2/hooks/usage-reading.ts @@ -34,13 +34,15 @@ export interface UsageReading { export function resolveUsageReading(input: UsageReadingInput): UsageReading | undefined { const { tokens } = input; if (!tokens) return undefined; + const numeric = (value: unknown): number => + typeof value === "number" && Number.isFinite(value) ? value : 0; const rowProviderID = typeof input.rowModel?.providerID === "string" ? input.rowModel.providerID : undefined; const rowModelID = typeof input.rowModel?.id === "string" ? input.rowModel.id : undefined; const measuredProviderID = rowProviderID ?? input.draftModel.providerID; const measuredModelID = rowModelID ?? input.draftModel.id; const inputTokens = - (tokens.input ?? 0) + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0); + numeric(tokens.input) + numeric(tokens.cache?.read) + numeric(tokens.cache?.write); const limit = input.limitFor(measuredProviderID, measuredModelID); if (!Number.isFinite(limit) || limit <= 0) return undefined; const sameModel = @@ -56,6 +58,8 @@ export function resolveUsageReading(input: UsageReadingInput): UsageReading | un ...(rowProviderID !== undefined && rowModelID !== undefined ? { modelKey: `${measuredProviderID}/${measuredModelID}` } : {}), - ...(input.completed !== undefined ? { completed: input.completed } : {}), + ...(typeof input.completed === "number" && Number.isFinite(input.completed) + ? { completed: input.completed } + : {}), }; } From 88fb463d5cde672bcafe7ff5ed695877722b3af2 Mon Sep 17 00:00:00 2001 From: astrid Date: Sat, 19 Sep 2026 23:28:30 -0400 Subject: [PATCH 12/13] fix(opencode2): close the round-five review test gap and nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round five found no blocking or major issues; one worth-fixing test gap and nits remained, closed here: - task-scheduler.test: a canonical task omitted from the caller's execution list (the v2 capability-filter case) keeps its schedule row and cursors — discriminating against the old caller-list pruning semantics. - deletedSessions is capped (oldest-first) and cleared on dispose. - usageMetaPatch re-checks the completed timestamp itself, so a future direct MeasuredUsage producer cannot reintroduce the ""-into-INTEGER cascade. - hidden-completion: a one-sided numeric usage row floors the missing side to 0 deliberately (documented), and a raw non-numeric tokens fixture pins the meter fallback. Verified: plugin suite 4963/0; e2e adapters-s2-contracts + store-reader 15/15; typecheck clean. --- .../dreamer/task-scheduler.test.ts | 29 +++++++++++++ .../plugin/src/v2/hidden-completion.test.ts | 41 ++++++++++++++++--- packages/plugin/src/v2/hidden-completion.ts | 4 ++ packages/plugin/src/v2/hooks/context.ts | 11 ++++- 4 files changed, 78 insertions(+), 7 deletions(-) diff --git a/packages/plugin/src/features/magic-context/dreamer/task-scheduler.test.ts b/packages/plugin/src/features/magic-context/dreamer/task-scheduler.test.ts index a8be24bfd..17c5cf526 100644 --- a/packages/plugin/src/features/magic-context/dreamer/task-scheduler.test.ts +++ b/packages/plugin/src/features/magic-context/dreamer/task-scheduler.test.ts @@ -171,6 +171,35 @@ describe("task-scheduler — planDueTasks", () => { expect(getTaskScheduleState(db, PROJECT, "curate")).not.toBeNull(); }); + it("prunes against the canonical set, not the caller's filtered list", () => { + db = freshDb(); + // A canonical task the caller's execution list omits (e.g. a capability + // filter on a host without a tool loop) must keep its durable schedule row + // and cursors — a capability filter selects what to run, it must never + // define what is canonical. + writeTaskScheduleState(db, { + projectPath: PROJECT, + task: "map-memories", + lastRunAt: 1234, + nextDueAt: Date.now() + 60_000, + schedule: "0 3 * * *", + lastStatus: "completed", + lastError: null, + retryCount: 0, + lastCheckedCommit: "abc", + retrospectiveWatermarkMs: 99, + }); + planDueTasks( + db, + PROJECT, + [cfg("verify", "0 3 * * *"), cfg("curate", "0 4 * * 0")], + Date.now(), + ); + const preserved = getTaskScheduleState(db, PROJECT, "map-memories"); + expect(preserved?.lastRunAt).toBe(1234); + expect(preserved?.retrospectiveWatermarkMs).toBe(99); + }); + it("deleteTaskScheduleRowsForProject removes ALL rows for an orphaned project only", () => { db = freshDb(); const orphan = "dir:deadworktree"; diff --git a/packages/plugin/src/v2/hidden-completion.test.ts b/packages/plugin/src/v2/hidden-completion.test.ts index a27886027..6bf099ef7 100644 --- a/packages/plugin/src/v2/hidden-completion.test.ts +++ b/packages/plugin/src/v2/hidden-completion.test.ts @@ -67,6 +67,7 @@ class Rows { modelID?: string; usage?: boolean; cache?: boolean; + rawTokens?: boolean; error?: unknown; finish?: string; } = {}, @@ -84,12 +85,20 @@ class Rows { ...(options.usage === false ? {} : { - tokens: { - input: 101, - output: 11, - reasoning: 3, - ...(options.cache === false ? {} : { cache: { read: 7, write: 5 } }), - }, + tokens: options.rawTokens + ? ({ + input: null, + output: "not-a-number", + reasoning: 3, + } as never) + : { + input: 101, + output: 11, + reasoning: 3, + ...(options.cache === false + ? {} + : { cache: { read: 7, write: 5 } }), + }, }), time: { created: Date.now(), completed: Date.now() }, }, @@ -120,6 +129,7 @@ async function setup(generation = "host-generation-1") { let delayRowMs = 0; let omitUsage = false; let omitCache = false; + let rawTokens = false; let completion = "editor completion"; const host: HiddenChildHost = { @@ -162,6 +172,7 @@ async function setup(generation = "host-generation-1") { rows.append(input.sessionID, completion, { usage: !omitUsage, cache: !omitCache, + rawTokens, modelID: child.model.id, }); if (delayRowMs > 0) setTimeout(write, delayRowMs); @@ -209,6 +220,9 @@ async function setup(generation = "host-generation-1") { setOmitCache(value: boolean) { omitCache = value; }, + setRawTokens(value: boolean) { + rawTokens = value; + }, setCompletion(value: string) { completion = value; }, @@ -405,6 +419,21 @@ describe("OpenCode 2 hidden child completion", () => { } }); + test("falls back to the local meter when token fields are non-numeric", async () => { + const state = await setup(); + try { + state.setRawTokens(true); + const handle = await state.executor.open(run); + await state.executor.attempt(handle, request()); + const completion = await state.executor.collect(handle, 50); + expect(completion.usage.input).toBeGreaterThan(0); + expect(completion.usage.output).toBeGreaterThan(0); + await close(state.executor, handle, true); + } finally { + state.db.close(); + } + }); + test("abort interrupts and retires the child before the next open", async () => { const state = await setup(); try { diff --git a/packages/plugin/src/v2/hidden-completion.ts b/packages/plugin/src/v2/hidden-completion.ts index 9c7961131..3652f6e02 100644 --- a/packages/plugin/src/v2/hidden-completion.ts +++ b/packages/plugin/src/v2/hidden-completion.ts @@ -619,6 +619,10 @@ export async function createV2HiddenCompletionExecutor( run.completion = { text, reasoning: null, + // A row with only one numeric side takes the provider branch and + // floors the other side to 0 deliberately: these numbers feed + // budget math, so never over-report a component the provider did + // not send. The local meter is for rows with no numeric usage. usage: reportedInput !== undefined || reportedOutput !== undefined ? { diff --git a/packages/plugin/src/v2/hooks/context.ts b/packages/plugin/src/v2/hooks/context.ts index ccabdba54..6dfe88d3c 100644 --- a/packages/plugin/src/v2/hooks/context.ts +++ b/packages/plugin/src/v2/hooks/context.ts @@ -353,7 +353,9 @@ export async function registerContext(context: V2Context) { const measuredUsageBySession = new Map(); const tagger = createTagger(); const usageMetaPatch = (value: MeasuredUsage) => ({ - ...(value.completed !== undefined ? { lastResponseTime: value.completed } : {}), + ...(typeof value.completed === "number" && Number.isFinite(value.completed) + ? { lastResponseTime: value.completed } + : {}), lastContextPercentage: (value.inputTokens / value.limit) * 100, lastInputTokens: value.inputTokens, lastUsageContextLimit: value.limit, @@ -364,6 +366,12 @@ export async function registerContext(context: V2Context) { const deletedSessions = new Set(); const clearSessionState = (sessionID: string) => { deletedSessions.add(sessionID); + // The tombstone only needs to outlive passes already in flight at deletion + // time; cap it so a long-lived process cannot grow it without bound. + if (deletedSessions.size > 1000) { + const oldest = deletedSessions.values().next().value; + if (oldest !== undefined) deletedSessions.delete(oldest); + } liveModels.delete(sessionID); variants.delete(sessionID); agents.delete(sessionID); @@ -934,6 +942,7 @@ export async function registerContext(context: V2Context) { rpcServer.stop(); sessionCleanupController.abort(); await sessionCleanupDone; + deletedSessions.clear(); await dreamTrigger?.dispose(); for (const release of rawProviders.values()) release(); rawProviders.clear(); From d4d0e922a7a51b5e9f27365c56bb3b5d75093ef2 Mon Sep 17 00:00:00 2001 From: astrid Date: Sat, 19 Sep 2026 23:47:30 -0400 Subject: [PATCH 13/13] docs(opencode2): explain why the fold materializer passes cacheExpired:false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the previous attempt established that materializeM0 never reads M0HardSignals.cacheExpired — only mustMaterialize does, and that decision only runs on the transform path with its own computed signals. This fold path renders fresh bytes unconditionally and uses only the system/model hashes for its markers, so computing the TTL signal here was a runtime no-op with a misleading comment. Keep the literal and document the reason. Verified: plugin suite 4963/0; typecheck clean. --- packages/plugin/src/v2/hooks/context.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/plugin/src/v2/hooks/context.ts b/packages/plugin/src/v2/hooks/context.ts index 6dfe88d3c..959ef99f5 100644 --- a/packages/plugin/src/v2/hooks/context.ts +++ b/packages/plugin/src/v2/hooks/context.ts @@ -497,6 +497,11 @@ export async function registerContext(context: V2Context) { systemHash: foldDigest(JSON.stringify(draft.system)), toolSetHash: "", modelKey: `${draft.model.providerID}/${draft.model.id}`, + // Deliberately false: materializeM0 never reads `cacheExpired` — + // only mustMaterialize does, and that decision runs on the + // transform path with its own computed signals. This fold path + // renders fresh bytes unconditionally and uses only the + // system/model hashes for its markers. cacheExpired: false, lastResponseTime: state.lastResponseTime, },