diff --git a/packages/plugin/src/features/magic-context/migrations-armed-replay.test.ts b/packages/plugin/src/features/magic-context/migrations-armed-replay.test.ts index a3f18d80b..2b903ee7d 100644 --- a/packages/plugin/src/features/magic-context/migrations-armed-replay.test.ts +++ b/packages/plugin/src/features/magic-context/migrations-armed-replay.test.ts @@ -364,6 +364,7 @@ function populateForVersion(db: DatabaseType, version: number, state: ReplayStat case 76: case 77: case 78: + case 80: if (!state.armed) throw new Error(`migration v${version} reached an unarmed store`); populateModuleOwnedRows(db, version, state); return; @@ -419,7 +420,11 @@ test("every migration lands on populated rows and v72+ stores stay armed", () => installMigrationLedgerFromSource(db); for (const [index, migration] of MIGRATIONS.entries()) { - expect(migration.version).toBe(index + 1); + const expectedVersion = index + 1; + // Temporary merge-order gap: PR #340 owns v79; remove this allowance + // once its migration lands ahead of this PR's v80 migration. + const awaitingPr340 = expectedVersion === 79 && migration.version === 80; + expect(migration.version === expectedVersion || awaitingPr340).toBe(true); assertPopulatedRowsLanded(db, state); applyExactlyOneMigration(db, migration); populateForVersion(db, migration.version, state); diff --git a/packages/plugin/src/features/magic-context/migrations-v74.test.ts b/packages/plugin/src/features/magic-context/migrations-v74.test.ts index 7f4d6cfed..37aeca638 100644 --- a/packages/plugin/src/features/magic-context/migrations-v74.test.ts +++ b/packages/plugin/src/features/magic-context/migrations-v74.test.ts @@ -36,7 +36,7 @@ describe("migration v74: detected context-limit provenance", () => { runMigrations(db); expect(columnNames(db, "session_meta")).toContain("detected_context_limit_provenance"); - expect(LATEST_SUPPORTED_VERSION).toBe(78); + expect(LATEST_SUPPORTED_VERSION).toBe(80); expect(LATEST_SUPPORTED_VERSION).toBe(LATEST_MIGRATION_VERSION); } finally { closeQuietly(db); diff --git a/packages/plugin/src/features/magic-context/migrations-v76.test.ts b/packages/plugin/src/features/magic-context/migrations-v76.test.ts index 11ddb4723..bd33b1429 100644 --- a/packages/plugin/src/features/magic-context/migrations-v76.test.ts +++ b/packages/plugin/src/features/magic-context/migrations-v76.test.ts @@ -43,7 +43,7 @@ describe("migration v76: retina condition compilation", () => { "compile_status", ]), ); - expect(LATEST_SUPPORTED_VERSION).toBe(78); + expect(LATEST_SUPPORTED_VERSION).toBe(80); expect(LATEST_SUPPORTED_VERSION).toBe(LATEST_MIGRATION_VERSION); expect(() => db diff --git a/packages/plugin/src/features/magic-context/migrations-v77.test.ts b/packages/plugin/src/features/magic-context/migrations-v77.test.ts index 5a89dc0da..90008662a 100644 --- a/packages/plugin/src/features/magic-context/migrations-v77.test.ts +++ b/packages/plugin/src/features/magic-context/migrations-v77.test.ts @@ -37,7 +37,7 @@ describe("migration v77: durable candidate provenance", () => { expect(columnNames(db, "user_memories")).toContain("source_candidate_provenance"); expect(columnNames(db, "primers")).toContain("source_candidate_provenance"); - expect(LATEST_SUPPORTED_VERSION).toBe(78); + expect(LATEST_SUPPORTED_VERSION).toBe(80); expect(LATEST_SUPPORTED_VERSION).toBe(LATEST_MIGRATION_VERSION); } finally { closeQuietly(db); diff --git a/packages/plugin/src/features/magic-context/migrations-v78.test.ts b/packages/plugin/src/features/magic-context/migrations-v78.test.ts index 849d973de..c39619da6 100644 --- a/packages/plugin/src/features/magic-context/migrations-v78.test.ts +++ b/packages/plugin/src/features/magic-context/migrations-v78.test.ts @@ -46,7 +46,7 @@ describe("migration v78: migration_pending journal", () => { "phase", "created_at", ]); - expect(LATEST_SUPPORTED_VERSION).toBe(78); + expect(LATEST_SUPPORTED_VERSION).toBe(80); expect(LATEST_SUPPORTED_VERSION).toBe(LATEST_MIGRATION_VERSION); } finally { closeQuietly(db); diff --git a/packages/plugin/src/features/magic-context/migrations-v80.test.ts b/packages/plugin/src/features/magic-context/migrations-v80.test.ts new file mode 100644 index 000000000..18f92834f --- /dev/null +++ b/packages/plugin/src/features/magic-context/migrations-v80.test.ts @@ -0,0 +1,79 @@ +/// + +import { describe, expect, test } from "bun:test"; +import { Database } from "../../shared/sqlite"; +import { closeQuietly } from "../../shared/sqlite-helpers"; +import { LATEST_MIGRATION_VERSION, runMigrations } from "./migrations"; +import { initializeDatabase, LATEST_SUPPORTED_VERSION } from "./storage-db"; + +function seedAppliedVersion(db: Database, version: number): void { + db.exec(` + CREATE TABLE schema_migrations ( + version INTEGER PRIMARY KEY, + description TEXT NOT NULL, + applied_at INTEGER NOT NULL + ); + `); + const insert = db.prepare( + "INSERT INTO schema_migrations (version, description, applied_at) VALUES (?, ?, ?)", + ); + for (let current = 1; current <= version; current += 1) { + insert.run(current, `seed v${current}`, Date.now()); + } +} + +function columnNames(db: Database, table: string): string[] { + return (db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map( + (column) => column.name, + ); +} + +describe("migration v80: tokenless usage observation timestamp", () => { + test("fresh databases include the timestamp and align the schema fence", () => { + const db = new Database(":memory:"); + try { + initializeDatabase(db); + runMigrations(db); + + expect(columnNames(db, "session_meta")).toContain("last_usage_observed_at"); + expect(LATEST_SUPPORTED_VERSION).toBe(80); + expect(LATEST_SUPPORTED_VERSION).toBe(LATEST_MIGRATION_VERSION); + } finally { + closeQuietly(db); + } + }); + + test("replaying from v79 preserves the observation time for legacy token usage", () => { + const db = new Database(":memory:"); + try { + seedAppliedVersion(db, 79); + db.exec(` + CREATE TABLE session_meta ( + session_id TEXT PRIMARY KEY, + last_context_percentage REAL DEFAULT 0, + last_input_tokens INTEGER DEFAULT 0, + last_response_time INTEGER + ); + INSERT INTO session_meta ( + session_id, last_context_percentage, last_input_tokens, last_response_time + ) VALUES ('ses-legacy', 50, 50000, 123); + `); + + runMigrations(db); + runMigrations(db); + + expect( + db + .prepare("SELECT last_usage_observed_at FROM session_meta WHERE session_id = ?") + .get("ses-legacy"), + ).toEqual({ last_usage_observed_at: 123 }); + expect( + db + .prepare("SELECT COUNT(*) AS count FROM schema_migrations WHERE version = 80") + .get(), + ).toEqual({ count: 1 }); + } finally { + closeQuietly(db); + } + }); +}); diff --git a/packages/plugin/src/features/magic-context/migrations.ts b/packages/plugin/src/features/magic-context/migrations.ts index a222fc06c..7092d6d2b 100644 --- a/packages/plugin/src/features/magic-context/migrations.ts +++ b/packages/plugin/src/features/magic-context/migrations.ts @@ -2818,6 +2818,35 @@ export const MIGRATIONS: Migration[] = [ `); }, }, + { + // Temporary merge-order reservation: PR #340 owns v79, so this PR must + // remain v80 even while v79 is absent from this worktree. + version: 80, + description: "persist the original observation time for tokenless usage TTL", + up(db: Database): void { + if (!tableExists(db, "session_meta")) return; + ensureColumn( + db, + "session_meta", + "last_usage_observed_at", + "INTEGER NOT NULL DEFAULT 0", + ); + const columns = new Set( + ( + db.prepare("PRAGMA table_info(session_meta)").all() as Array<{ name: string }> + ).map((row) => row.name), + ); + if (columns.has("last_input_tokens") && columns.has("last_response_time")) { + db.exec(` + UPDATE session_meta + SET last_usage_observed_at = last_response_time + WHERE last_usage_observed_at = 0 + AND last_input_tokens > 0 + AND last_response_time > 0; + `); + } + }, + }, ]; /** diff --git a/packages/plugin/src/features/magic-context/storage-db.test.ts b/packages/plugin/src/features/magic-context/storage-db.test.ts index 20d59c69a..422173d41 100644 --- a/packages/plugin/src/features/magic-context/storage-db.test.ts +++ b/packages/plugin/src/features/magic-context/storage-db.test.ts @@ -22,6 +22,7 @@ import { __resetStoragePrivatePermissionEnforcementForTests, setStoragePrivatePermissionEnforcement, } from "../../shared/storage-permissions"; +import { MIGRATIONS } from "./migrations"; import { __resetRpcDiscoveryFsForTests, __resetSchemaFenceStateForTests, @@ -47,6 +48,11 @@ import { clearSession } from "./storage-meta-session"; const tempDirs: string[] = []; const originalXdgDataHome = process.env.XDG_DATA_HOME; +const PREVIOUS_MIGRATION_VERSION = Math.max( + ...MIGRATIONS.filter((migration) => migration.version < LATEST_SUPPORTED_VERSION).map( + (migration) => migration.version, + ), +); function makeTempDir(prefix: string): string { const dir = mkdtempSync(join(tmpdir(), prefix)); @@ -492,7 +498,7 @@ describe("storage-db", () => { expect(opened === null).toBe(scenario.blocksMigration); expect(readPersistedVersion(dbPath)).toBe( scenario.blocksMigration - ? LATEST_SUPPORTED_VERSION - 1 + ? PREVIOUS_MIGRATION_VERSION : LATEST_SUPPORTED_VERSION, ); if (!scenario.blocksMigration && pid === process.pid) { @@ -523,7 +529,7 @@ describe("storage-db", () => { expect(openDatabase()).toBeNull(); expect(getMigrationOnOpenRefusal()).toEqual({ - persistedVersion: LATEST_SUPPORTED_VERSION - 1, + persistedVersion: PREVIOUS_MIGRATION_VERSION, supportedVersion: LATEST_SUPPORTED_VERSION, serverPids: [41001], blockingProcesses: [{ kind: "Pi", pid: 41001 }], @@ -534,13 +540,13 @@ describe("storage-db", () => { expect( formatLiveProcessMigrationRefusal( dbPath, - LATEST_SUPPORTED_VERSION - 1, + PREVIOUS_MIGRATION_VERSION, LATEST_SUPPORTED_VERSION, [], [41001], ), ).toContain("confirmed Pi harness PID 41001"); - expect(readPersistedVersion(dbPath)).toBe(LATEST_SUPPORTED_VERSION - 1); + expect(readPersistedVersion(dbPath)).toBe(PREVIOUS_MIGRATION_VERSION); }); it("#when an unrelated Pi harness is live #then opens a fresh explicit-path database", () => { @@ -647,14 +653,14 @@ describe("storage-db", () => { // insurance for files left by older or interrupted installations. expect(openDatabase()).toBeNull(); expect(getMigrationOnOpenRefusal()).toEqual({ - persistedVersion: LATEST_SUPPORTED_VERSION - 1, + persistedVersion: PREVIOUS_MIGRATION_VERSION, supportedVersion: LATEST_SUPPORTED_VERSION, serverPids: [], blockingProcesses: [], unreadableFile: portFile, unreadableArm: "parse", }); - expect(readPersistedVersion(dbPath)).toBe(LATEST_SUPPORTED_VERSION - 1); + expect(readPersistedVersion(dbPath)).toBe(PREVIOUS_MIGRATION_VERSION); }); it("#when old malformed and pidless records are discovered #then deletes them and allows migration", () => { @@ -705,7 +711,7 @@ describe("storage-db", () => { ); expect(getMigrationOnOpenRefusal()?.unreadableArm).toBe("parse"); for (const file of junk) expect(existsSync(file)).toBe(true); - expect(readPersistedVersion(dbPath)).toBe(LATEST_SUPPORTED_VERSION - 1); + expect(readPersistedVersion(dbPath)).toBe(PREVIOUS_MIGRATION_VERSION); }); it("#when a port path cannot be read as a file #then refuses migration and names the io arm", () => { @@ -720,7 +726,7 @@ describe("storage-db", () => { unreadableFile, unreadableArm: "io", }); - expect(readPersistedVersion(dbPath)).toBe(LATEST_SUPPORTED_VERSION - 1); + expect(readPersistedVersion(dbPath)).toBe(PREVIOUS_MIGRATION_VERSION); }); it("#when reading a port file returns EACCES #then refuses migration without deleting it", () => { @@ -747,7 +753,7 @@ describe("storage-db", () => { unreadableArm: "io", }); expect(existsSync(unreadableFile)).toBe(true); - expect(readPersistedVersion(dbPath)).toBe(LATEST_SUPPORTED_VERSION - 1); + expect(readPersistedVersion(dbPath)).toBe(PREVIOUS_MIGRATION_VERSION); }); it("#when stale junk cleanup returns EACCES #then refuses migration with the cleanup file named", () => { @@ -776,7 +782,7 @@ describe("storage-db", () => { unreadableArm: "io", }); expect(existsSync(staleFile)).toBe(true); - expect(readPersistedVersion(dbPath)).toBe(LATEST_SUPPORTED_VERSION - 1); + expect(readPersistedVersion(dbPath)).toBe(PREVIOUS_MIGRATION_VERSION); }); it("#when the RPC directory cannot be enumerated #then refuses migration", () => { @@ -787,7 +793,7 @@ describe("storage-db", () => { expect(openDatabase()).toBeNull(); expect(getMigrationOnOpenRefusal()?.unreadableFile).toBe(rpcPath); - expect(readPersistedVersion(dbPath)).toBe(LATEST_SUPPORTED_VERSION - 1); + expect(readPersistedVersion(dbPath)).toBe(PREVIOUS_MIGRATION_VERSION); }); it("#when an alive PID is reused by a newer process #then removes the record and allows migration", () => { @@ -822,7 +828,7 @@ describe("storage-db", () => { const legacy = new Database(dbPath); legacy.exec(` CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY); - INSERT INTO schema_migrations(version) VALUES (${LATEST_SUPPORTED_VERSION - 1}); + INSERT INTO schema_migrations(version) VALUES (${PREVIOUS_MIGRATION_VERSION}); `); legacy.close(); @@ -852,7 +858,7 @@ describe("storage-db", () => { }); for (const junkFile of junkFiles) expect(existsSync(junkFile)).toBe(false); expect(existsSync(livePortFile)).toBe(true); - expect(readPersistedVersion(dbPath)).toBe(LATEST_SUPPORTED_VERSION - 1); + expect(readPersistedVersion(dbPath)).toBe(PREVIOUS_MIGRATION_VERSION); }); it("#when a discovery record provides a process kind #then it takes precedence over command probes", () => { @@ -862,7 +868,7 @@ describe("storage-db", () => { const legacy = new Database(dbPath); legacy.exec(` CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY); - INSERT INTO schema_migrations(version) VALUES (${LATEST_SUPPORTED_VERSION - 1}); + INSERT INTO schema_migrations(version) VALUES (${PREVIOUS_MIGRATION_VERSION}); INSERT INTO schema_migrations(version) VALUES (${FORK_MIGRATION_VERSION_FLOOR}); `); legacy.close(); @@ -893,7 +899,7 @@ describe("storage-db", () => { const legacy = new Database(dbPath); legacy.exec(` CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY); - INSERT INTO schema_migrations(version) VALUES (${LATEST_SUPPORTED_VERSION - 1}); + INSERT INTO schema_migrations(version) VALUES (${PREVIOUS_MIGRATION_VERSION}); INSERT INTO schema_migrations(version) VALUES (${FORK_MIGRATION_VERSION_FLOOR}); `); legacy.close(); @@ -931,7 +937,7 @@ describe("storage-db", () => { for (const junkFile of junkFiles) expect(existsSync(junkFile)).toBe(false); expect(existsSync(livePortFile)).toBe(true); expect(getMigrationOnOpenRefusal()).toEqual({ - persistedVersion: LATEST_SUPPORTED_VERSION - 1, + persistedVersion: PREVIOUS_MIGRATION_VERSION, supportedVersion: LATEST_SUPPORTED_VERSION, serverPids: [process.pid], blockingProcesses: [{ kind: "process", pid: process.pid }], @@ -940,7 +946,7 @@ describe("storage-db", () => { { kind: "process", pid: process.pid }, ]); const unchanged = new Database(dbPath); - expect(getPersistedSchemaVersion(unchanged)).toBe(LATEST_SUPPORTED_VERSION - 1); + expect(getPersistedSchemaVersion(unchanged)).toBe(PREVIOUS_MIGRATION_VERSION); expect( unchanged .prepare("SELECT 1 FROM schema_migrations WHERE version = ?") diff --git a/packages/plugin/src/features/magic-context/storage-db.ts b/packages/plugin/src/features/magic-context/storage-db.ts index c0e99fc9d..03ca00a92 100644 --- a/packages/plugin/src/features/magic-context/storage-db.ts +++ b/packages/plugin/src/features/magic-context/storage-db.ts @@ -93,7 +93,7 @@ export function __resetSchemaFenceStateForTests(): void { lastMigrationOnOpenRefusal = null; } -export const LATEST_SUPPORTED_VERSION = 78; +export const LATEST_SUPPORTED_VERSION = 80; // chmod is meaningless on Windows (POSIX modes are not honored), so all // permission tightening is skipped there. mkdir's `mode` is likewise ignored. @@ -1475,6 +1475,7 @@ CREATE INDEX IF NOT EXISTS idx_dream_queue_pending ON dream_queue(started_at, en cached_m1_bytes BLOB, last_observed_model_key TEXT, last_usage_context_limit INTEGER NOT NULL DEFAULT 0, + last_usage_observed_at INTEGER NOT NULL DEFAULT 0, prior_boundary_ordinal INTEGER NOT NULL DEFAULT 1, protected_tail_policy_version INTEGER NOT NULL DEFAULT 0, protected_tail_drain_window_started_at INTEGER NOT NULL DEFAULT 0, @@ -1880,6 +1881,7 @@ CREATE INDEX IF NOT EXISTS idx_dream_queue_pending ON dream_queue(started_at, en ensureColumn(db, "session_meta", "cached_m1_bytes", "BLOB"); ensureColumn(db, "session_meta", "last_observed_model_key", "TEXT"); ensureColumn(db, "session_meta", "last_usage_context_limit", "INTEGER NOT NULL DEFAULT 0"); + ensureColumn(db, "session_meta", "last_usage_observed_at", "INTEGER NOT NULL DEFAULT 0"); ensureColumn(db, "session_meta", "prior_boundary_ordinal", "INTEGER NOT NULL DEFAULT 1"); ensureColumn(db, "session_meta", "protected_tail_policy_version", "INTEGER NOT NULL DEFAULT 0"); ensureColumn( diff --git a/packages/plugin/src/features/magic-context/storage-meta-persisted.ts b/packages/plugin/src/features/magic-context/storage-meta-persisted.ts index bfdc27198..c11eabc5d 100644 --- a/packages/plugin/src/features/magic-context/storage-meta-persisted.ts +++ b/packages/plugin/src/features/magic-context/storage-meta-persisted.ts @@ -10,6 +10,8 @@ import { stableStringify } from "../../shared/stable-json"; import { ensureSessionMetaRow } from "./storage-meta-shared"; import type { ContextUsage } from "./types"; +export const CONTEXT_USAGE_TTL_MS = 60 * 60 * 1_000; + const emergencyRecoveryArmedSessions = new Set(); const emergencyRecoveryArmedAtBySession = new Map(); const providerOverflowReconfirmedSessions = new Set(); @@ -38,6 +40,7 @@ interface PersistedUsageRow { last_response_time: number; last_observed_model_key: string | null; last_usage_context_limit: number | null; + last_usage_observed_at: number; } interface PersistedReasoningWatermarkRow { @@ -198,7 +201,8 @@ function isPersistedUsageRow(row: unknown): row is PersistedUsageRow { typeof r.last_input_tokens === "number" && typeof r.last_response_time === "number" && (typeof r.last_observed_model_key === "string" || r.last_observed_model_key === null) && - (typeof r.last_usage_context_limit === "number" || r.last_usage_context_limit === null) + (typeof r.last_usage_context_limit === "number" || r.last_usage_context_limit === null) && + typeof r.last_usage_observed_at === "number" ); } @@ -297,12 +301,15 @@ function getDefaultHistorianFailureState(): PersistedHistorianFailureState { export function loadPersistedUsage(db: Database, sessionId: string): PersistedUsageState | null { const result = db .prepare( - "SELECT last_context_percentage, last_input_tokens, last_response_time, last_observed_model_key, last_usage_context_limit FROM session_meta WHERE session_id = ?", + "SELECT last_context_percentage, last_input_tokens, last_response_time, last_observed_model_key, last_usage_context_limit, last_usage_observed_at FROM session_meta WHERE session_id = ?", ) .get(sessionId); + if (!isPersistedUsageRow(result)) return null; + const observedAt = result.last_usage_observed_at || result.last_response_time; if ( - !isPersistedUsageRow(result) || + observedAt <= 0 || + Date.now() - observedAt > CONTEXT_USAGE_TTL_MS || (result.last_context_percentage === 0 && result.last_input_tokens === 0) ) { return null; @@ -313,7 +320,7 @@ export function loadPersistedUsage(db: Database, sessionId: string): PersistedUs percentage: result.last_context_percentage, inputTokens: result.last_input_tokens, }, - updatedAt: result.last_response_time || Date.now(), + updatedAt: observedAt, lastObservedModelKey: result.last_observed_model_key, lastUsageContextLimit: typeof result.last_usage_context_limit === "number" diff --git a/packages/plugin/src/features/magic-context/storage-meta-shared.ts b/packages/plugin/src/features/magic-context/storage-meta-shared.ts index a478b44b9..4fff04550 100644 --- a/packages/plugin/src/features/magic-context/storage-meta-shared.ts +++ b/packages/plugin/src/features/magic-context/storage-meta-shared.ts @@ -49,6 +49,7 @@ export interface SessionMetaRow { cached_m0_project_identity: string | null; last_observed_model_key: string | null; last_usage_context_limit: number | null; + last_usage_observed_at: number | null; prior_boundary_ordinal: number | null; protected_tail_policy_version: number | null; protected_tail_drain_window_started_at: number | null; @@ -107,6 +108,7 @@ export const SESSION_META_SELECT_COLUMNS = [ "cached_m0_project_identity", "last_observed_model_key", "last_usage_context_limit", + "last_usage_observed_at", "prior_boundary_ordinal", "protected_tail_policy_version", "protected_tail_drain_window_started_at", @@ -164,6 +166,7 @@ export const META_COLUMNS: Record = { cachedM0ProjectIdentity: "cached_m0_project_identity", lastObservedModelKey: "last_observed_model_key", lastUsageContextLimit: "last_usage_context_limit", + lastUsageObservedAt: "last_usage_observed_at", priorBoundaryOrdinal: "prior_boundary_ordinal", protectedTailPolicyVersion: "protected_tail_policy_version", protectedTailDrainWindowStartedAt: "protected_tail_drain_window_started_at", @@ -289,6 +292,7 @@ export function isSessionMetaRow(row: unknown): row is SessionMetaRow { isStringOrNull(r.cached_m0_project_identity) && isStringOrNull(r.last_observed_model_key) && isNumberOrNull(r.last_usage_context_limit) && + isNumberOrNull(r.last_usage_observed_at) && isNumberOrNull(r.prior_boundary_ordinal) && isNumberOrNull(r.protected_tail_policy_version) && isNumberOrNull(r.protected_tail_drain_window_started_at) && @@ -348,6 +352,7 @@ export function getDefaultSessionMeta(sessionId: string): SessionMeta { cachedM0ProjectIdentity: null, lastObservedModelKey: null, lastUsageContextLimit: 0, + lastUsageObservedAt: 0, priorBoundaryOrdinal: 1, protectedTailPolicyVersion: 0, protectedTailDrainWindowStartedAt: 0, @@ -468,6 +473,7 @@ export function toSessionMeta(row: SessionMetaRow): SessionMeta { cachedM0ProjectIdentity: stringOrNull(row.cached_m0_project_identity), lastObservedModelKey: stringOrNull(row.last_observed_model_key), lastUsageContextLimit: numOrZero(row.last_usage_context_limit), + lastUsageObservedAt: numOrZero(row.last_usage_observed_at), priorBoundaryOrdinal: Math.max(1, numOrZero(row.prior_boundary_ordinal) || 1), protectedTailPolicyVersion: numOrZero(row.protected_tail_policy_version), protectedTailDrainWindowStartedAt: numOrZero(row.protected_tail_drain_window_started_at), diff --git a/packages/plugin/src/features/magic-context/types.ts b/packages/plugin/src/features/magic-context/types.ts index bfbcdfe44..5c210303e 100644 --- a/packages/plugin/src/features/magic-context/types.ts +++ b/packages/plugin/src/features/magic-context/types.ts @@ -108,6 +108,7 @@ export interface SessionMeta { cachedM0ProjectIdentity: string | null; lastObservedModelKey: string | null; lastUsageContextLimit: number; + lastUsageObservedAt: number; priorBoundaryOrdinal: number; protectedTailPolicyVersion: number; protectedTailDrainWindowStartedAt: number; diff --git a/packages/plugin/src/hooks/magic-context/event-handler.test.ts b/packages/plugin/src/hooks/magic-context/event-handler.test.ts index bb3a4746d..a692101ab 100644 --- a/packages/plugin/src/hooks/magic-context/event-handler.test.ts +++ b/packages/plugin/src/hooks/magic-context/event-handler.test.ts @@ -14,6 +14,7 @@ import { getHistorianFailureState, getMaxCompressionDepth, getOrCreateSessionMeta, + getOverflowState, getStrippedPlaceholderIds, getTagsBySession, incrementCompressionDepth, @@ -42,6 +43,8 @@ import type { ContextUsage } from "../../features/magic-context/types"; import { getWindowReportsPath } from "../../features/magic-context/window-report-ledger"; import { clearModelsDevCache, refreshModelLimitsFromApi } from "../../shared/models-dev-cache"; import { createEventHandler } from "./event-handler"; +import { createEventHook } from "./hook-handlers"; +import { loadContextUsage } from "./transform-context-state"; type ContextUsageCacheEntry = { usage: ContextUsage; @@ -159,6 +162,29 @@ function providersClient(limit: number, prompt?: ReturnType) { }; } +function createHostEventHook( + deps: ReturnType, + contextUsageMap: Map, +) { + return createEventHook({ + eventHandler: createEventHandler(deps), + contextUsageMap, + db: deps.db, + liveModelBySession: new Map(), + variantBySession: new Map(), + agentBySession: new Map(), + sessionDirectoryBySession: new Map(), + historyRefreshSessions: new Set(), + deferredHistoryRefreshSessions: new Set(), + systemPromptRefreshSessions: new Set(), + pendingMaterializationSessions: new Set(), + deferredMaterializationSessions: new Set(), + lastHeuristicsTurnId: new Map(), + client: deps.client as never, + protectedTags: 5, + }); +} + describe("createEventHandler", () => { it("normalizes transform decision reasons across harnesses", () => { expect(normalizeMaterializeReason("opencode", "system_hash", true)).toBe("system_hash"); @@ -546,6 +572,280 @@ describe("createEventHandler", () => { expect(contextUsageMap.get("ses-regression-recovered")?.usage.percentage).toBe(90); }); + it("uses an output-length completion after the real model-switch reset to correct a stale low limit", async () => { + // Given: a prior model established a small safe-token baseline while the + // catalog advertises only 128k for the next model. + useTempDataHome("context-event-model-switch-first-sample-"); + await refreshModelLimitsFromApi(providersClient(128_000)); + const contextUsageMap = new Map(); + const deps = createDeps(contextUsageMap); + deps.client = providersClient(128_000); + const hook = createHostEventHook(deps, contextUsageMap); + await hook({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + finish: "stop", + sessionID: "ses-model-switch-first-sample", + providerID: "test-provider", + modelID: "old-model", + tokens: { input: 1_000, cache: { read: 0, write: 0 } }, + }, + }, + }, + }); + + // When: an error-free output-length response on the new model proves + // a 10M-token prompt, including cache reads and writes, succeeded. + await hook({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + finish: "length", + sessionID: "ses-model-switch-first-sample", + providerID: "test-provider", + modelID: "test-model", + tokens: { input: 6_000_000, cache: { read: 3_000_000, write: 1_000_000 } }, + }, + }, + }, + }); + + // Then: shared metadata and live pressure both use the proven lower bound. + const meta = getOrCreateSessionMeta(deps.db, "ses-model-switch-first-sample"); + expect(contextUsageMap.get("ses-model-switch-first-sample")?.usage).toEqual({ + inputTokens: 10_000_000, + percentage: 100, + }); + expect(meta.lastUsageContextLimit).toBe(10_000_000); + expect(getOverflowState(deps.db, "ses-model-switch-first-sample")).toMatchObject({ + detectedContextLimit: 0, + detectedContextLimitModelKey: null, + detectedContextLimitProvenance: "unknown", + }); + }); + + it("preserves new-model overflow recovery when overflow is the first event after a switch", async () => { + // Given: model A is the last observed live model. + useTempDataHome("context-event-model-switch-overflow-first-"); + const contextUsageMap = new Map(); + const deps = createDeps(contextUsageMap); + const hook = createHostEventHook(deps, contextUsageMap); + const sessionID = "ses-model-switch-overflow-first"; + await hook({ + event: { + type: "message.updated", + properties: { + info: { + id: "msg-model-a", + role: "assistant", + finish: "stop", + sessionID, + providerID: "test-provider", + modelID: "model-a", + tokens: { input: 1_000, cache: { read: 0, write: 0 } }, + }, + }, + }, + }); + + // When: the first model-B event overflows, followed by a successful B response. + await hook({ + event: { + type: "message.updated", + properties: { + info: { + id: "msg-model-b-overflow", + role: "assistant", + finish: "error", + sessionID, + providerID: "test-provider", + modelID: "model-b", + error: "This model's maximum context length is 120000 tokens.", + }, + }, + }, + }); + expect(getOverflowState(deps.db, sessionID)).toMatchObject({ + detectedContextLimit: 120_000, + detectedContextLimitModelKey: "test-provider/model-b", + needsEmergencyRecovery: true, + }); + await hook({ + event: { + type: "message.updated", + properties: { + info: { + id: "msg-model-b-success", + role: "assistant", + finish: "stop", + sessionID, + providerID: "test-provider", + modelID: "model-b", + tokens: { input: 1_000, cache: { read: 0, write: 0 } }, + }, + }, + }, + }); + + // Then: model B's detected limit and recovery latch survive its success. + expect(getOverflowState(deps.db, sessionID)).toMatchObject({ + detectedContextLimit: 120_000, + detectedContextLimitModelKey: "test-provider/model-b", + needsEmergencyRecovery: true, + }); + }); + + for (const completion of [ + { + name: "an error-bearing completed event", + finish: undefined, + completed: 1, + error: "boom", + }, + { name: "finish:error", finish: "error", completed: undefined, error: undefined }, + { + name: "a nonterminal numeric usage event", + finish: undefined, + completed: undefined, + error: undefined, + }, + ]) { + it(`does not update ordinary usage from ${completion.name}`, async () => { + // Given: ordinary usage, a pending transform decision, and historian + // failure state established by the prior successful model. + useTempDataHome("context-event-model-switch-failed-sample-"); + await refreshModelLimitsFromApi(providersClient(128_000)); + const contextUsageMap = new Map(); + const deps = createDeps(contextUsageMap); + deps.client = providersClient(128_000); + const hook = createHostEventHook(deps, contextUsageMap); + await hook({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + finish: "stop", + sessionID: "ses-model-switch-failed-sample", + providerID: "test-provider", + modelID: "old-model", + tokens: { input: 100_000, cache: { read: 0, write: 0 } }, + }, + }, + }, + }); + incrementHistorianFailure(deps.db, "ses-model-switch-failed-sample", "failed"); + recordPendingTransformDecision("ses-model-switch-failed-sample", { + tsMs: 1, + decision: "execute", + materialized: true, + materializeReason: "pressure_refold", + emergency: false, + droppedTokens: 0, + droppedCount: 1, + inputTokens: 100_000, + bustedThisPass: true, + }); + const baselineMeta = getOrCreateSessionMeta(deps.db, "ses-model-switch-failed-sample"); + const baselineUsage = contextUsageMap.get("ses-model-switch-failed-sample"); + + // When: the new model reports usage on a failed completion shape. + await hook({ + event: { + type: "message.updated", + properties: { + info: { + id: "msg-model-switch-failed-sample", + role: "assistant", + finish: completion.finish, + time: + completion.completed === undefined + ? undefined + : { completed: completion.completed }, + error: completion.error, + sessionID: "ses-model-switch-failed-sample", + providerID: "test-provider", + modelID: "test-model", + tokens: { input: 258_901, cache: { read: 0, write: 0 } }, + }, + }, + }, + }); + await waitForTimers(); + + // Then: live pressure, persisted pressure, historian recovery, and + // the pending transform decision remain exactly as they were. + expect(contextUsageMap.get("ses-model-switch-failed-sample")).toEqual(baselineUsage); + const meta = getOrCreateSessionMeta(deps.db, "ses-model-switch-failed-sample"); + expect(meta.lastContextPercentage).toBe(baselineMeta.lastContextPercentage); + expect(meta.lastInputTokens).toBe(baselineMeta.lastInputTokens); + expect(meta.lastUsageContextLimit).toBe(baselineMeta.lastUsageContextLimit); + expect(meta.lastObservedModelKey).toBe(baselineMeta.lastObservedModelKey); + expect(meta.observedSafeInputTokens).toBe(baselineMeta.observedSafeInputTokens); + expect( + getHistorianFailureState(deps.db, "ses-model-switch-failed-sample").failureCount, + ).toBe(1); + const row = deps.db + .prepare("SELECT COUNT(*) AS count FROM transform_decisions WHERE session_id = ?") + .get("ses-model-switch-failed-sample") as { count: number }; + expect(row.count).toBe(0); + expect( + transformDecisionLogTest.getPending("ses-model-switch-failed-sample"), + ).toBeDefined(); + }); + } + + for (const malformed of [ + { name: "fractional input", tokens: { input: 1.5, cache: { read: 0, write: 0 } } }, + { name: "negative cache usage", tokens: { input: 1, cache: { read: -1, write: 0 } } }, + { + name: "cache-inclusive integer overflow", + tokens: { input: Number.MAX_SAFE_INTEGER, cache: { read: 1, write: 0 } }, + }, + ]) { + it(`rejects ${malformed.name}`, async () => { + // Given: stale 128k catalog metadata and no prior usage. + useTempDataHome("context-event-model-switch-malformed-sample-"); + await refreshModelLimitsFromApi(providersClient(128_000)); + const contextUsageMap = new Map(); + const deps = createDeps(contextUsageMap); + deps.client = providersClient(128_000); + const hook = createHostEventHook(deps, contextUsageMap); + + // When: the host reports malformed cache-inclusive usage. + await hook({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + finish: "stop", + sessionID: "ses-model-switch-malformed-sample", + providerID: "test-provider", + modelID: "test-model", + tokens: malformed.tokens, + }, + }, + }, + }); + + // Then: malformed usage cannot overwrite metadata or live pressure. + expect( + getOverflowState(deps.db, "ses-model-switch-malformed-sample").detectedContextLimit, + ).toBe(0); + expect(contextUsageMap.get("ses-model-switch-malformed-sample")).toBeUndefined(); + expect( + getOrCreateSessionMeta(deps.db, "ses-model-switch-malformed-sample") + .observedSafeInputTokens, + ).toBe(0); + }); + } + it("alerts once when a cache-regressed context limit stays wrong after refresh", async () => { useTempDataHome("context-event-cache-regression-alert-"); const contextUsageMap = new Map(); @@ -596,7 +896,7 @@ describe("createEventHandler", () => { const meta = getOrCreateSessionMeta(openDatabase(), "ses-regression-alert"); expect(meta.cacheAlertSent).toBe(true); - expect(meta.lastContextPercentage).toBe(400); + expect(meta.lastContextPercentage).toBe(100); expect(prompt).toHaveBeenCalledTimes(1); const call = prompt.mock.calls[0]?.[0] as { body?: { parts?: Array<{ text?: string }> } }; expect(call.body?.parts?.[0]?.text).toContain("context limit of 30,000 tokens"); @@ -670,6 +970,7 @@ describe("createEventHandler", () => { }); deps.config.cache_ttl = { default: "5m", "openai/gpt-4o": "1m" }; const handler = createEventHandler(deps); + const before = Date.now(); await handler({ event: { @@ -686,6 +987,7 @@ describe("createEventHandler", () => { const meta = getOrCreateSessionMeta(openDatabase(), "ses-partial"); expect(meta.cacheTtl).toBe("1m"); + expect(meta.lastResponseTime).toBeGreaterThanOrEqual(before); expect(meta.lastContextPercentage).toBe(61); expect(meta.lastInputTokens).toBe(122_000); expect(contextUsageMap.get("ses-partial")).toEqual({ @@ -694,6 +996,96 @@ describe("createEventHandler", () => { }); }); + it("expires tokenless-restored usage across a process restart", async () => { + // Given: usage was observed at T0, then a tokenless terminal update arrived later. + useTempDataHome("context-event-tokenless-restart-"); + const realNow = Date.now; + const observedAt = realNow(); + const contextUsageMap = new Map(); + const deps = createDeps(contextUsageMap); + const handler = createEventHandler(deps); + const sessionID = "ses-tokenless-restart"; + try { + Date.now = () => observedAt; + await handler({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + finish: "stop", + sessionID, + tokens: { input: 64_000, cache: { read: 0, write: 0 } }, + }, + }, + }, + }); + Date.now = () => observedAt + 30 * 60 * 1_000; + await handler({ + event: { + type: "message.updated", + properties: { + info: { role: "assistant", finish: "stop", sessionID }, + }, + }, + }); + + // When: the process restarts after the original usage observation expires. + closeDatabase(); + Date.now = () => observedAt + 61 * 60 * 1_000; + const restoredUsage = loadContextUsage(new Map(), openDatabase(), sessionID); + + // Then: the newer tokenless response cannot extend stale usage forever. + expect(restoredUsage).toEqual({ percentage: 0, inputTokens: 0 }); + } finally { + Date.now = realNow; + } + }); + + it("refreshes response timing from fresh persisted usage after restart", async () => { + useTempDataHome("context-event-tokenless-restart-refresh-"); + const realNow = Date.now; + const observedAt = realNow(); + const sessionID = "ses-tokenless-restart-refresh"; + try { + Date.now = () => observedAt; + const firstHandler = createEventHandler(createDeps(new Map())); + await firstHandler({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + finish: "stop", + sessionID, + tokens: { input: 64_000, cache: { read: 0, write: 0 } }, + }, + }, + }, + }); + + closeDatabase(); + Date.now = () => observedAt + 30 * 60 * 1_000; + const restartedUsage = new Map(); + const restartedHandler = createEventHandler(createDeps(restartedUsage)); + await restartedHandler({ + event: { + type: "message.updated", + properties: { + info: { role: "assistant", finish: "stop", sessionID }, + }, + }, + }); + + const meta = getOrCreateSessionMeta(openDatabase(), sessionID); + expect(meta.lastResponseTime).toBe(observedAt + 30 * 60 * 1_000); + expect(meta.lastUsageObservedAt).toBe(observedAt); + expect(restartedUsage.get(sessionID)?.usage.inputTokens).toBe(64_000); + } finally { + Date.now = realNow; + } + }); + it("ignores tokenless assistant updates when no prior usage exists", async () => { useTempDataHome("context-event-no-finish-"); const handler = createEventHandler(createDeps(new Map())); @@ -781,6 +1173,7 @@ describe("createEventHandler", () => { properties: { info: { role: "assistant", + finish: "stop", sessionID: "ses-bg", tokens: { input: 120_000, cache: { read: 12_000, write: 0 } }, }, diff --git a/packages/plugin/src/hooks/magic-context/event-handler.ts b/packages/plugin/src/hooks/magic-context/event-handler.ts index 559bc8546..34b4c2840 100644 --- a/packages/plugin/src/hooks/magic-context/event-handler.ts +++ b/packages/plugin/src/hooks/magic-context/event-handler.ts @@ -14,6 +14,7 @@ import { getPendingCompactionMarkerState, getPersistedNoteNudge, getPersistedReasoningWatermark, + loadPersistedUsage, markSessionCleanupPending, recordDetectedContextLimit, recordOverflowDetected, @@ -24,6 +25,7 @@ import { updateSessionMeta, } from "../../features/magic-context/storage"; import { + CONTEXT_USAGE_TTL_MS, getChannel2NudgeState, getPersistedCompactionMarkerState, } from "../../features/magic-context/storage-meta-persisted"; @@ -47,6 +49,7 @@ import { getSessionCreatedInfo, getSessionErrorInfo, getSessionProperties, + isSuccessfulHostEvent, } from "./event-payloads"; import { resolveCacheTtl, @@ -62,8 +65,6 @@ import { type NotificationParams, sendIgnoredMessage } from "./send-session-noti import { clearMessageTokensCache } from "./transform"; import { resetDegradedCacheCount } from "./transform-postprocess-phase"; -const CONTEXT_USAGE_TTL_MS = 60 * 60 * 1000; - type CacheTtlConfig = string | Record; interface ContextUsageEntry { @@ -424,8 +425,6 @@ export function createEventHandler(deps: EventHandlerDeps) { }); } - let messageHadOverflowError = false; - // Secondary overflow-detection path: OpenCode attaches overflow // errors to the assistant message itself in addition to emitting // session.error. Checking both ensures we catch the error no @@ -435,7 +434,6 @@ export function createEventHandler(deps: EventHandlerDeps) { if (info.error !== undefined && info.error !== null) { const detection = detectOverflow(info.error); if (detection.isOverflow) { - messageHadOverflowError = true; try { captureWindowReport({ db: deps.db, @@ -518,28 +516,38 @@ export function createEventHandler(deps: EventHandlerDeps) { } } + const successfulTerminalEvent = isSuccessfulHostEvent(info); + if ((info.error !== undefined && info.error !== null) || info.finish === "error") { + return; + } + const now = Date.now(); const usageTokens = [ info.tokens?.input, info.tokens?.cache?.read, info.tokens?.cache?.write, ]; - const hasUsageTokens = usageTokens.some( - (value) => typeof value === "number" && value > 0, + const totalInputTokens = usageTokens.reduce( + (total, value) => total + (value ?? 0), + 0, ); + const hasUsageTokens = + usageTokens.every( + (value) => value === undefined || (Number.isSafeInteger(value) && value >= 0), + ) && + usageTokens.some((value) => typeof value === "number" && value > 0) && + Number.isSafeInteger(totalInputTokens); + if (!successfulTerminalEvent && usageTokens.some((value) => value !== undefined)) { + return; + } const terminalAssistantUpdate = - info.messageID !== undefined && - hasUsageTokens && - (typeof info.finish === "string" || typeof info.completedAt === "number"); + info.messageID !== undefined && hasUsageTokens && successfulTerminalEvent; if (terminalAssistantUpdate && info.messageID) { scheduleOpenCodeTransformDecisionWrite({ db: deps.db, sessionId: info.sessionID, messageId: info.messageID, - inputTokens: - (info.tokens?.input ?? 0) + - (info.tokens?.cache?.read ?? 0) + - (info.tokens?.cache?.write ?? 0), + inputTokens: totalInputTokens, }); } @@ -548,7 +556,25 @@ export function createEventHandler(deps: EventHandlerDeps) { `event message.updated: provider=${info.providerID} model=${info.modelID} hasUsageTokens=${hasUsageTokens} tokens.input=${info.tokens?.input} cache.read=${info.tokens?.cache?.read} cache.write=${info.tokens?.cache?.write}`, ); - const hasKnownUsage = hasUsageTokens || deps.contextUsageMap.has(info.sessionID); + let hasKnownUsage = hasUsageTokens || deps.contextUsageMap.has(info.sessionID); + if (!hasKnownUsage) { + try { + const persisted = loadPersistedUsage(deps.db, info.sessionID); + if (persisted) { + deps.contextUsageMap.set(info.sessionID, { + ...persisted, + hasUsageTokens: false, + }); + hasKnownUsage = true; + } + } catch (error) { + sessionLog( + info.sessionID, + "event message.updated usage restore failed:", + error, + ); + } + } if (!hasKnownUsage) { sessionLog( info.sessionID, @@ -570,10 +596,6 @@ export function createEventHandler(deps: EventHandlerDeps) { } if (hasUsageTokens) { - const totalInputTokens = - (info.tokens?.input ?? 0) + - (info.tokens?.cache?.read ?? 0) + - (info.tokens?.cache?.write ?? 0); // Auth is provably live now (a request returned usage), so // re-warm the model-limit cache once per process to overwrite // any stale pre-auth limit (e.g. gpt-5.5 cached at the raw @@ -588,6 +610,7 @@ export function createEventHandler(deps: EventHandlerDeps) { db: deps.db, sessionID: info.sessionID, }); + let usageContextLimit = contextLimit; let percentage = contextLimit > 0 ? (totalInputTokens / contextLimit) * 100 : 0; sessionLog( @@ -599,8 +622,9 @@ export function createEventHandler(deps: EventHandlerDeps) { const observedSafeInputTokens = sessionMeta.observedSafeInputTokens ?? 0; if ( percentage > 100 && - observedSafeInputTokens > 0 && - totalInputTokens <= observedSafeInputTokens * 2 + modelKey !== undefined && + (observedSafeInputTokens === 0 || + totalInputTokens <= observedSafeInputTokens * 2) ) { const oldLimit = contextLimit; if (deps.client) { @@ -612,6 +636,7 @@ export function createEventHandler(deps: EventHandlerDeps) { sessionID: info.sessionID, }); if (contextLimit >= totalInputTokens) { + usageContextLimit = contextLimit; percentage = (totalInputTokens / contextLimit) * 100; sessionLog( info.sessionID, @@ -635,6 +660,12 @@ export function createEventHandler(deps: EventHandlerDeps) { updates.cacheAlertSent = true; } } + + if (contextLimit < totalInputTokens) { + usageContextLimit = totalInputTokens; + percentage = 100; + if (sessionMeta.cacheAlertSent) updates.cacheAlertSent = true; + } } deps.contextUsageMap.set(info.sessionID, { @@ -649,14 +680,13 @@ export function createEventHandler(deps: EventHandlerDeps) { updates.lastContextPercentage = percentage; updates.lastInputTokens = totalInputTokens; - updates.lastUsageContextLimit = contextLimit; + updates.lastUsageContextLimit = usageContextLimit; + updates.lastUsageObservedAt = now; updates.lastObservedModelKey = modelKey ?? null; - if (!messageHadOverflowError) { - updates.observedSafeInputTokens = Math.max( - observedSafeInputTokens, - totalInputTokens, - ); - } + updates.observedSafeInputTokens = Math.max( + observedSafeInputTokens, + totalInputTokens, + ); const historianFailureState = getHistorianFailureState(deps.db, info.sessionID); if (historianFailureState.failureCount > 0 && percentage < 90) { diff --git a/packages/plugin/src/hooks/magic-context/event-payloads.ts b/packages/plugin/src/hooks/magic-context/event-payloads.ts index 2b018ff71..9c4a812e6 100644 --- a/packages/plugin/src/hooks/magic-context/event-payloads.ts +++ b/packages/plugin/src/hooks/magic-context/event-payloads.ts @@ -57,6 +57,15 @@ export interface MessageUpdatedInfo { completedAt?: number; } +export function isSuccessfulHostEvent(info: MessageUpdatedAssistantInfo): boolean { + if (info.error !== undefined && info.error !== null) return false; + if (info.finish === "error") return false; + return ( + typeof info.completedAt === "number" || + (typeof info.finish === "string" && info.finish.length > 0) + ); +} + export interface SessionErrorInfo { sessionID: string; error: unknown; diff --git a/packages/plugin/src/hooks/magic-context/event-resolvers.test.ts b/packages/plugin/src/hooks/magic-context/event-resolvers.test.ts index 35e7b25b1..cada4a741 100644 --- a/packages/plugin/src/hooks/magic-context/event-resolvers.test.ts +++ b/packages/plugin/src/hooks/magic-context/event-resolvers.test.ts @@ -131,6 +131,8 @@ describe("event-resolvers", () => { lastInputTokens: 100_000, lastUsageContextLimit: 1_048_576, lastObservedModelKey: modelKey, + lastResponseTime: Date.now(), + lastUsageObservedAt: Date.now(), }); const trustedLimit = resolveTrustedContextLimit("custom-proxy", "gemini-agent", { @@ -162,6 +164,8 @@ describe("event-resolvers", () => { lastInputTokens: 100_000, lastUsageContextLimit: 1_048_576, lastObservedModelKey: "custom-proxy/previous-model", + lastResponseTime: Date.now(), + lastUsageObservedAt: Date.now(), }); const trustedLimit = resolveTrustedContextLimit("custom-proxy", "gemini-agent", { @@ -191,6 +195,8 @@ describe("event-resolvers", () => { lastInputTokens: 100_000, lastUsageContextLimit: 1_048_576, lastObservedModelKey: "openai/gpt-alias-test", + lastResponseTime: Date.now(), + lastUsageObservedAt: Date.now(), }); // Simulate a legacy session row whose model key uses the old provider prefix. db.prepare( diff --git a/packages/plugin/src/hooks/magic-context/hook-handlers.ts b/packages/plugin/src/hooks/magic-context/hook-handlers.ts index d318046cc..eb9cb5887 100644 --- a/packages/plugin/src/hooks/magic-context/hook-handlers.ts +++ b/packages/plugin/src/hooks/magic-context/hook-handlers.ts @@ -3,6 +3,7 @@ import { scheduleIncrementalIndex, scheduleReconciliation, } from "../../features/magic-context/message-index-async"; +import { detectOverflow } from "../../features/magic-context/overflow-detection"; import { clearPersistedReasoningWatermark } from "../../features/magic-context/storage"; import { getOrCreateSessionMeta, @@ -39,6 +40,7 @@ import { getMessageUpdatedAssistantInfo, getMessageUpdatedInfo, getSessionProperties, + isSuccessfulHostEvent, } from "./event-payloads"; import { resolveSessionId as resolveEventSessionId } from "./event-resolvers"; import { dropSlot } from "./lkg-slot"; @@ -281,41 +283,29 @@ export function createEventHook(args: { client: PluginContext["client"]; protectedTags: number; }) { + const pressureModelBySession = new Map(); return async (input: { event: { type: string; properties?: unknown } }) => { - await args.eventHandler(input); - if (input.event.type === "message.updated") { - const messageInfo = getMessageUpdatedInfo(input.event.properties); - if (messageInfo?.messageID) { - const isTerminalUser = messageInfo.role === "user"; - const isTerminalAssistant = - messageInfo.role === "assistant" && - (typeof messageInfo.completedAt === "number" || - typeof messageInfo.finish === "string"); - if (isTerminalUser || isTerminalAssistant) { - scheduleIncrementalIndex( - args.db, - messageInfo.sessionID, - messageInfo.messageID, - readRawSessionMessageById, - ); - } - } - const assistantInfo = getMessageUpdatedAssistantInfo(input.event.properties); - if (assistantInfo?.providerID && assistantInfo?.modelID) { + if (assistantInfo?.providerID && assistantInfo.modelID) { const previous = args.liveModelBySession.get(assistantInfo.sessionID); + const pressureModel = + pressureModelBySession.get(assistantInfo.sessionID) ?? previous; args.liveModelBySession.set(assistantInfo.sessionID, { providerID: assistantInfo.providerID, modelID: assistantInfo.modelID, }); + const acceptsPressureModel = + isSuccessfulHostEvent(assistantInfo) || + detectOverflow(assistantInfo.error).isOverflow; // When the model changes (e.g., switching from 128k to 1M context model), // clear stale context percentage and historian failure state so the transform // doesn't keep using the old model's usage metrics or emergency state. if ( - previous && - (previous.providerID !== assistantInfo.providerID || - previous.modelID !== assistantInfo.modelID) + acceptsPressureModel && + pressureModel && + (pressureModel.providerID !== assistantInfo.providerID || + pressureModel.modelID !== assistantInfo.modelID) ) { // The reasoning watermark is only valid for the model that // produced it. On a switch TO an interleaved-reasoning @@ -329,7 +319,7 @@ export function createEventHook(args: { dropSlot(assistantInfo.sessionID, "model-change"); sessionLog( assistantInfo.sessionID, - `model changed (${previous.providerID}/${previous.modelID} -> ${assistantInfo.providerID}/${assistantInfo.modelID}), clearing historian failure state and reasoning watermark`, + `model changed (${pressureModel.providerID}/${pressureModel.modelID} -> ${assistantInfo.providerID}/${assistantInfo.modelID}), clearing historian failure state and reasoning watermark`, ); // Don't clear lastContextPercentage/lastInputTokens here — the event handler // already computed the correct percentage using the NEW model's context limit @@ -362,6 +352,33 @@ export function createEventHook(args: { cacheAlertSent: false, }); } + if (acceptsPressureModel) { + pressureModelBySession.set(assistantInfo.sessionID, { + providerID: assistantInfo.providerID, + modelID: assistantInfo.modelID, + }); + } + } + } + + await args.eventHandler(input); + + if (input.event.type === "message.updated") { + const messageInfo = getMessageUpdatedInfo(input.event.properties); + if (messageInfo?.messageID) { + const isTerminalUser = messageInfo.role === "user"; + const isTerminalAssistant = + messageInfo.role === "assistant" && + (typeof messageInfo.completedAt === "number" || + typeof messageInfo.finish === "string"); + if (isTerminalUser || isTerminalAssistant) { + scheduleIncrementalIndex( + args.db, + messageInfo.sessionID, + messageInfo.messageID, + readRawSessionMessageById, + ); + } } } @@ -377,6 +394,7 @@ export function createEventHook(args: { // createEventHandler has already persisted pending_session_cleanup before // this process-local indexing latch is discarded. args.liveModelBySession.delete(sessionId); + pressureModelBySession.delete(sessionId); args.variantBySession.delete(sessionId); args.agentBySession.delete(sessionId); args.sessionDirectoryBySession.delete(sessionId); diff --git a/packages/plugin/src/hooks/magic-context/hook.test.ts b/packages/plugin/src/hooks/magic-context/hook.test.ts index ce6ee43a8..96cafa5da 100644 --- a/packages/plugin/src/hooks/magic-context/hook.test.ts +++ b/packages/plugin/src/hooks/magic-context/hook.test.ts @@ -1051,7 +1051,7 @@ describe("magic-context hook", () => { const meta = getOrCreateSessionMeta(openDatabase(), "ses-model-change"); expect(meta.clearedReasoningThroughTag).toBe(0); - expect(meta.observedSafeInputTokens).toBe(0); + expect(meta.observedSafeInputTokens).toBe(25_000); expect(meta.cacheAlertSent).toBe(false); }); }); diff --git a/packages/plugin/src/hooks/magic-context/transform-context-state.test.ts b/packages/plugin/src/hooks/magic-context/transform-context-state.test.ts index d516037d0..13ce97a45 100644 --- a/packages/plugin/src/hooks/magic-context/transform-context-state.test.ts +++ b/packages/plugin/src/hooks/magic-context/transform-context-state.test.ts @@ -45,6 +45,7 @@ describe("loadContextUsage", () => { useTempDataHome("context-usage-load-"); const db = openDatabase(); updateSessionMeta(db, "ses-load", { + lastUsageObservedAt: Date.now(), lastResponseTime: 1_000, lastContextPercentage: 42.5, lastInputTokens: 85_000, @@ -61,6 +62,7 @@ describe("loadContextUsage", () => { useTempDataHome("context-usage-refresh-"); const db = openDatabase(); updateSessionMeta(db, "ses-refresh", { + lastUsageObservedAt: Date.now(), lastResponseTime: 1_000, lastContextPercentage: 12.4, lastInputTokens: 12_400, @@ -73,6 +75,7 @@ describe("loadContextUsage", () => { }); updateSessionMeta(db, "ses-refresh", { + lastUsageObservedAt: Date.now(), lastResponseTime: 2_000, lastContextPercentage: 126.7, lastInputTokens: 126_700, @@ -89,6 +92,7 @@ describe("loadContextUsage", () => { useTempDataHome("context-usage-cache-hit-"); const db = openDatabase(); updateSessionMeta(db, "ses-cache", { + lastUsageObservedAt: Date.now(), lastResponseTime: 1_000, lastContextPercentage: 50, lastInputTokens: 50_000, diff --git a/packages/plugin/src/hooks/magic-context/transform.test.ts b/packages/plugin/src/hooks/magic-context/transform.test.ts index e02b4ca1c..c89f74626 100644 --- a/packages/plugin/src/hooks/magic-context/transform.test.ts +++ b/packages/plugin/src/hooks/magic-context/transform.test.ts @@ -2226,9 +2226,10 @@ describe("createTransform", () => { //#when — simulate message.updated setting real usage, then second pass loads it contextUsageMap.delete("ses-lazy"); updateSessionMeta(db, "ses-lazy", { - lastResponseTime: 1_000, + lastResponseTime: Date.now(), lastContextPercentage: 50, lastInputTokens: 100_000, + lastUsageObservedAt: Date.now(), }); await transform({}, { messages }); @@ -2619,6 +2620,8 @@ describe("createTransform shrinking model-switch overflow pre-arm", () => { lastInputTokens: 300_000, lastObservedModelKey: OLD_KEY, lastUsageContextLimit: 512_000, + lastResponseTime: Date.now(), + lastUsageObservedAt: Date.now(), }); await seedNewModelLimit(272_000); @@ -2685,6 +2688,8 @@ describe("createTransform shrinking model-switch overflow pre-arm", () => { lastInputTokens: 200_000, lastObservedModelKey: NEW_KEY, lastUsageContextLimit: 272_000, + lastResponseTime: Date.now(), + lastUsageObservedAt: Date.now(), }); await seedNewModelLimit(272_000); @@ -2727,6 +2732,8 @@ describe("createTransform shrinking model-switch overflow pre-arm", () => { lastInputTokens: 300_000, lastObservedModelKey: NEW_KEY, lastUsageContextLimit: 400_000, + lastResponseTime: Date.now(), + lastUsageObservedAt: Date.now(), }); await seedNewModelLimit(272_000); @@ -2767,6 +2774,8 @@ describe("createTransform shrinking model-switch overflow pre-arm", () => { lastInputTokens: 300_000, lastObservedModelKey: OLD_KEY, lastUsageContextLimit: 512_000, + lastResponseTime: Date.now(), + lastUsageObservedAt: Date.now(), }); await seedNewModelLimit(272_000); @@ -2791,6 +2800,8 @@ describe("createTransform shrinking model-switch overflow pre-arm", () => { lastInputTokens: 300_000, lastObservedModelKey: OLD_KEY, lastUsageContextLimit: 512_000, + lastResponseTime: Date.now(), + lastUsageObservedAt: Date.now(), }); await seedNewModelLimit(272_000); diff --git a/packages/plugin/src/shared/models-dev-cache.test.ts b/packages/plugin/src/shared/models-dev-cache.test.ts index 321dc75c9..1e9642138 100644 --- a/packages/plugin/src/shared/models-dev-cache.test.ts +++ b/packages/plugin/src/shared/models-dev-cache.test.ts @@ -17,7 +17,7 @@ import { /** * Model context limits resolve from OpenCode's SDK only (`config.providers()`), - * bounded to a sane [20k, 3M] range, with a persisted last-known-good cache for + * bounded to exact positive integers above 20k, with a persisted last-known-good cache for * cold start. We no longer read OpenCode's `models.json` file ourselves (a torn * read produced impossible limits and a stale copy out-voted the live cap). */ @@ -196,6 +196,21 @@ describe("models-dev-cache (SDK-only)", () => { expect(getSdkContextLimit("unknown", "unknown")).toBeUndefined(); }); + test("accepts a legitimate 10M-token model window", async () => { + await refreshModelLimitsFromApi( + makeClient([ + { + id: "large-context-provider", + models: { "large-context-model": { limit: { context: 10_000_000 } } }, + }, + ]), + ); + + expect(getSdkContextLimit("large-context-provider", "large-context-model")).toBe( + 10_000_000, + ); + }); + test("Codex-OAuth cap is honored: a 400k/272k gpt-5.5 resolves to 272k (not the stale 922k)", async () => { // The bug we're fixing: the SDK reports the auth-resolved cap; nothing may // out-vote it with a larger stale value. @@ -276,7 +291,7 @@ describe("models-dev-cache (SDK-only)", () => { expect(getSdkContextLimit("ollama-cloud", "nonexistent:cloud")).toBeUndefined(); }); - describe("sanity bounds [20k, 3M]", () => { + describe("sanity bounds [20k, Number.MAX_SAFE_INTEGER]", () => { test("rejects an implausibly small limit (torn-read garbage like 6748)", async () => { await refreshModelLimitsFromApi( makeClient([ @@ -298,9 +313,14 @@ describe("models-dev-cache (SDK-only)", () => { expect(getSdkContextLimit("p", "m")).toBeUndefined(); }); - test("rejects an impossibly large limit (> 3M)", async () => { + test("rejects a limit outside JavaScript's exact integer range", async () => { await refreshModelLimitsFromApi( - makeClient([{ id: "p", models: { m: { limit: { context: 5_000_000 } } } }]), + makeClient([ + { + id: "p", + models: { m: { limit: { context: Number.MAX_SAFE_INTEGER + 1 } } }, + }, + ]), ); expect(getSdkContextLimit("p", "m")).toBeUndefined(); }); @@ -312,13 +332,13 @@ describe("models-dev-cache (SDK-only)", () => { id: "p", models: { lo: { limit: { context: 20000 } }, - hi: { limit: { context: 3000000 } }, + hi: { limit: { context: Number.MAX_SAFE_INTEGER } }, }, }, ]), ); expect(getSdkContextLimit("p", "lo")).toBe(20000); - expect(getSdkContextLimit("p", "hi")).toBe(3000000); + expect(getSdkContextLimit("p", "hi")).toBe(Number.MAX_SAFE_INTEGER); }); }); diff --git a/packages/plugin/src/shared/models-dev-cache.ts b/packages/plugin/src/shared/models-dev-cache.ts index cd4dd0b97..9f38ed94c 100644 --- a/packages/plugin/src/shared/models-dev-cache.ts +++ b/packages/plugin/src/shared/models-dev-cache.ts @@ -16,7 +16,7 @@ * from a persisted last-known-good file on cold start so a restart uses the * real limit immediately (no 128k-default budget-collapse window). * - * All cached values are bounded to a sane [20k, 3M] range on insert, so torn / + * All cached values must be exact integers of at least 20k on insert, so torn / * unconfigured-default garbage can never be returned or persisted. The startup * warm retries a couple times when OpenCode's provider service isn't ready yet. * @@ -46,21 +46,21 @@ interface OpencodeClientLike { }; } -// Plausible bounds for a real model's prompt limit. A value outside this range -// is physically impossible for an agentic session and signals a transient/garbage -// read — e.g. a torn read of OpenCode's `models.json` mid-write once produced -// `contextLimit=6748` (smaller than a single system prompt) for a session that -// had been running for hours past 200k+ (issue #117). Such values must be -// REJECTED, not trusted as a "smaller real cap". A genuinely smaller real limit -// still comes through the overflow-detection path (detectedContextLimit). +// The lower bound rejects transient/garbage reads — e.g. a torn models.json read +// produced contextLimit=6748 for a session already past 200k (issue #117). The +// upper bound is JavaScript's exact-integer ceiling: model windows keep growing, +// so arithmetic safety is the only future-proof maximum. export const MIN_SANE_LIMIT = 20_000; -export const MAX_SANE_LIMIT = 3_000_000; +export const MAX_SANE_LIMIT = Number.MAX_SAFE_INTEGER; -/** True when `limit` is a plausible real prompt window — used to reject torn / - * unconfigured-default garbage in BOTH harnesses (OpenCode's SDK values and - * Pi's reported `contextWindow`). Exported so Pi applies the identical bound. */ +/** True when `limit` is an exact prompt window above the garbage-read floor. */ export function isSaneLimit(limit: number | undefined): limit is number { - return typeof limit === "number" && limit >= MIN_SANE_LIMIT && limit <= MAX_SANE_LIMIT; + return ( + typeof limit === "number" && + Number.isSafeInteger(limit) && + limit >= MIN_SANE_LIMIT && + limit <= MAX_SANE_LIMIT + ); } export type OutputReserveConfig = number | { default: number; [modelKey: string]: number };