Skip to content

Commit 437432a

Browse files
committed
mason: fix compaction marker boundary anchoring
1 parent f3bfcd4 commit 437432a

11 files changed

Lines changed: 724 additions & 62 deletions
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/// <reference types="bun-types" />
2+
3+
import { afterEach, describe, expect, it } from "bun:test";
4+
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
5+
import { tmpdir } from "node:os";
6+
import { join } from "node:path";
7+
import { Database } from "../../shared/sqlite";
8+
import { closeQuietly } from "../../shared/sqlite-helpers";
9+
import {
10+
closeCompactionMarkerDb,
11+
findBoundaryUserMessage,
12+
injectCompactionMarker,
13+
} from "./compaction-marker";
14+
15+
const tempDirs: string[] = [];
16+
const originalXdgDataHome = process.env.XDG_DATA_HOME;
17+
18+
function useTempDataHome(prefix: string): string {
19+
const dir = mkdtempSync(join(tmpdir(), prefix));
20+
tempDirs.push(dir);
21+
process.env.XDG_DATA_HOME = dir;
22+
mkdirSync(join(dir, "opencode"), { recursive: true });
23+
return dir;
24+
}
25+
26+
function createOpenCodeDb(dataHome: string): Database {
27+
const db = new Database(join(dataHome, "opencode", "opencode.db"));
28+
db.exec("PRAGMA journal_mode=WAL");
29+
db.exec(
30+
"CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, time_updated INTEGER, data TEXT)",
31+
);
32+
db.exec(
33+
"CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, time_created INTEGER, time_updated INTEGER, data TEXT)",
34+
);
35+
return db;
36+
}
37+
38+
function insertMessage(
39+
db: Database,
40+
id: string,
41+
role: string,
42+
timeCreated: number,
43+
data: Record<string, unknown> = {},
44+
): void {
45+
db.prepare(
46+
"INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, 'ses-1', ?, ?, ?)",
47+
).run(id, timeCreated, timeCreated, JSON.stringify({ role, ...data }));
48+
}
49+
50+
afterEach(() => {
51+
closeCompactionMarkerDb();
52+
process.env.XDG_DATA_HOME = originalXdgDataHome;
53+
for (const dir of tempDirs) {
54+
rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
55+
}
56+
tempDirs.length = 0;
57+
});
58+
59+
describe("findBoundaryUserMessage", () => {
60+
it("anchors by endMessageId after rows before the target were deleted", () => {
61+
const dataHome = useTempDataHome("marker-boundary-deleted-before-");
62+
const db = createOpenCodeDb(dataHome);
63+
insertMessage(db, "msg_001_deleted_user", "user", 100);
64+
insertMessage(db, "msg_002_deleted_assistant", "assistant", 200);
65+
insertMessage(db, "msg_003_prior_user", "user", 300);
66+
insertMessage(db, "msg_004_target", "assistant", 400);
67+
insertMessage(db, "msg_005_after_user", "user", 500);
68+
db.prepare(
69+
"DELETE FROM message WHERE id IN ('msg_001_deleted_user', 'msg_002_deleted_assistant')",
70+
).run();
71+
closeQuietly(db);
72+
73+
expect(findBoundaryUserMessage("ses-1", "msg_004_target")?.id).toBe("msg_003_prior_user");
74+
});
75+
76+
it("uses the canonical time_created/id tie-break at equal timestamps", () => {
77+
const dataHome = useTempDataHome("marker-boundary-tiebreak-");
78+
const db = createOpenCodeDb(dataHome);
79+
insertMessage(db, "msg_a_prior_user", "user", 1_000);
80+
insertMessage(db, "msg_b_target", "assistant", 1_000);
81+
insertMessage(db, "msg_c_after_user", "user", 1_000);
82+
closeQuietly(db);
83+
84+
expect(findBoundaryUserMessage("ses-1", "msg_b_target")?.id).toBe("msg_a_prior_user");
85+
});
86+
87+
it("returns the target itself when the target message is a user", () => {
88+
const dataHome = useTempDataHome("marker-boundary-target-user-");
89+
const db = createOpenCodeDb(dataHome);
90+
insertMessage(db, "msg_001_prior_user", "user", 100);
91+
insertMessage(db, "msg_002_target_user", "user", 200);
92+
closeQuietly(db);
93+
94+
expect(findBoundaryUserMessage("ses-1", "msg_002_target_user")?.id).toBe(
95+
"msg_002_target_user",
96+
);
97+
});
98+
99+
it("is unchanged by deleting rows after the target", () => {
100+
const dataHome = useTempDataHome("marker-boundary-deleted-after-");
101+
const db = createOpenCodeDb(dataHome);
102+
insertMessage(db, "msg_001_prior_user", "user", 100);
103+
insertMessage(db, "msg_002_target", "assistant", 200);
104+
insertMessage(db, "msg_003_after_user", "user", 300);
105+
closeQuietly(db);
106+
107+
expect(findBoundaryUserMessage("ses-1", "msg_002_target")?.id).toBe("msg_001_prior_user");
108+
109+
const reopened = new Database(join(dataHome, "opencode", "opencode.db"));
110+
reopened.prepare("DELETE FROM message WHERE id = 'msg_003_after_user'").run();
111+
closeQuietly(reopened);
112+
closeCompactionMarkerDb();
113+
114+
expect(findBoundaryUserMessage("ses-1", "msg_002_target")?.id).toBe("msg_001_prior_user");
115+
});
116+
117+
it("finds a prior user across a long assistant/tool span", () => {
118+
const dataHome = useTempDataHome("marker-boundary-long-span-");
119+
const db = createOpenCodeDb(dataHome);
120+
insertMessage(db, "msg_001_prior_user", "user", 100);
121+
for (let i = 0; i < 150; i++) {
122+
insertMessage(
123+
db,
124+
`msg_${String(i + 2).padStart(3, "0")}_assistant`,
125+
"assistant",
126+
101 + i,
127+
);
128+
}
129+
insertMessage(db, "msg_999_target", "tool", 1_000);
130+
closeQuietly(db);
131+
132+
expect(findBoundaryUserMessage("ses-1", "msg_999_target")?.id).toBe("msg_001_prior_user");
133+
});
134+
});
135+
136+
describe("injectCompactionMarker", () => {
137+
it("preserves the deterministic boundary in the healthy no-deletion case", () => {
138+
const dataHome = useTempDataHome("marker-inject-healthy-");
139+
const db = createOpenCodeDb(dataHome);
140+
insertMessage(db, "msg_001_user", "user", 100);
141+
insertMessage(db, "msg_002_assistant", "assistant", 200);
142+
insertMessage(db, "msg_003_target", "assistant", 300);
143+
closeQuietly(db);
144+
145+
const result = injectCompactionMarker({
146+
sessionId: "ses-1",
147+
endOrdinal: 3,
148+
endMessageId: "msg_003_target",
149+
summaryText: "summary placeholder",
150+
directory: dataHome,
151+
});
152+
153+
// Generated marker row ids include random base62 suffixes; compare only
154+
// the deterministic boundary field that the old ordinal path intended.
155+
expect(result?.boundaryMessageId).toBe("msg_001_user");
156+
});
157+
});

packages/plugin/src/features/magic-context/compaction-marker.ts

Lines changed: 75 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -183,53 +183,99 @@ export function closeCompactionMarkerDb(): void {
183183

184184
// ── Boundary User Message Resolution ─────────────────────────────
185185

186-
interface BoundaryUserMessage {
186+
export interface BoundaryUserMessage {
187187
id: string;
188188
timeCreated: number;
189189
}
190190

191+
interface NonSummaryMessageSortKey {
192+
id: string;
193+
timeCreated: number;
194+
}
195+
196+
function getNonSummaryMessageSortKey(
197+
sessionId: string,
198+
messageId: string,
199+
): NonSummaryMessageSortKey | null {
200+
const db = getWritableOpenCodeDb();
201+
const row = db
202+
.prepare(
203+
`SELECT time_created, id
204+
FROM message
205+
WHERE session_id = ?
206+
AND id = ?
207+
AND NOT (COALESCE(json_extract(data, '$.summary'), 0) = 1
208+
AND COALESCE(json_extract(data, '$.finish'), '') = 'stop')
209+
LIMIT 1`,
210+
)
211+
.get(sessionId, messageId) as { time_created?: unknown; id?: unknown } | undefined;
212+
if (typeof row?.time_created !== "number" || typeof row.id !== "string") {
213+
return null;
214+
}
215+
return { id: row.id, timeCreated: row.time_created };
216+
}
217+
191218
/**
192-
* Find the nearest user message at or before the given raw ordinal.
219+
* Find the nearest user message at or before the given end message id.
193220
* The boundary must be a user message for filterCompacted to work.
194221
*
195222
* Filters out compaction summary messages (summary=true, finish="stop")
196223
* so ordinals stay consistent with readRawSessionMessagesFromDb.
197224
*/
198225
export function findBoundaryUserMessage(
199226
sessionId: string,
200-
endOrdinal: number,
227+
endMessageId: string,
201228
): BoundaryUserMessage | null {
202229
const db = getWritableOpenCodeDb();
203230

204-
// Filter out our own injected summary messages (summary=true AND finish="stop")
205-
// in SQL using json_extract — matches readRawSessionMessagesFromDb's filter so
206-
// ordinals stay parity-correct. Avoids O(N) JSON.parse in JS for sessions with
207-
// thousands of messages. COALESCE handles rows missing the fields.
208-
const rows = db
231+
// Resolve the target's canonical sort key first, using the same summary
232+
// exclusion as readRawSessionMessagesFromDb. If the stored endMessageId is
233+
// gone (or is itself one of our injected summaries), the pending/direct
234+
// marker update is stale and must not move the boundary.
235+
const target = getNonSummaryMessageSortKey(sessionId, endMessageId);
236+
if (!target) return null;
237+
238+
// Match the raw-message reader's canonical ASC order
239+
// (time_created ASC, id ASC). "At or before target" is therefore
240+
// time_created < target.time_created OR the same timestamp with id <= target.id.
241+
// Push role='user' into SQL so a long assistant/tool span before the target
242+
// cannot exhaust a JS scan window and miss the prior user.
243+
const boundary = db
209244
.prepare(
210245
`SELECT id, time_created, data
211246
FROM message
212247
WHERE session_id = ?
213248
AND NOT (COALESCE(json_extract(data, '$.summary'), 0) = 1
214249
AND COALESCE(json_extract(data, '$.finish'), '') = 'stop')
215-
ORDER BY time_created ASC, id ASC
216-
LIMIT ?`,
250+
AND COALESCE(json_extract(data, '$.role'), '') = 'user'
251+
AND (time_created < ? OR (time_created = ? AND id <= ?))
252+
ORDER BY time_created DESC, id DESC
253+
LIMIT 1`,
217254
)
218-
.all(sessionId, endOrdinal) as Array<{ id: string; time_created: number; data: string }>;
255+
.get(sessionId, target.timeCreated, target.timeCreated, target.id) as
256+
| { id?: unknown; time_created?: unknown; data?: unknown }
257+
| undefined;
219258

220-
let bestMatch: BoundaryUserMessage | null = null;
221-
for (const row of rows) {
222-
try {
223-
const info = JSON.parse(row.data);
224-
if (info.role === "user") {
225-
bestMatch = { id: row.id, timeCreated: row.time_created };
226-
}
227-
} catch {
228-
// skip corrupt rows
229-
}
259+
if (typeof boundary?.id !== "string" || typeof boundary.time_created !== "number") {
260+
return null;
230261
}
231262

232-
return bestMatch;
263+
return { id: boundary.id, timeCreated: boundary.time_created };
264+
}
265+
266+
export function compareOpenCodeMessagesByCanonicalOrder(
267+
sessionId: string,
268+
leftMessageId: string,
269+
rightMessageId: string,
270+
): number | null {
271+
const left = getNonSummaryMessageSortKey(sessionId, leftMessageId);
272+
const right = getNonSummaryMessageSortKey(sessionId, rightMessageId);
273+
if (!left || !right) return null;
274+
if (left.timeCreated < right.timeCreated) return -1;
275+
if (left.timeCreated > right.timeCreated) return 1;
276+
if (left.id < right.id) return -1;
277+
if (left.id > right.id) return 1;
278+
return 0;
233279
}
234280

235281
/**
@@ -276,10 +322,14 @@ export interface InjectCompactionMarkerArgs {
276322
sessionId: string;
277323
/** Raw ordinal of the last compartmentalized message */
278324
endOrdinal: number;
325+
/** OpenCode message id of the last compartmentalized message */
326+
endMessageId: string;
279327
/** Summary text for the compaction summary message (static placeholder) */
280328
summaryText: string;
281329
/** Working directory for the session */
282330
directory: string;
331+
/** Boundary resolved before removing the old marker (prevents null-boundary cache busts). */
332+
resolvedBoundary?: BoundaryUserMessage;
283333
}
284334

285335
/**
@@ -298,10 +348,11 @@ export function injectCompactionMarker(
298348
return null;
299349
}
300350

301-
const boundary = findBoundaryUserMessage(args.sessionId, args.endOrdinal);
351+
const boundary =
352+
args.resolvedBoundary ?? findBoundaryUserMessage(args.sessionId, args.endMessageId);
302353
if (!boundary) {
303354
log(
304-
`[magic-context] compaction-marker: no user message found at or before ordinal ${args.endOrdinal}`,
355+
`[magic-context] compaction-marker: no user message found at or before endMessageId ${args.endMessageId} (ordinal ${args.endOrdinal})`,
305356
);
306357
return null;
307358
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/// <reference types="bun-types" />
2+
3+
import { describe, expect, test } from "bun:test";
4+
import { Database } from "../../shared/sqlite";
5+
import { closeQuietly } from "../../shared/sqlite-helpers";
6+
import { runMigrations } from "./migrations";
7+
import { initializeDatabase } from "./storage-db";
8+
9+
function columnNames(db: Database): string[] {
10+
return (db.prepare("PRAGMA table_info(session_meta)").all() as Array<{ name: string }>).map(
11+
(column) => column.name,
12+
);
13+
}
14+
15+
describe("migration v39 — compaction marker target end message id", () => {
16+
test("fresh database has nullable compaction_marker_target_end_message_id column", () => {
17+
const db = new Database(":memory:");
18+
try {
19+
initializeDatabase(db);
20+
runMigrations(db);
21+
22+
expect(columnNames(db)).toContain("compaction_marker_target_end_message_id");
23+
db.prepare("INSERT INTO session_meta (session_id) VALUES ('ses-1')").run();
24+
const row = db
25+
.prepare(
26+
"SELECT compaction_marker_target_end_message_id FROM session_meta WHERE session_id = 'ses-1'",
27+
)
28+
.get() as { compaction_marker_target_end_message_id: string | null };
29+
expect(row.compaction_marker_target_end_message_id).toBeNull();
30+
} finally {
31+
closeQuietly(db);
32+
}
33+
});
34+
35+
test("upgrade adds column and backfills JSON targetEndMessageId when present", () => {
36+
const db = new Database(":memory:");
37+
try {
38+
db.exec(`
39+
CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, description TEXT NOT NULL, applied_at INTEGER NOT NULL);
40+
INSERT INTO schema_migrations (version, description, applied_at) VALUES (38, 'pre-v39 fixture', 1);
41+
CREATE TABLE session_meta (
42+
session_id TEXT PRIMARY KEY,
43+
compaction_marker_state TEXT DEFAULT ''
44+
);
45+
INSERT INTO session_meta (session_id, compaction_marker_state) VALUES (
46+
'ses-json',
47+
'{"boundaryMessageId":"msg-user","summaryMessageId":"msg-summary","compactionPartId":"prt-comp","summaryPartId":"prt-summary","boundaryOrdinal":10,"targetEndMessageId":"msg-target"}'
48+
);
49+
INSERT INTO session_meta (session_id, compaction_marker_state) VALUES (
50+
'ses-legacy',
51+
'{"boundaryMessageId":"msg-user","summaryMessageId":"msg-summary","compactionPartId":"prt-comp","summaryPartId":"prt-summary","boundaryOrdinal":10}'
52+
);
53+
`);
54+
55+
runMigrations(db);
56+
runMigrations(db);
57+
58+
expect(columnNames(db)).toContain("compaction_marker_target_end_message_id");
59+
const rows = db
60+
.prepare(
61+
"SELECT session_id, compaction_marker_target_end_message_id FROM session_meta ORDER BY session_id",
62+
)
63+
.all() as Array<{
64+
session_id: string;
65+
compaction_marker_target_end_message_id: string | null;
66+
}>;
67+
expect(rows).toEqual([
68+
{ session_id: "ses-json", compaction_marker_target_end_message_id: "msg-target" },
69+
{ session_id: "ses-legacy", compaction_marker_target_end_message_id: null },
70+
]);
71+
} finally {
72+
closeQuietly(db);
73+
}
74+
});
75+
});

0 commit comments

Comments
 (0)