fix(transform): make the mid-turn release valve actually detect injections

Merged
IC
2mo ago
+181-11

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:

proberows
json_extract(data,'$.synthetic') on message0
json_extract(data,'$.synthetic') on part11,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:

caseexpectationguards
synthetic part after stale tool-calls tailmid-turn truethe original bug
marker part (metadata.marker.kind: "inbox")mid-turn truethe marker.kind clause
@mention operator prompt (real text + synthetic agent part)releases → falseANY-semantics inversion
partless user messagereleases → falsevacuous-ALL fence
marker part + plain operator textreleases → falsemixed-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

gateresult
read-session-db.test.ts20 pass / 0 fail
plugin bun test3357 pass / 0 fail (clean-master baseline 3353; +4 net)
tsc --noEmitclean
lintno 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.


View with [code]smith Autofix with [code]smith
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, or ignored).
    • 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.

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 @mention message shapes.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

FilenameOverview
packages/plugin/src/hooks/magic-context/read-session-db.tsReplaces 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.tsAdds 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