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..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 @@ -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,11 +148,154 @@ 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 from peer" }, 200); + insertPart(db, "session-1", "user-1", "part-1", { + type: "text", + text: "✉ Inbox from peer", + metadata: { + marker: { + kind: "inbox", + from: "Peer Session", + sessionId: "ses_peer0000000000000000000", + }, + }, + }); + + 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(); 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 75bbccb5d..2611a58ae 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,40 @@ 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 + AND COALESCE(json_extract(p.data, '$.ignored'), 0) NOT IN (1, 'true') + ) + ) 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, 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; }