Skip to content

Commit 2862b64

Browse files
mason: fix mid-turn release and redaction
Co-authored-by: Alfonso [Magic Context] <288211368+alfonso-magic-context@users.noreply.github.com>
1 parent 4ed06dd commit 2862b64

9 files changed

Lines changed: 204 additions & 8 deletions

File tree

packages/cli/src/lib/diagnostics-pi.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { projectPathToPiDirSlug } from "../commands/migrate";
6-
import { collectDiagnostics } from "./diagnostics-pi";
6+
import { collectDiagnostics, sanitizeValue } from "./diagnostics-pi";
77

88
const tempRoots: string[] = [];
99
const originalHome = process.env.HOME;
@@ -35,6 +35,20 @@ afterEach(() => {
3535
}
3636
});
3737

38+
describe("sanitizeValue Pi diagnostics redaction", () => {
39+
it("preserves numeric thresholds while redacting string secrets", () => {
40+
expect(
41+
sanitizeValue({
42+
execute_threshold_tokens: 200000,
43+
api_key: "sk-x",
44+
}),
45+
).toEqual({
46+
execute_threshold_tokens: 200000,
47+
api_key: "<REDACTED>",
48+
});
49+
});
50+
});
51+
3852
describe("collectDiagnostics Pi path resolution", () => {
3953
it("reads recent sessions from PI_CODING_AGENT_DIR instead of HOME/.pi/agent", async () => {
4054
const root = makeTempRoot();

packages/cli/src/lib/diagnostics-pi.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ function shouldRedactKey(key: string): boolean {
206206
}
207207

208208
export function sanitizeValue(value: unknown, key = ""): unknown {
209+
if (value === null || typeof value === "number" || typeof value === "boolean") return value;
209210
if (shouldRedactKey(key)) return "<REDACTED>";
210211
if (typeof value === "string") return sanitizeString(value);
211212
if (Array.isArray(value)) return value.map((entry) => sanitizeValue(entry));

packages/cli/src/lib/redaction.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,24 @@ describe("sanitizeConfigValue — preserves benign config keys", () => {
142142
expect(sanitized.execute_threshold_tokens.default).toBe(80000);
143143
expect(sanitized.execute_threshold_tokens["openai/gpt-5.5"]).toBe(200000);
144144
});
145+
146+
it("preserves non-string scalars under secret-looking keys", () => {
147+
const sanitized = sanitizeConfigValue({
148+
execute_threshold_tokens: 200000,
149+
api_key: "sk-x",
150+
access_token: 12345,
151+
has_token: true,
152+
refresh_token: null,
153+
}) as Record<string, unknown>;
154+
155+
expect(sanitized).toEqual({
156+
execute_threshold_tokens: 200000,
157+
api_key: "<REDACTED:api_key>",
158+
access_token: 12345,
159+
has_token: true,
160+
refresh_token: null,
161+
});
162+
});
145163
});
146164

147165
describe("hasShareabilitySensitiveText", () => {

packages/pi-plugin/src/boundary-execution-pi.test.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,36 @@ describe("boundary execution Pi integration", () => {
7373
expect(peekDeferredExecutePending(db, "s1")?.id).toBe("flag-1");
7474
});
7575

76-
it("13. Pi boundary execute drains prior flag when work executes", () => {
76+
it("13. Pi stale-tail release executes and drains prior flag on a fresh user turn", () => {
77+
const db = createDb();
78+
ensureSessionMetaRow(db, "s1");
79+
setDeferredExecutePendingIfAbsent(db, "s1", flag());
80+
const midTurn = isMidTurnPi(
81+
{
82+
messages: [
83+
{ role: "assistant", stopReason: "toolUse", content: [] },
84+
{ role: "user", content: "new turn" },
85+
],
86+
},
87+
"s1",
88+
);
89+
const result = applyMidTurnDeferral({
90+
base: "execute",
91+
bypassReason: "none",
92+
midTurn,
93+
});
94+
95+
expect(midTurn).toBe(false);
96+
expect(result.midTurnAdjustedSchedulerDecision).toBe("execute");
97+
const current = peekDeferredExecutePending(db, "s1");
98+
expect(current).not.toBeNull();
99+
if (current !== null) {
100+
clearDeferredExecutePendingIfMatches(db, "s1", current);
101+
}
102+
expect(peekDeferredExecutePending(db, "s1")).toBeNull();
103+
});
104+
105+
it("14. Pi boundary execute drains prior flag when work executes", () => {
77106
const db = createDb();
78107
setDeferredExecutePendingIfAbsent(db, "s1", flag());
79108
const current = peekDeferredExecutePending(db, "s1");
@@ -84,7 +113,7 @@ describe("boundary execution Pi integration", () => {
84113
expect(peekDeferredExecutePending(db, "s1")).toBeNull();
85114
});
86115

87-
it("14. Pi preserves flag when execute-gated work fails", () => {
116+
it("15. Pi preserves flag when execute-gated work fails", () => {
88117
const db = createDb();
89118
ensureSessionMetaRow(db, "s1");
90119
setDeferredExecutePendingIfAbsent(db, "s1", flag());
@@ -96,7 +125,7 @@ describe("boundary execution Pi integration", () => {
96125
expect(peekDeferredExecutePending(db, "s1")?.id).toBe("flag-1");
97126
});
98127

99-
it("15. a prior deferred flag does NOT promote a defer decision to execute (parity with OpenCode contract #4)", () => {
128+
it("16. a prior deferred flag does NOT promote a defer decision to execute (parity with OpenCode contract #4)", () => {
100129
// Regression: Pi previously force-promoted schedulerDecision defer→execute
101130
// whenever a deferred-execute flag existed and the pass wasn't mid-turn,
102131
// diverging from OpenCode (which treats the flag as drain-on-success ONLY

packages/pi-plugin/src/read-session-pi.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,34 @@ describe("isMidTurnPi", () => {
2020
).toBe(true);
2121
});
2222

23+
it("is not mid-turn when a newer real user message ends a stale toolUse tail", () => {
24+
expect(
25+
isMidTurnPi(
26+
{
27+
messages: [
28+
{ role: "assistant", stopReason: "toolUse", content: [] },
29+
{ role: "user", content: "new turn" },
30+
],
31+
},
32+
"session-1",
33+
),
34+
).toBe(false);
35+
});
36+
37+
it("does not release mid-turn for custom-role nudges after a stale toolUse tail", () => {
38+
expect(
39+
isMidTurnPi(
40+
{
41+
messages: [
42+
{ role: "assistant", stopReason: "toolUse", content: [] },
43+
{ role: "custom", content: "agent nudge" },
44+
],
45+
},
46+
"session-1",
47+
),
48+
).toBe(true);
49+
});
50+
2351
it("is mid-turn when the latest assistant has an unpaired toolCall", () => {
2452
expect(
2553
isMidTurnPi(
@@ -36,6 +64,23 @@ describe("isMidTurnPi", () => {
3664
).toBe(true);
3765
});
3866

67+
it("is not mid-turn when a newer real user message ends an unpaired toolCall tail", () => {
68+
expect(
69+
isMidTurnPi(
70+
{
71+
messages: [
72+
{
73+
role: "assistant",
74+
content: [{ type: "toolCall", id: "call-1", name: "bash" }],
75+
},
76+
{ role: "user", content: "new turn" },
77+
],
78+
},
79+
"session-1",
80+
),
81+
).toBe(false);
82+
});
83+
3984
it("is not mid-turn when toolCall content is paired or absent", () => {
4085
expect(
4186
isMidTurnPi(

packages/pi-plugin/src/read-session-pi.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ export function isMidTurnPi(event: unknown, _sessionId: string): boolean {
163163
}
164164

165165
if (latestAssistant === null) return false;
166+
if (hasRealUserAfter(messages, latestAssistantIndex)) return false;
166167
if (latestAssistant.stopReason === "toolUse") return true;
167168

168169
const toolCallIds = getToolCallIds(latestAssistant.content);
@@ -184,6 +185,21 @@ export function isMidTurnPi(event: unknown, _sessionId: string): boolean {
184185
return false;
185186
}
186187

188+
function hasRealUserAfter(
189+
messages: readonly unknown[],
190+
latestAssistantIndex: number,
191+
): boolean {
192+
// If an interrupted assistant tool-use sequence is followed by a real user
193+
// message, treat that user message as the turn boundary so the next pass can
194+
// finish any work held back during tool-use. Custom-role nudge messages are
195+
// not real user turns, so they should not trigger the boundary.
196+
for (const msg of messages.slice(latestAssistantIndex + 1)) {
197+
if (msg === null || typeof msg !== "object") continue;
198+
if ((msg as Record<string, unknown>).role === "user") return true;
199+
}
200+
return false;
201+
}
202+
187203
function getToolCallIds(content: unknown): Set<string> {
188204
const ids = new Set<string>();
189205
if (!Array.isArray(content)) return ids;

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

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,23 @@ function insertAssistant(
4646
sessionId: string,
4747
id: string,
4848
data: Record<string, unknown>,
49+
timeCreated = Date.now(),
4950
): void {
5051
db.prepare(
5152
"INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)",
52-
).run(id, sessionId, Date.now(), Date.now(), JSON.stringify({ role: "assistant", ...data }));
53+
).run(id, sessionId, timeCreated, timeCreated, JSON.stringify({ role: "assistant", ...data }));
54+
}
55+
56+
function insertUser(
57+
db: Database,
58+
sessionId: string,
59+
id: string,
60+
data: Record<string, unknown>,
61+
timeCreated: number,
62+
): void {
63+
db.prepare(
64+
"INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)",
65+
).run(id, sessionId, timeCreated, timeCreated, JSON.stringify({ role: "user", ...data }));
5366
}
5467

5568
function insertPart(
@@ -67,14 +80,30 @@ function insertPart(
6780
describe("isMidTurnFromOpenCodeDb", () => {
6881
it("is mid-turn when the latest assistant finished with tool-calls", () => {
6982
const db = createMidTurnDb();
70-
insertAssistant(db, "session-1", "assistant-1", { finish: "tool-calls" });
83+
insertAssistant(db, "session-1", "assistant-1", { finish: "tool-calls" }, 100);
84+
85+
expect(isMidTurnFromOpenCodeDb(db, "session-1")).toBe(true);
86+
});
87+
88+
it("is not mid-turn when a newer real user message ends a stale tool-calls tail", () => {
89+
const db = createMidTurnDb();
90+
insertAssistant(db, "session-1", "assistant-1", { finish: "tool-calls" }, 100);
91+
insertUser(db, "session-1", "user-1", { content: "new turn" }, 200);
92+
93+
expect(isMidTurnFromOpenCodeDb(db, "session-1")).toBe(false);
94+
});
95+
96+
it("does not release mid-turn for synthetic user messages after a stale tool-calls tail", () => {
97+
const db = createMidTurnDb();
98+
insertAssistant(db, "session-1", "assistant-1", { finish: "tool-calls" }, 100);
99+
insertUser(db, "session-1", "user-1", { content: "agent nudge", synthetic: true }, 200);
71100

72101
expect(isMidTurnFromOpenCodeDb(db, "session-1")).toBe(true);
73102
});
74103

75104
it("is mid-turn when the latest assistant has a non-provider-executed tool part", () => {
76105
const db = createMidTurnDb();
77-
insertAssistant(db, "session-1", "assistant-1", { finish: "stop" });
106+
insertAssistant(db, "session-1", "assistant-1", { finish: "stop" }, 100);
78107
insertPart(db, "session-1", "assistant-1", "part-1", {
79108
type: "tool",
80109
providerExecuted: false,
@@ -83,6 +112,18 @@ describe("isMidTurnFromOpenCodeDb", () => {
83112
expect(isMidTurnFromOpenCodeDb(db, "session-1")).toBe(true);
84113
});
85114

115+
it("is not mid-turn when a newer real user message ends an unexecuted tool tail", () => {
116+
const db = createMidTurnDb();
117+
insertAssistant(db, "session-1", "assistant-1", { finish: "stop" }, 100);
118+
insertPart(db, "session-1", "assistant-1", "part-1", {
119+
type: "tool",
120+
providerExecuted: false,
121+
});
122+
insertUser(db, "session-1", "user-1", { content: "new turn" }, 200);
123+
124+
expect(isMidTurnFromOpenCodeDb(db, "session-1")).toBe(false);
125+
});
126+
86127
it("is not mid-turn for provider-executed tool parts", () => {
87128
const db = createMidTurnDb();
88129
insertAssistant(db, "session-1", "assistant-1", { finish: "stop" });

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

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ interface RawCountRow {
1212
interface AssistantMidTurnRow {
1313
id?: string;
1414
finish?: string | null;
15+
timeCreated?: number;
16+
}
17+
18+
interface ExistenceRow {
19+
one?: number;
1520
}
1621

1722
interface PartDataRow {
@@ -96,7 +101,8 @@ export function isMidTurnFromOpenCodeDb(db: Database, sessionId: string): boolea
96101
const latestAssistant = db
97102
.prepare(
98103
`SELECT id,
99-
json_extract(data, '$.finish') as finish
104+
json_extract(data, '$.finish') as finish,
105+
time_created as timeCreated
100106
FROM message
101107
WHERE session_id = ?
102108
AND json_extract(data, '$.role') = 'assistant'
@@ -106,6 +112,7 @@ export function isMidTurnFromOpenCodeDb(db: Database, sessionId: string): boolea
106112
.get(sessionId) as AssistantMidTurnRow | null;
107113

108114
if (typeof latestAssistant?.id !== "string") return false;
115+
if (hasNewerRealUserMessage(db, sessionId, latestAssistant.timeCreated)) return false;
109116
if (latestAssistant.finish === "tool-calls") return true;
110117

111118
const partRows = db
@@ -123,6 +130,30 @@ export function isMidTurnFromOpenCodeDb(db: Database, sessionId: string): boolea
123130
});
124131
}
125132

133+
function hasNewerRealUserMessage(
134+
db: Database,
135+
sessionId: string,
136+
latestAssistantTimeCreated: unknown,
137+
): boolean {
138+
if (typeof latestAssistantTimeCreated !== "number") return false;
139+
const row = db
140+
.prepare(
141+
`SELECT 1 as one
142+
FROM message
143+
WHERE session_id = ?
144+
AND time_created > ?
145+
AND json_extract(data, '$.role') = 'user'
146+
AND COALESCE(json_extract(data, '$.synthetic'), 0) NOT IN (1, 'true')
147+
LIMIT 1`,
148+
)
149+
.get(sessionId, latestAssistantTimeCreated) as ExistenceRow | null;
150+
// OpenCode persists promptAsync/channel-2 synthetic prompts as
151+
// message.info.synthetic, which is the top-level $.synthetic field in the
152+
// message table's data JSON. Those agent-directed nudges should not end a
153+
// still-accumulating tool-use turn, but a later real user message does.
154+
return row?.one === 1;
155+
}
156+
126157
interface AssistantModelRow {
127158
providerID?: string;
128159
modelID?: string;

packages/plugin/src/shared/redaction.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ export function hasShareabilitySensitiveText(text: string): boolean {
244244
}
245245

246246
export function sanitizeConfigValue(value: unknown, keyPath: string[] = []): unknown {
247+
if (value === null || typeof value === "number" || typeof value === "boolean") return value;
247248
const key = keyPath.at(-1) ?? "";
248249
if (key && isSecretKey(key)) {
249250
return `<REDACTED:${redactionTypeForKey(key)}>`;

0 commit comments

Comments
 (0)