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 };