Skip to content

Commit 34d4c65

Browse files
committed
fix(ctx-status): show correct live-model threshold after restart
`/ctx-status` resolves `execute_threshold_percentage` (and the resolved context limit) against the live session model via `liveModelBySession`. That map is populated only by transform passes and the `chat.message` hook, so when `/ctx-status` runs before any transform has fired after restart (for example the very first status query on a resumed session), the resolver received `liveModelKey === undefined`, skipped any Anthropic-specific override, and displayed the default threshold — showing e.g. 97,500-token history-compression budgets (1M × 65% × 15%) instead of the live 60,000 (1M × 40% × 15%) the runtime actually used. Fix: add `findLastAssistantModelFromOpenCodeDb` in `read-session-db.ts`, backed by a read-only query against OpenCode's SQLite `message` table for the latest assistant with `providerID`/`modelID`. `hook.ts` now wraps `liveModelBySession` access through `resolveLiveModel`, which falls back to that DB helper and caches the recovered model into the in-memory map. The helper is used from `/ctx-status`'s `getLiveModelKey` and `getContextLimit`, plus `/ctx-recomp`'s `fallbackModelId`, so every command that reads live-model state behaves consistently whether or not a transform pass has already run since restart. Runtime behavior is unchanged — transform passes still populate the map on every turn — so this is a display-only correctness fix for pre-first- transform command invocations. Regression tests cover brand-new sessions, user-only sessions, assistant- latest selection by `time_created`, rows missing `providerID`/`modelID`, cross-session scoping, and missing-DB graceful degradation.
1 parent 10987a3 commit 34d4c65

3 files changed

Lines changed: 278 additions & 3 deletions

File tree

packages/plugin/src/hooks/magic-context/hook.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { createEventHandler } from "./event-handler";
2929
import { resolveContextLimit, resolveModelKey } from "./event-resolvers";
3030
import { clearInjectionCache } from "./inject-compartments";
3131
import { createNudger } from "./nudger";
32+
import { findLastAssistantModelFromOpenCodeDb } from "./read-session-db";
3233
import { createTextCompleteHandler } from "./text-complete";
3334
import { createNudgePlacementStore, createTransform } from "./transform";
3435

@@ -177,6 +178,30 @@ export function createMagicContextHook(deps: MagicContextDeps) {
177178
const agentBySession = deps.liveSessionState?.agentBySession ?? new Map<string, string>();
178179
const recentReduceBySession = new Map<string, number>();
179180
const toolUsageSinceUserTurn = new Map<string, number>();
181+
182+
/**
183+
* Return the live provider/model for a session.
184+
*
185+
* Prefers the in-memory `liveModelBySession` map populated by transform passes
186+
* and `chat.message` hooks. When the map is empty (for example `/ctx-status`
187+
* is invoked before any transform pass has run since restart), falls back to
188+
* reading the last assistant message from OpenCode's SQLite DB and caches the
189+
* result so subsequent calls in the same process don't hit the DB again.
190+
*
191+
* Returns undefined only for brand-new sessions with no assistant turn yet.
192+
*/
193+
const resolveLiveModel = (
194+
sessionId: string,
195+
): { providerID: string; modelID: string } | undefined => {
196+
const cached = liveModelBySession.get(sessionId);
197+
if (cached) return cached;
198+
const recovered = findLastAssistantModelFromOpenCodeDb(sessionId);
199+
if (recovered) {
200+
liveModelBySession.set(sessionId, recovered);
201+
return recovered;
202+
}
203+
return undefined;
204+
};
180205
const ctxReduceEnabled = deps.config.ctx_reduce_enabled !== false;
181206
const nudgerWithRecentReduce = ctxReduceEnabled
182207
? createNudger({
@@ -315,11 +340,17 @@ export function createMagicContextHook(deps: MagicContextDeps) {
315340
historyBudgetPercentage: deps.config.history_budget_percentage,
316341
commitClusterTrigger: deps.config.commit_cluster_trigger,
317342
getLiveModelKey: (sessionId) => {
318-
const model = liveModelBySession.get(sessionId);
343+
// Use DB fallback so /ctx-status shows the correct model-specific
344+
// threshold even before the first transform pass has populated
345+
// liveModelBySession after restart. Without this, the resolver
346+
// falls back to the default threshold and displays a stale budget.
347+
const model = resolveLiveModel(sessionId);
319348
return model ? `${model.providerID}/${model.modelID}` : undefined;
320349
},
321350
getContextLimit: (sessionId) => {
322-
const model = liveModelBySession.get(sessionId);
351+
// Same DB fallback as getLiveModelKey — /ctx-status's "Resolved
352+
// context limit" and history-budget math depend on the live model.
353+
const model = resolveLiveModel(sessionId);
323354
if (!model) return undefined;
324355
return resolveContextLimit(model.providerID, model.modelID);
325356
},
@@ -335,7 +366,9 @@ export function createMagicContextHook(deps: MagicContextDeps) {
335366
deps.config.historian_timeout_ms ?? DEFAULT_HISTORIAN_TIMEOUT_MS,
336367
directory: deps.directory,
337368
fallbackModelId: (() => {
338-
const model = liveModelBySession.get(sessionId);
369+
// DB fallback so /ctx-recomp's last-resort fallback model
370+
// is known even when invoked before the first transform.
371+
const model = resolveLiveModel(sessionId);
339372
return model ? `${model.providerID}/${model.modelID}` : undefined;
340373
})(),
341374
getNotificationParams: () =>
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/// <reference types="bun-types" />
2+
3+
import { Database } from "bun:sqlite";
4+
import { afterEach, describe, expect, it } from "bun:test";
5+
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
6+
import { tmpdir } from "node:os";
7+
import { dirname, join } from "node:path";
8+
import { closeReadOnlySessionDb, findLastAssistantModelFromOpenCodeDb } from "./read-session-db";
9+
10+
const tempDirs: string[] = [];
11+
const originalXdgDataHome = process.env.XDG_DATA_HOME;
12+
13+
afterEach(() => {
14+
// Close any cached OpenCode read-only DB handle so the new XDG_DATA_HOME
15+
// points to a fresh DB on the next test case.
16+
closeReadOnlySessionDb();
17+
process.env.XDG_DATA_HOME = originalXdgDataHome;
18+
for (const dir of tempDirs) {
19+
rmSync(dir, { recursive: true, force: true });
20+
}
21+
tempDirs.length = 0;
22+
});
23+
24+
function useTempDataHome(prefix: string): void {
25+
const dir = mkdtempSync(join(tmpdir(), prefix));
26+
tempDirs.push(dir);
27+
process.env.XDG_DATA_HOME = dir;
28+
}
29+
30+
interface MessageRow {
31+
id: string;
32+
sessionId: string;
33+
role: "user" | "assistant";
34+
providerID?: string;
35+
modelID?: string;
36+
timeCreated: number;
37+
}
38+
39+
function createOpenCodeDb(rows: MessageRow[]): void {
40+
const dbPath = join(process.env.XDG_DATA_HOME!, "opencode", "opencode.db");
41+
mkdirSync(dirname(dbPath), { recursive: true });
42+
const db = new Database(dbPath);
43+
try {
44+
db.run(`
45+
CREATE TABLE IF NOT EXISTS message (
46+
id TEXT PRIMARY KEY,
47+
session_id TEXT NOT NULL,
48+
time_created INTEGER NOT NULL,
49+
time_updated INTEGER NOT NULL,
50+
data TEXT NOT NULL
51+
);
52+
`);
53+
const insert = db.prepare(
54+
`INSERT INTO message (id, session_id, time_created, time_updated, data)
55+
VALUES (?, ?, ?, ?, ?)`,
56+
);
57+
for (const row of rows) {
58+
const data: Record<string, unknown> = { role: row.role };
59+
if (row.providerID !== undefined) data.providerID = row.providerID;
60+
if (row.modelID !== undefined) data.modelID = row.modelID;
61+
insert.run(
62+
row.id,
63+
row.sessionId,
64+
row.timeCreated,
65+
row.timeCreated,
66+
JSON.stringify(data),
67+
);
68+
}
69+
} finally {
70+
db.close(false);
71+
}
72+
}
73+
74+
describe("findLastAssistantModelFromOpenCodeDb", () => {
75+
it("returns null for a session with no assistant messages", () => {
76+
useTempDataHome("read-session-db-no-assistant-");
77+
createOpenCodeDb([
78+
{
79+
id: "msg_user1",
80+
sessionId: "ses_A",
81+
role: "user",
82+
timeCreated: 1000,
83+
},
84+
]);
85+
expect(findLastAssistantModelFromOpenCodeDb("ses_A")).toBeNull();
86+
});
87+
88+
it("returns the most recent assistant's providerID/modelID", () => {
89+
useTempDataHome("read-session-db-latest-assistant-");
90+
createOpenCodeDb([
91+
{
92+
id: "msg_old",
93+
sessionId: "ses_A",
94+
role: "assistant",
95+
providerID: "anthropic",
96+
modelID: "claude-sonnet-4.5",
97+
timeCreated: 1000,
98+
},
99+
{
100+
id: "msg_new",
101+
sessionId: "ses_A",
102+
role: "assistant",
103+
providerID: "anthropic",
104+
modelID: "claude-opus-4-7",
105+
timeCreated: 2000,
106+
},
107+
]);
108+
expect(findLastAssistantModelFromOpenCodeDb("ses_A")).toEqual({
109+
providerID: "anthropic",
110+
modelID: "claude-opus-4-7",
111+
});
112+
});
113+
114+
it("ignores user messages even when they are newer", () => {
115+
useTempDataHome("read-session-db-ignore-user-");
116+
createOpenCodeDb([
117+
{
118+
id: "msg_asst",
119+
sessionId: "ses_A",
120+
role: "assistant",
121+
providerID: "github-copilot",
122+
modelID: "claude-sonnet-4.5",
123+
timeCreated: 1000,
124+
},
125+
{
126+
id: "msg_user_newer",
127+
sessionId: "ses_A",
128+
role: "user",
129+
timeCreated: 2000,
130+
},
131+
]);
132+
expect(findLastAssistantModelFromOpenCodeDb("ses_A")).toEqual({
133+
providerID: "github-copilot",
134+
modelID: "claude-sonnet-4.5",
135+
});
136+
});
137+
138+
it("ignores assistants without providerID or modelID", () => {
139+
useTempDataHome("read-session-db-incomplete-assistant-");
140+
createOpenCodeDb([
141+
{
142+
id: "msg_full",
143+
sessionId: "ses_A",
144+
role: "assistant",
145+
providerID: "anthropic",
146+
modelID: "claude-opus-4-7",
147+
timeCreated: 1000,
148+
},
149+
{
150+
id: "msg_missing_model",
151+
sessionId: "ses_A",
152+
role: "assistant",
153+
providerID: "anthropic",
154+
// modelID missing
155+
timeCreated: 2000,
156+
},
157+
]);
158+
// Returns the fully-populated earlier assistant rather than the newer partial row.
159+
expect(findLastAssistantModelFromOpenCodeDb("ses_A")).toEqual({
160+
providerID: "anthropic",
161+
modelID: "claude-opus-4-7",
162+
});
163+
});
164+
165+
it("scopes by session ID and does not leak across sessions", () => {
166+
useTempDataHome("read-session-db-session-scope-");
167+
createOpenCodeDb([
168+
{
169+
id: "msg_A1",
170+
sessionId: "ses_A",
171+
role: "assistant",
172+
providerID: "anthropic",
173+
modelID: "claude-opus-4-7",
174+
timeCreated: 1000,
175+
},
176+
{
177+
id: "msg_B1",
178+
sessionId: "ses_B",
179+
role: "assistant",
180+
providerID: "github-copilot",
181+
modelID: "gpt-5.4",
182+
timeCreated: 2000,
183+
},
184+
]);
185+
expect(findLastAssistantModelFromOpenCodeDb("ses_A")).toEqual({
186+
providerID: "anthropic",
187+
modelID: "claude-opus-4-7",
188+
});
189+
expect(findLastAssistantModelFromOpenCodeDb("ses_B")).toEqual({
190+
providerID: "github-copilot",
191+
modelID: "gpt-5.4",
192+
});
193+
});
194+
195+
it("returns null gracefully when the DB is missing entirely", () => {
196+
useTempDataHome("read-session-db-missing-db-");
197+
// Do NOT create the DB. The helper should log and return null instead of throwing.
198+
expect(findLastAssistantModelFromOpenCodeDb("ses_A")).toBeNull();
199+
});
200+
});

packages/plugin/src/hooks/magic-context/read-session-db.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,45 @@ export function getRawSessionMessageCountFromDb(db: Database, sessionId: string)
6161
.get(sessionId) as RawCountRow | null;
6262
return typeof row?.count === "number" ? row.count : 0;
6363
}
64+
65+
interface AssistantModelRow {
66+
providerID?: string;
67+
modelID?: string;
68+
}
69+
70+
/**
71+
* Read the provider/model of the most recent assistant message for a session
72+
* directly from OpenCode's SQLite DB. Used as a fallback when the in-memory
73+
* `liveModelBySession` map is empty — for example when `/ctx-status` is invoked
74+
* before any transform pass has populated the map after restart.
75+
*
76+
* Returns null for brand-new sessions with no assistant turn yet.
77+
*/
78+
export function findLastAssistantModelFromOpenCodeDb(
79+
sessionId: string,
80+
): { providerID: string; modelID: string } | null {
81+
try {
82+
return withReadOnlySessionDb((db) => {
83+
const row = db
84+
.prepare(
85+
`SELECT json_extract(data, '$.providerID') as providerID,
86+
json_extract(data, '$.modelID') as modelID
87+
FROM message
88+
WHERE session_id = ?
89+
AND json_extract(data, '$.role') = 'assistant'
90+
AND json_extract(data, '$.providerID') IS NOT NULL
91+
AND json_extract(data, '$.modelID') IS NOT NULL
92+
ORDER BY time_created DESC
93+
LIMIT 1`,
94+
)
95+
.get(sessionId) as AssistantModelRow | null;
96+
if (!row || typeof row.providerID !== "string" || typeof row.modelID !== "string") {
97+
return null;
98+
}
99+
return { providerID: row.providerID, modelID: row.modelID };
100+
});
101+
} catch (error) {
102+
log("[magic-context] failed to recover live model from OpenCode DB:", error);
103+
return null;
104+
}
105+
}

0 commit comments

Comments
 (0)