fix(transform): make the mid-turn release valve actually detect injections
The bug
hasNewerRealUserMessage (read-session-db.ts:133) decides whether a newer real user message exists after the latest assistant turn — isMidTurnFromOpenCodeDb uses it to release the mid-turn lock. It filtered on $.synthetic on the message row:
AND COALESCE(json_extract(data, '$.synthetic'), 0) NOT IN (1, 'true')
OpenCode persists synthetic on the part row's data, never on the message row. Measured on a live opencode.db:
| probe | rows |
|---|---|
json_extract(data,'$.synthetic') on message | 0 |
json_extract(data,'$.synthetic') on part | 11,320 |
So the predicate is always true. Every synthetic injection — Channel-2 ceiling nudges, s2s frames, subagent-message notifications — counts as a real user message and releases the mid-turn lock while a turn is still accumulating tool calls. The lock exists so the transform never rewrites bytes mid-turn, so this quietly gives up that protection.
Two things kept it hidden: the comment above the return asserted the opposite ("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"), and the existing test fixtured synthetic: true on the message row — a shape production never produces, which passes only because the filter reads that field. Comment and test agreed with each other and both were wrong.
The fix
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.
All three pieces are load-bearing, and each was found by measurement rather than reasoning:
1. marker.kind is required. 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 (packages/tui/src/routes/session/index.tsx ≈1431-1441: !x.synthetic && metadata.marker.kind !== undefined). They're tagged structurally instead. Live counts: 1,778 such parts (message 847, inbox 683, interrupt 248). Testing synthetic alone misses all of them.
2. ALL-parts, not ANY. 1,813 user messages carry a synthetic part and a non-synthetic companion. Of those, 525 are genuine operator prompts that used an @mention — the mention adds a synthetic agent part beside real human text. ANY-semantics would classify those live human turns as injected and suppress the lock on real input: the inverse bug, and worse than the original.
3. 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 — so this is a live case, not a theoretical one.
The misleading comment is replaced with one that states where synthetic actually lives and why each clause exists.
Tests
Rewrote the existing synthetic test to fixture the part-level shape (it still expects true — still mid-turn), and added four cases:
| case | expectation | guards |
|---|---|---|
| synthetic part after stale tool-calls tail | mid-turn true | the original bug |
marker part (metadata.marker.kind: "inbox") | mid-turn true | the marker.kind clause |
@mention operator prompt (real text + synthetic agent part) | releases → false | ANY-semantics inversion |
| partless user message | releases → false | vacuous-ALL fence |
| marker part + plain operator text | releases → false | mixed-parts |
The synthetic-part and marker-part cases are red-verified against the unfixed query:
(fail) does not release mid-turn for synthetic-part user messages after a stale tool-calls tail
(fail) does not release mid-turn for marker-part user messages after a stale tool-calls tail
18 pass / 2 fail
Restoring the fix: 20 pass / 0 fail.
Gates
| gate | result |
|---|---|
read-session-db.test.ts | 20 pass / 0 fail |
plugin bun test | 3357 pass / 0 fail (clean-master baseline 3353; +4 net) |
tsc --noEmit | clean |
| lint | no diagnostics in either touched file |
CI note: Check (plugin), Check (pi-plugin) and Check (dashboard) are currently failing on master itself (7af5961d) with lint errors in files this PR doesn't touch — compartment-chunk-embedding.ts, storage-git-commits.ts, render-mural.test.ts, embedding-local.test.ts and others. This branch inherits that red; it doesn't add to it. Happy to include the lint cleanup here if you'd rather, but it seemed wrong to bundle unrelated fixes into a behavioral change.
Note on scope
I deliberately did not propose tagging the marker/companion parts synthetic to make a single-flag test work — that would erase every ✉/⊘ system-event line from the operator's transcript, per the TUI predicate above. Separately worth flagging for maintainers: metadata.marker.kind appears to be the real "machine-generated" discriminator but isn't documented as such, so a consumer reaching for the obvious synthetic flag gets the classification wrong in both directions. A short note in the docs, or a dedicated systemGenerated field, would prevent the next consumer repeating this. Happy to file that separately if useful.
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.
Summary by cubic
Fixes mid-turn lock detection so synthetic, marker-only, and ignored-only user messages no longer release the lock. This prevents premature unlocks while tool calls are still running.
- Bug Fixes
- Detect injections at the part level; a user message is injected only if it has parts and every part is machine-generated (
synthetic,metadata.marker.kind, orignored). - Add an EXISTS guard so partless user messages count as real input.
- Update tests with real marker shapes and cases for @mentions/mixed parts, partless, ignored-only, ignored+real text, numeric
ignored: 1, and interrupt/message markers.
- Detect injections at the part level; a user message is injected only if it has parts and every part is machine-generated (
Greptile Summary
The PR corrects mid-turn release detection by classifying user messages from their associated parts instead of a nonexistent message-level synthetic flag.
- Treats messages as injected only when they contain parts and every part is synthetic, marker-tagged, or ignored.
- Preserves real-user classification for partless and mixed-part messages.
- Expands coverage for synthetic, marker, ignored, mixed, partless, and
@mentionmessage shapes.
Confidence Score: 5/5
The PR appears safe to merge.
No blocking failure remains.
Important Files Changed
| Filename | Overview |
|---|---|
| packages/plugin/src/hooks/magic-context/read-session-db.ts | Replaces the ineffective message-level synthetic check with all-parts classification and a partless-message guard. |
| packages/plugin/src/hooks/magic-context/read-session-db.test.ts | Adds representative coverage for synthetic, marker, ignored, mixed, partless, and mention-bearing user messages. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Newer user message] --> B{Has parts?}
B -- No --> R[Real user message: release lock]
B -- Yes --> C{Every part synthetic, marker-tagged, or ignored?}
C -- Yes --> I[Injected message: keep lock]
C -- No --> R
Reviews (2): Last reviewed commit: "fix(transform): treat ignored-only user ..." | Re-trigger Greptile