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/e2e-tests/tests/opencode2/store-reader.test.ts b/packages/e2e-tests/tests/opencode2/store-reader.test.ts index e47a57243..607223cf7 100644 --- a/packages/e2e-tests/tests/opencode2/store-reader.test.ts +++ b/packages/e2e-tests/tests/opencode2/store-reader.test.ts @@ -116,6 +116,30 @@ 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 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" } })); + 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/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/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/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/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/hooks/magic-context/read-session-db.test.ts b/packages/plugin/src/hooks/magic-context/read-session-db.test.ts index aefe65e1e..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 @@ -598,6 +598,62 @@ 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; + }>, + options: { v2Marker?: boolean } = {}, +): 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 + ); + 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 (?, ?, ?, ?, ?, ?, ?)`, + ); + 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 +682,87 @@ 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("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 45aa6e60b..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,25 +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) => { - 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; + // 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/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/hidden-completion.test.ts b/packages/plugin/src/v2/hidden-completion.test.ts index 0b8ddb386..6bf099ef7 100644 --- a/packages/plugin/src/v2/hidden-completion.test.ts +++ b/packages/plugin/src/v2/hidden-completion.test.ts @@ -66,6 +66,8 @@ class Rows { options: { modelID?: string; usage?: boolean; + cache?: boolean; + rawTokens?: boolean; error?: unknown; finish?: string; } = {}, @@ -83,12 +85,20 @@ class Rows { ...(options.usage === false ? {} : { - tokens: { - input: 101, - output: 11, - reasoning: 3, - 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() }, }, @@ -118,6 +128,8 @@ async function setup(generation = "host-generation-1") { let failPrompt = false; let delayRowMs = 0; let omitUsage = false; + let omitCache = false; + let rawTokens = false; let completion = "editor completion"; const host: HiddenChildHost = { @@ -159,6 +171,8 @@ async function setup(generation = "host-generation-1") { const write = () => rows.append(input.sessionID, completion, { usage: !omitUsage, + cache: !omitCache, + rawTokens, modelID: child.model.id, }); if (delayRowMs > 0) setTimeout(write, delayRowMs); @@ -203,6 +217,12 @@ async function setup(generation = "host-generation-1") { setOmitUsage(value: boolean) { omitUsage = value; }, + setOmitCache(value: boolean) { + omitCache = value; + }, + setRawTokens(value: boolean) { + rawTokens = value; + }, setCompletion(value: string) { completion = value; }, @@ -380,6 +400,40 @@ 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("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 11f0f0d5b..3652f6e02 100644 --- a/packages/plugin/src/v2/hidden-completion.ts +++ b/packages/plugin/src/v2/hidden-completion.ts @@ -612,17 +612,26 @@ 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, - output: tokens.output, - cacheRead: tokens.cache.read, - cacheWrite: tokens.cache.write, - } - : meter(system, promptText(request), text ?? ""), + // 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 + ? { + 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 453456a1f..959ef99f5 100644 --- a/packages/plugin/src/v2/hooks/context.ts +++ b/packages/plugin/src/v2/hooks/context.ts @@ -1,6 +1,9 @@ +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 { 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 { @@ -11,18 +14,29 @@ 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, } 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 { clearSidebarSnapshotCache } from "../../plugin/sidebar-snapshot-cache"; +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 { getErrorMessage } from "../../shared/error-message"; import { resolveHistorianModel } from "../../shared/model-resolution"; import type { PromptSurfaceConfig } from "../../shared/prompt-surface"; import { @@ -31,18 +45,22 @@ 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"; 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 { modelLimitCacheWarm, warmModelLimitCacheFromCatalog } from "./model-limit-cache"; 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, @@ -116,6 +134,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, @@ -135,9 +180,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) { @@ -147,8 +197,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({ @@ -221,6 +269,56 @@ 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, + // 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, + metadata: () => {}, + ask: async () => {}, + }); + return toV2ToolResult(result); + }, + }); + } + }); + } 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( gaDatabasePath(getDataDir(), process.env.OPENCODE_CHANNEL ?? "latest"), @@ -239,6 +337,86 @@ export async function registerContext(context: V2Context) { getCount: (sessionID: string) => read(sessionID).length, }); let transform: ReturnType | undefined; + type MeasuredUsage = { + inputTokens: number; + limit: number; + /** 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 + // 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 tagger = createTagger(); + const usageMetaPatch = (value: MeasuredUsage) => ({ + ...(typeof value.completed === "number" && Number.isFinite(value.completed) + ? { lastResponseTime: value.completed } + : {}), + lastContextPercentage: (value.inputTokens / value.limit) * 100, + lastInputTokens: value.inputTokens, + 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 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); + usage.delete(sessionID); + channel1.delete(sessionID); + historyRefreshSessions.delete(sessionID); + pendingMaterializationSessions.delete(sessionID); + lastHeuristicsTurnId.delete(sessionID); + measuredUsageBySession.delete(sessionID); + tagger.cleanup(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 { @@ -249,27 +427,44 @@ 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 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; - unsafe = inputTokens / limit >= 0.95; - const completed = latest?.data.time?.completed; - if (typeof completed === "number") - updateSessionMeta(db, draft.sessionID, { lastResponseTime: completed }); + const latest = reader.latestAssistant(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 (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: 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(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(), }); @@ -279,7 +474,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; @@ -300,49 +497,66 @@ 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, }, }).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; + // 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, }); 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}`; @@ -408,11 +622,12 @@ export async function registerContext(context: V2Context) { ); transform ??= createTransform({ db, - tagger: createTagger(), + tagger, scheduler: createScheduler({ executeThresholdPercentage: config.execute_threshold_percentage, }), contextUsageMap: usage, + compactionOff: !compactionEnabled, protectedTokens: config.protected_tokens, protectedTokenTierOverrides: getProtectedTokensTierOverrides(config), executeThresholdPercentage: config.execute_threshold_percentage, @@ -427,7 +642,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, @@ -446,60 +663,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); @@ -512,6 +733,23 @@ 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). 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 !== undefined && + measured.modelKey === passModelKey + ) { + updateSessionMeta(db, draft.sessionID, usageMetaPatch(measured)); + } + } 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; @@ -538,19 +776,178 @@ 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 + // 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 + // 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 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 + // 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" }; + // 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", + { message: "Dreaming is not configured for this project.", variant: "warning" }, + sessionId, + ); + 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( + "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; + // 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, + projectIdentity: resolveProjectIdentity(directory) ?? directory, + directory, + language: config.language, + mural: config.mural, + executor: runExecutor, + sessionId, + ...(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 = [ + hasSummaryContent || unsupportedTasks.length === 0 + ? summarizeManualDream(summary) + : undefined, + 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, + }, + 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. + 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(); + sessionCleanupController.abort(); + await sessionCleanupDone; + deletedSessions.clear(); await dreamTrigger?.dispose(); for (const release of rawProviders.values()) release(); rawProviders.clear(); 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..e14e0ac14 --- /dev/null +++ b/packages/plugin/src/v2/hooks/dream-manual.test.ts @@ -0,0 +1,85 @@ +import { expect, test } from "bun:test"; +import { summarizeManualDream } from "../../features/magic-context/dreamer/manual-summary"; +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({}); + 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."); +}); + +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 new file mode 100644 index 000000000..d9d64e538 --- /dev/null +++ b/packages/plugin/src/v2/hooks/dream-manual.ts @@ -0,0 +1,115 @@ +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, + 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"; +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 }; +} + +/** + * 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) { + 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. + 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. + * + * 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 { + 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: selection.runnable, + executor, + ...(args.task !== undefined ? { task: args.task } : {}), + }); + return { summary, unsupportedTasks: selection.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/model-limit-cache.test.ts b/packages/plugin/src/v2/hooks/model-limit-cache.test.ts new file mode 100644 index 000000000..0b5c6e00c --- /dev/null +++ b/packages/plugin/src/v2/hooks/model-limit-cache.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from "bun:test"; +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([ + { + 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([]); +}); + +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 new file mode 100644 index 000000000..dde0cd871 --- /dev/null +++ b/packages/plugin/src/v2/hooks/model-limit-cache.ts @@ -0,0 +1,90 @@ +import { getErrorMessage } from "../../shared/error-message"; +import { sessionLog } from "../../shared/logger"; +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 + * (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, + options: { retries?: number; retryDelayMs?: number } = {}, +): Promise { + if (warmStarted) return; + warmStarted = true; + try { + await refreshModelLimitsFromApi( + { + config: { + providers: async () => ({ + data: { + providers: catalogProvidersPayload( + await Promise.resolve(context.model.list()), + ), + }, + }), + }, + }, + { + 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; + } +} 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; 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..becd3e426 --- /dev/null +++ b/packages/plugin/src/v2/hooks/usage-reading.test.ts @@ -0,0 +1,102 @@ +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("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({ + 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..2edcbb75b --- /dev/null +++ b/packages/plugin/src/v2/hooks/usage-reading.ts @@ -0,0 +1,65 @@ +/** + * 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 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 = + 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 = + 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}` } + : {}), + ...(typeof input.completed === "number" && Number.isFinite(input.completed) + ? { completed: input.completed } + : {}), + }; +} diff --git a/packages/plugin/src/v2/tui/host-contract.test.ts b/packages/plugin/src/v2/tui/host-contract.test.ts index 35a3dd736..e4375af34 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,31 +125,77 @@ 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") }, }); expect(fixture.layers[0]!.commands.map((command) => command.slash.name)).toEqual([ "ctx-status", "ctx-recomp", + "ctx-dream", ]); 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", "ctx-dream"]]); + // 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..5157f6f98 100644 --- a/packages/plugin/src/v2/tui/index.ts +++ b/packages/plugin/src/v2/tui/index.ts @@ -1,11 +1,14 @@ 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, initRpcClient, loadSidebarSnapshot, loadStatusDetail, + requestDream, requestRecomp, } from "../../tui/data/context-db"; import { @@ -13,7 +16,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(); @@ -25,8 +28,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 +56,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}`, @@ -162,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 }) => { @@ -170,38 +207,82 @@ 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 + /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: [ + { + 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", - ); + }, + { + 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; + 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, /ctx-recomp and /ctx-dream were not registered", + ); + } + return null; + }, + }); } const stopListening = context.data.listen(({ details }) => { @@ -255,6 +336,7 @@ export async function setupWithJsx(context: V2TuiContext, jsx: JsxFactory): Prom return () => { unregisterSlot(); + unregisterKeymapSlot?.(); stopListening(); stopNotificationSocket(); closeRpc(); 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", + ); +}); 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; }