From 26b3982428df0e5ba4072d6afc093bad1801030f Mon Sep 17 00:00:00 2001 From: Tehan Date: Wed, 5 Aug 2026 20:00:09 +0200 Subject: [PATCH 1/2] fix(transform): make the mid-turn release valve actually detect injections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hasNewerRealUserMessage` tested `$.synthetic` on the MESSAGE row, but OpenCode persists that flag on the PART row's data — never on the message. Measured on a live opencode.db: message-level `$.synthetic` matches 0 rows, part-level matches 11,320. The predicate was therefore always true, so every synthetic injection (Channel-2 nudges, s2s frames, subagent notifications) counted as a real user message and released the mid-turn lock while a turn was still accumulating tool calls. The comment above the return asserted the opposite ("message.info.synthetic, which is the top-level $.synthetic field in the message table's data JSON"), and the existing test fixtured `synthetic: true` on the message row — a shape production never produces. Comment and test agreed with each other, which is why the inert filter survived. Replace it with a part join. A part is machine-generated iff it carries `synthetic: true` OR a `metadata.marker.kind`; a user message is injected iff it HAS parts AND EVERY part is machine-generated. Both clauses are load-bearing: - Marker parts (s2s inbox lines, subagent-message notifications, steer/cancel notices) are deliberately NON-synthetic so the TUI renders them as visible system-event lines, and are tagged structurally via `metadata.marker.kind` instead. Testing `synthetic` alone misses them. - ALL-parts rather than ANY: an @mention puts a synthetic `agent` part on a genuine operator prompt, so ANY-semantics would classify live human turns as injected and suppress the lock on real input — the inverse bug, and worse. - The `EXISTS (… FROM part …)` clause is the vacuous-ALL fence: a partless user message satisfies "every part is machine-generated" trivially and must count as real. Partless user rows do occur (4 of 19,004 on the box measured). Rewrote the misleading test to fixture the part-level shape, and added cases for marker parts, @mention prompts, partless messages, and mixed marker-plus-operator-text. The synthetic-part and marker-part cases are red-verified against the unfixed query. --- .../magic-context/read-session-db.test.ts | 65 ++++++++++++++++++- .../hooks/magic-context/read-session-db.ts | 35 +++++++--- 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/packages/plugin/src/hooks/magic-context/read-session-db.test.ts b/packages/plugin/src/hooks/magic-context/read-session-db.test.ts index 18060bff6..8ce7603cf 100644 --- a/packages/plugin/src/hooks/magic-context/read-session-db.test.ts +++ b/packages/plugin/src/hooks/magic-context/read-session-db.test.ts @@ -93,10 +93,15 @@ describe("isMidTurnFromOpenCodeDb", () => { expect(isMidTurnFromOpenCodeDb(db, "session-1")).toBe(false); }); - it("does not release mid-turn for synthetic user messages after a stale tool-calls tail", () => { + it("does not release mid-turn for synthetic-part user messages after a stale tool-calls tail", () => { const db = createMidTurnDb(); insertAssistant(db, "session-1", "assistant-1", { finish: "tool-calls" }, 100); - insertUser(db, "session-1", "user-1", { content: "agent nudge", synthetic: true }, 200); + insertUser(db, "session-1", "user-1", { content: "agent nudge" }, 200); + insertPart(db, "session-1", "user-1", "part-1", { + type: "text", + text: "agent nudge", + synthetic: true, + }); expect(isMidTurnFromOpenCodeDb(db, "session-1")).toBe(true); }); @@ -143,6 +148,62 @@ describe("isMidTurnFromOpenCodeDb", () => { expect(isMidTurnFromOpenCodeDb(db, "session-1")).toBe(false); }); + it("does not release mid-turn for marker-part user messages after a stale tool-calls tail", () => { + const db = createMidTurnDb(); + insertAssistant(db, "session-1", "assistant-1", { finish: "tool-calls" }, 100); + insertUser(db, "session-1", "user-1", { content: "inbox notification" }, 200); + insertPart(db, "session-1", "user-1", "part-1", { + type: "text", + text: "✉ Inbox from peer", + metadata: { marker: { kind: "inbox" } }, + }); + + expect(isMidTurnFromOpenCodeDb(db, "session-1")).toBe(true); + }); + + it("releases mid-turn for an @mention operator prompt with a synthetic agent part", () => { + const db = createMidTurnDb(); + insertAssistant(db, "session-1", "assistant-1", { finish: "tool-calls" }, 100); + insertUser(db, "session-1", "user-1", { content: "do the thing @research-deep" }, 200); + insertPart(db, "session-1", "user-1", "part-1", { + type: "text", + text: "do the thing @research-deep", + }); + insertPart(db, "session-1", "user-1", "part-2", { + type: "agent", + name: "research-deep", + synthetic: true, + }); + + expect(isMidTurnFromOpenCodeDb(db, "session-1")).toBe(false); + }); + + it("releases mid-turn for a partless user message (vacuous-ALL fence)", () => { + const db = createMidTurnDb(); + insertAssistant(db, "session-1", "assistant-1", { finish: "tool-calls" }, 100); + insertUser(db, "session-1", "user-1", { content: "new turn" }, 200); + // No parts inserted — partless messages must count as real. + + expect(isMidTurnFromOpenCodeDb(db, "session-1")).toBe(false); + }); + + it("releases mid-turn when a user message has a marker part AND a real text part", () => { + const db = createMidTurnDb(); + insertAssistant(db, "session-1", "assistant-1", { finish: "tool-calls" }, 100); + insertUser(db, "session-1", "user-1", { content: "real input with marker" }, 200); + insertPart(db, "session-1", "user-1", "part-1", { + type: "text", + text: "✉ Inbox from peer", + metadata: { marker: { kind: "inbox" } }, + }); + insertPart(db, "session-1", "user-1", "part-2", { + type: "text", + text: "real input with marker", + }); + + expect(isMidTurnFromOpenCodeDb(db, "session-1")).toBe(false); + }); + it("is not mid-turn when there is no assistant message", () => { const db = createMidTurnDb(); diff --git a/packages/plugin/src/hooks/magic-context/read-session-db.ts b/packages/plugin/src/hooks/magic-context/read-session-db.ts index 75bbccb5d..3c069e7e7 100644 --- a/packages/plugin/src/hooks/magic-context/read-session-db.ts +++ b/packages/plugin/src/hooks/magic-context/read-session-db.ts @@ -139,18 +139,35 @@ function hasNewerRealUserMessage( const row = db .prepare( `SELECT 1 as one - FROM message - WHERE session_id = ? - AND time_created > ? - AND json_extract(data, '$.role') = 'user' - AND COALESCE(json_extract(data, '$.synthetic'), 0) NOT IN (1, 'true') + FROM message m + WHERE m.session_id = ? + AND m.time_created > ? + AND json_extract(m.data, '$.role') = 'user' + AND NOT ( + EXISTS (SELECT 1 FROM part p WHERE p.message_id = m.id) + AND NOT EXISTS ( + SELECT 1 FROM part p + WHERE p.message_id = m.id + AND COALESCE(json_extract(p.data, '$.synthetic'), 0) NOT IN (1, 'true') + AND json_extract(p.data, '$.metadata.marker.kind') IS NULL + ) + ) LIMIT 1`, ) .get(sessionId, latestAssistantTimeCreated) as ExistenceRow | null; - // OpenCode persists promptAsync/channel-2 synthetic prompts as - // message.info.synthetic, which is the top-level $.synthetic field in the - // message table's data JSON. Those agent-directed nudges should not end a - // still-accumulating tool-use turn, but a later real user message does. + // OpenCode persists synthetic as an annotation on the PART row's data, never + // on the message row. So separating injected from real user messages requires + // a part join. A user message is injected iff it HAS at least one part AND + // EVERY part is machine-generated — where a part is machine-generated if it + // carries either synthetic=true or a marker part (metadata.marker.kind). + // Marker parts are deliberately NON-synthetic so the TUI renders them as + // visible system-event lines; they are identified structurally. ALL-parts + // semantics is load-bearing: a real operator prompt may include a synthetic + // `agent` part from an @mention — classifying that as injected would release + // the mid-turn lock on genuine human input (the inverse bug, and worse). + // The EXISTS guard on part rows is the vacuous-ALL fence: a partless message + // satisfies "every part is machine-generated" trivially, so it must count as + // real to avoid incorrectly suppressing a lock release. return row?.one === 1; } From 1ce9b36f5a4970e156e2f3415afd36b8d9d4dd66 Mon Sep 17 00:00:00 2001 From: Tehan Date: Wed, 5 Aug 2026 22:05:03 +0200 Subject: [PATCH 2/2] fix(transform): treat ignored-only user messages as injected in the mid-turn valve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hasNewerRealUserMessage` classified a user message as REAL whenever any part was non-synthetic and carried no `metadata.marker.kind`. Parts flagged `ignored: true` satisfied both conditions, so an ignored-only message released the mid-turn lock and allowed a tail rewrite while a tool turn was still running. Measured on a live opencode.db: 102 ignored parts, all on user messages, none synthetic, none carrying a marker — and every one of those messages was ignored-ONLY, so all 102 released the lock. They are plugin status notifications (bodies like "## Claude Routing Status", "## Claude Quotas"). What settles the classification is opencode's own serializer, not our inference. message-v2.ts:206 keeps a user text part only when `!part.ignored && part.text !== ""`, so an ignored part never reaches the model. A message whose parts are all ignored contributes nothing to the prompt and therefore cannot be a real user turn. Adds a third machine-generated clause to the inner NOT EXISTS witness, using the same COALESCE truthiness convention as the synthetic clause (both `1` and `'true'` count). Tests: the marker fixtures previously used an invented `metadata: { marker: { kind } }` shape — self-confirming, since the code reads the same assumption. They now use shapes captured verbatim from real persisted part rows (live counts: inbox 740, interrupt 168, message 805), including the sibling fields beside `kind` (`from`/`sessionId`, `intent`/`origin`, `peer`/`expectReply`), which also proves the predicate does not depend on `marker` having exactly one key. Session id and peer name are placeholders. Five cases added: ignored-only keeps the lock; ignored part alongside genuine operator text still RELEASES it (the guard against suppressing a legitimate release); numeric `ignored: 1`; and interrupt + message marker kinds. RED verified by reverting the query alone — the two ignored cases fail (23 pass / 2 fail) and pass again with the clause restored (25/0). --- .../magic-context/read-session-db.test.ts | 91 ++++++++++++++++++- .../hooks/magic-context/read-session-db.ts | 23 +++-- 2 files changed, 103 insertions(+), 11 deletions(-) diff --git a/packages/plugin/src/hooks/magic-context/read-session-db.test.ts b/packages/plugin/src/hooks/magic-context/read-session-db.test.ts index 8ce7603cf..b4ecf3687 100644 --- a/packages/plugin/src/hooks/magic-context/read-session-db.test.ts +++ b/packages/plugin/src/hooks/magic-context/read-session-db.test.ts @@ -151,11 +151,17 @@ describe("isMidTurnFromOpenCodeDb", () => { it("does not release mid-turn for marker-part user messages after a stale tool-calls tail", () => { const db = createMidTurnDb(); insertAssistant(db, "session-1", "assistant-1", { finish: "tool-calls" }, 100); - insertUser(db, "session-1", "user-1", { content: "inbox notification" }, 200); + insertUser(db, "session-1", "user-1", { content: "✉ Inbox from peer" }, 200); insertPart(db, "session-1", "user-1", "part-1", { type: "text", text: "✉ Inbox from peer", - metadata: { marker: { kind: "inbox" } }, + metadata: { + marker: { + kind: "inbox", + from: "Peer Session", + sessionId: "ses_peer0000000000000000000", + }, + }, }); expect(isMidTurnFromOpenCodeDb(db, "session-1")).toBe(true); @@ -209,6 +215,87 @@ describe("isMidTurnFromOpenCodeDb", () => { expect(isMidTurnFromOpenCodeDb(db, "session-1")).toBe(false); }); + + it("does not release mid-turn for an ignored-only user part after a stale tool-calls tail", () => { + const db = createMidTurnDb(); + insertAssistant(db, "session-1", "assistant-1", { finish: "tool-calls" }, 100); + insertUser(db, "session-1", "user-1", { content: "status notification" }, 200); + insertPart(db, "session-1", "user-1", "part-1", { + type: "text", + text: "## Claude Routing Status", + ignored: true, + }); + + expect(isMidTurnFromOpenCodeDb(db, "session-1")).toBe(true); + }); + + it("releases mid-turn when a user message has an ignored part AND a real text part", () => { + const db = createMidTurnDb(); + insertAssistant(db, "session-1", "assistant-1", { finish: "tool-calls" }, 100); + insertUser(db, "session-1", "user-1", { content: "notification + real input" }, 200); + insertPart(db, "session-1", "user-1", "part-1", { + type: "text", + text: "## Claude Quotas", + ignored: true, + }); + insertPart(db, "session-1", "user-1", "part-2", { + type: "text", + text: "actually do the thing", + }); + + expect(isMidTurnFromOpenCodeDb(db, "session-1")).toBe(false); + }); + + it("does not release mid-turn when ignored is numeric 1 (truthy variant)", () => { + const db = createMidTurnDb(); + insertAssistant(db, "session-1", "assistant-1", { finish: "tool-calls" }, 100); + insertUser(db, "session-1", "user-1", { content: "status notification" }, 200); + insertPart(db, "session-1", "user-1", "part-1", { + type: "text", + text: "## Claude Quotas", + ignored: 1, + }); + + expect(isMidTurnFromOpenCodeDb(db, "session-1")).toBe(true); + }); + + it("does not release mid-turn for interrupt marker parts after a stale tool-calls tail", () => { + const db = createMidTurnDb(); + insertAssistant(db, "session-1", "assistant-1", { finish: "tool-calls" }, 100); + insertUser(db, "session-1", "user-1", { content: "interrupt" }, 200); + insertPart(db, "session-1", "user-1", "part-1", { + type: "text", + text: "interrupt", + metadata: { + marker: { + kind: "interrupt", + intent: "abort", + origin: "parent", + }, + }, + }); + + expect(isMidTurnFromOpenCodeDb(db, "session-1")).toBe(true); + }); + + it("does not release mid-turn for message marker parts after a stale tool-calls tail", () => { + const db = createMidTurnDb(); + insertAssistant(db, "session-1", "assistant-1", { finish: "tool-calls" }, 100); + insertUser(db, "session-1", "user-1", { content: "peer message" }, 200); + insertPart(db, "session-1", "user-1", "part-1", { + type: "text", + text: "peer message", + metadata: { + marker: { + kind: "message", + peer: "subagent", + expectReply: false, + }, + }, + }); + + expect(isMidTurnFromOpenCodeDb(db, "session-1")).toBe(true); + }); }); function useTempDataHome(prefix: string): void { diff --git a/packages/plugin/src/hooks/magic-context/read-session-db.ts b/packages/plugin/src/hooks/magic-context/read-session-db.ts index 3c069e7e7..2611a58ae 100644 --- a/packages/plugin/src/hooks/magic-context/read-session-db.ts +++ b/packages/plugin/src/hooks/magic-context/read-session-db.ts @@ -150,6 +150,7 @@ function hasNewerRealUserMessage( WHERE p.message_id = m.id AND COALESCE(json_extract(p.data, '$.synthetic'), 0) NOT IN (1, 'true') AND json_extract(p.data, '$.metadata.marker.kind') IS NULL + AND COALESCE(json_extract(p.data, '$.ignored'), 0) NOT IN (1, 'true') ) ) LIMIT 1`, @@ -159,15 +160,19 @@ function hasNewerRealUserMessage( // on the message row. So separating injected from real user messages requires // a part join. A user message is injected iff it HAS at least one part AND // EVERY part is machine-generated — where a part is machine-generated if it - // carries either synthetic=true or a marker part (metadata.marker.kind). - // Marker parts are deliberately NON-synthetic so the TUI renders them as - // visible system-event lines; they are identified structurally. ALL-parts - // semantics is load-bearing: a real operator prompt may include a synthetic - // `agent` part from an @mention — classifying that as injected would release - // the mid-turn lock on genuine human input (the inverse bug, and worse). - // The EXISTS guard on part rows is the vacuous-ALL fence: a partless message - // satisfies "every part is machine-generated" trivially, so it must count as - // real to avoid incorrectly suppressing a lock release. + // carries either synthetic=true, a marker part (metadata.marker.kind), or + // an ignored flag. Marker parts are deliberately NON-synthetic so the TUI + // renders them as visible system-event lines; they are identified + // structurally. Ignored parts are dropped by opencode's own model-facing + // serializer (message-v2.ts:206): an ignored text part is never pushed into + // the model-facing message, so a message whose parts are all ignored cannot + // constitute a real user turn. ALL-parts semantics is load-bearing: a real + // operator prompt may include a synthetic `agent` part from an @mention — + // classifying that as injected would release the mid-turn lock on genuine + // human input (the inverse bug, and worse). The EXISTS guard on part rows is + // the vacuous-ALL fence: a partless message satisfies "every part is + // machine-generated" trivially, so it must count as real to avoid incorrectly + // suppressing a lock release. return row?.one === 1; }