Skip to content

Commit b003c3c

Browse files
committed
mason: harden marker reconciliation gates
1 parent 271e3d9 commit b003c3c

10 files changed

Lines changed: 443 additions & 192 deletions

packages/plugin/src/hooks/magic-context/compaction-marker-manager.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,11 @@ function markerServeWire(
136136
): string {
137137
const messages = [
138138
{
139-
info: { role: "user", sessionID: sessionId },
139+
info: { role: "user", sessionID: sessionId, syntheticHead: true },
140140
parts: [{ type: "text", text: "m0", synthetic: true }],
141141
},
142142
{
143-
info: { role: "user", sessionID: sessionId },
143+
info: { role: "user", sessionID: sessionId, syntheticHead: true },
144144
parts: [{ type: "text", text: "m1", synthetic: true }],
145145
},
146146
{
@@ -159,7 +159,7 @@ function markerServeWire(
159159
db,
160160
sessionId,
161161
tagger: createTagger(),
162-
ctxReduceCallable: true,
162+
ctxReduceAvailability: { callable: true, frozen: true },
163163
});
164164
return serializeAnthropicWireWithAdjacentAssistantMerge(messages);
165165
}

packages/plugin/src/hooks/magic-context/ctx-reduce-availability.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,43 +16,43 @@ describe("ctx_reduce availability (spawn tools map)", () => {
1616
const verdict = resolveCtxReduceAvailabilityFromMessages("ses-allow", [
1717
userMsg({ "*": false, read: true, grep: true }),
1818
]);
19-
expect(verdict).toBe(false);
19+
expect(verdict).toEqual({ callable: false, frozen: true });
2020
});
2121

2222
it("resolves true when ctx_reduce is explicitly allowed", () => {
2323
clearCtxReduceAvailability("ses-explicit");
2424
const verdict = resolveCtxReduceAvailabilityFromMessages("ses-explicit", [
2525
userMsg({ "*": false, read: true, ctx_reduce: true }),
2626
]);
27-
expect(verdict).toBe(true);
27+
expect(verdict).toEqual({ callable: true, frozen: true });
2828
});
2929

3030
it("fails open for sessions without a tools map (normal sessions)", () => {
3131
clearCtxReduceAvailability("ses-plain");
3232
const verdict = resolveCtxReduceAvailabilityFromMessages("ses-plain", [userMsg()]);
33-
expect(verdict).toBe(true);
33+
expect(verdict).toEqual({ callable: true, frozen: true });
3434
});
3535

3636
it("resolves false when ctx_reduce is explicitly denied", () => {
3737
clearCtxReduceAvailability("ses-deny");
3838
const verdict = resolveCtxReduceAvailabilityFromMessages("ses-deny", [
3939
userMsg({ ctx_reduce: false }),
4040
]);
41-
expect(verdict).toBe(false);
41+
expect(verdict).toEqual({ callable: false, frozen: true });
4242
});
4343

4444
it("freezes the verdict per session — later, different tool maps cannot flap it", () => {
4545
clearCtxReduceAvailability("ses-frozen");
4646
const first = resolveCtxReduceAvailabilityFromMessages("ses-frozen", [
4747
userMsg({ "*": false, read: true }),
4848
]);
49-
expect(first).toBe(false);
49+
expect(first).toEqual({ callable: false, frozen: true });
5050
// Same session, contradictory map on a later pass: cached verdict wins
5151
// (per-turn maps can differ; a flapping verdict would bust the cache).
5252
const second = resolveCtxReduceAvailabilityFromMessages("ses-frozen", [
5353
userMsg({ "*": false, ctx_reduce: true }),
5454
]);
55-
expect(second).toBe(false);
55+
expect(second).toEqual({ callable: false, frozen: true });
5656
});
5757

5858
it("ignores non-user messages and falls open when the first user message carries no signal", () => {
@@ -61,7 +61,7 @@ describe("ctx_reduce availability (spawn tools map)", () => {
6161
{ info: { role: "assistant" } },
6262
userMsg({}),
6363
]);
64-
expect(verdict).toBe(true);
64+
expect(verdict).toEqual({ callable: true, frozen: true });
6565
});
6666

6767
it("does not freeze a fail-open verdict from an array with no user message", () => {
@@ -70,13 +70,13 @@ describe("ctx_reduce availability (spawn tools map)", () => {
7070
const provisional = resolveCtxReduceAvailabilityFromMessages("ses-no-user-yet", [
7171
{ info: { role: "assistant" } },
7272
]);
73-
expect(provisional).toBe(true);
73+
expect(provisional).toEqual({ callable: true, frozen: false });
7474
// ...but must NOT lock the session: the real first user message (a
7575
// deny-list spawn) still decides the frozen verdict.
7676
const final = resolveCtxReduceAvailabilityFromMessages("ses-no-user-yet", [
7777
{ info: { role: "assistant" } },
7878
userMsg({ "*": false, read: true }),
7979
]);
80-
expect(final).toBe(false);
80+
expect(final).toEqual({ callable: false, frozen: true });
8181
});
8282
});

packages/plugin/src/hooks/magic-context/ctx-reduce-availability.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,23 +41,23 @@ function verdictFromToolsMap(tools: unknown): boolean | null {
4141
export function resolveCtxReduceAvailabilityFromMessages(
4242
sessionId: string,
4343
messages: ReadonlyArray<{ info?: { role?: string; tools?: unknown } }>,
44-
): boolean {
44+
): CtxReduceAvailabilityVerdict {
4545
const cached = availabilityBySession.get(sessionId);
46-
if (cached !== undefined) return cached;
46+
if (cached !== undefined) return { callable: cached, frozen: true };
4747

4848
for (const message of messages) {
4949
if (message.info?.role !== "user") continue;
5050
// First user message decides: explicit signal, or no-signal → available.
5151
// Either way the verdict is final — freeze it.
5252
const verdict = verdictFromToolsMap(message.info.tools) ?? true;
5353
availabilityBySession.set(sessionId, verdict);
54-
return verdict;
54+
return { callable: verdict, frozen: true };
5555
}
5656
// No user message in the array at all (not a real prompt — e.g. a stray
5757
// pass on an empty session). Fail open but do NOT freeze: caching true here
5858
// would lock a deny-list session into the reduce surface before its first
5959
// user message ever arrives to say otherwise.
60-
return true;
60+
return { callable: true, frozen: false };
6161
}
6262

6363
/** Availability verdict plus whether it is final for the session's lifetime. */

packages/plugin/src/hooks/magic-context/inject-compartments.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2258,18 +2258,21 @@ function prependM0M1Messages(
22582258
m0Text: string,
22592259
m1Text: string,
22602260
): void {
2261-
// `synthetic: true` marks these as injected context, not real user turns.
2261+
// `syntheticHead` identifies the injected m0 and m1 message positions for
2262+
// marker placement; `synthetic: true` marks their parts as injected context,
2263+
// not real user turns.
22622264
// OpenCode's `toModelMessagesEffect` filters on `ignored` (NOT `synthetic`),
22632265
// so the blocks STILL reach the model — but its title-generation gate
22642266
// (`ensureTitle`) counts a message as a real user turn only when not every
22652267
// part is synthetic, and skips titling unless exactly one real user message
2266-
// exists. Without this flag, m[0]+m[1] add two phantom user turns on the
2267-
// first message and permanently suppress the session's auto-title (issue
2268-
// #129). Must NOT use `ignored` here — that would strip the history
2268+
// exists. Without the part-level `synthetic` flag, m[0]+m[1] add two
2269+
// phantom user turns on the first message and permanently suppress the
2270+
// session's auto-title (issue #129). Must NOT use `ignored` here — that
2271+
// would strip the history
22692272
// injection from the real model call.
22702273
messages.unshift(
22712274
{
2272-
info: { role: "user", sessionID: sessionId },
2275+
info: { role: "user", sessionID: sessionId, syntheticHead: true },
22732276
parts: [
22742277
{
22752278
type: "text",
@@ -2279,7 +2282,7 @@ function prependM0M1Messages(
22792282
],
22802283
},
22812284
{
2282-
info: { role: "user", sessionID: sessionId },
2285+
info: { role: "user", sessionID: sessionId, syntheticHead: true },
22832286
parts: [{ type: "text", text: m1Text, synthetic: true }],
22842287
},
22852288
);

packages/plugin/src/hooks/magic-context/tag-messages.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,8 @@ export type MessageInfo = {
195195
role?: string;
196196
sessionID?: string;
197197
summary?: boolean;
198+
/** Marks one of the two m[0]/m[1] messages prepended by compartment injection. */
199+
syntheticHead?: boolean;
198200
finish?: string;
199201
error?: unknown;
200202
};

packages/plugin/src/hooks/magic-context/transform-message-helpers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,9 @@ function hasToolPartWithCallId(message: MessageLike, callId: string): boolean {
142142
}
143143

144144
function isReplayableAssistantAnchor(message: MessageLike): boolean {
145+
// A compaction summary is rebuilt by marker reconciliation, so anchoring a
146+
// synthetic todo part there would lose it when the summary is replaced.
147+
if (message.info.summary === true) return false;
145148
return message.info.error === undefined || message.info.error === null;
146149
}
147150

0 commit comments

Comments
 (0)