feat(gateway,desktop): per-turn model note + display-only mode label (composer-mode frame) - #108242
Closed
LisandroNahuelH wants to merge 14 commits into
Closed
LisandroNahuelH wants to merge 14 commits into
LisandroNahuelH wants to merge 14 commits into
Conversation
LisandroNahuelH
force-pushed
the
feat/composer-mode-note
branch
from
September 11, 2026 19:28
a880697 to
df93a4d
Compare
A single send can now carry a composer-mode frame: `note` is an instruction for the model, `mode` an opaque display-only label. The note never mixes into the user's own `content` — it rides the api_content sidecar on the delivered row. - `AIAgent.steer`/`redirect` accept `note`/`mode` and queue them next to their pending slot (concatenating notes, last label wins); the delivery sites take them one-shot so a frame can never leak into a later correction. - `steer_user_row` puts the note in `api_content` and the label in display_metadata; `_apply_active_turn_redirect` prepends the note to the correction's api_content and carries the label. - The redundant-final-turn path consumes a leftover frame instead of letting it attach to the next turn; interrupt/clear paths drop it with their slot.
… steer `prompt.submit`, `session.steer` and `session.redirect` accept an optional `note`/`mode` pair (`parse_turn_note`: sanitized + capped; mode is display-only). The note is prepended to the run message at turn assembly — like the other per-turn notes — so it reaches the model without polluting the durable row. - Busy arrivals keep the frame in their OWN queue envelope (never merged into a plain slot, never dropped by the self-duplicate sanitizer); the drain hands it back to the turn, and the compute-host turn frame carries the same keys. - The server-side queued branch of a build-window redirect keeps the frame too.
The composer middleware is now the single producer of a per-turn frame: it runs once per send and hands `note`/`mode` to the gateway through submit options / redirect params, so a mode survives every entry point — typed, voice, queued drains, and mid-turn steers (including the tile composer). - `redirectPrompt` runs the chain before the RPC and before the session-not-found retry; `steerPrompt` runs it before the optimistic append so a cancel leaves no bubble behind. - `'canceled'` is a cancel, not a rejection: `steerDraft` restores the draft and queues nothing, and the queue's steer-now consumes an entry only on a delivered redirect (`accepted === true`) — 'canceled' is truthy, so a truthiness test would silently eat the queued words.
- Python: parse/sanitize/cap, steer-row api_content vs content, one-shot frame consumption, queue envelope isolation, sanitizer exemption (proven red against the pre-fix tree). - Desktop: cancel semantics for `steerDraft` (restore, no enqueue) and `steerQueuedNow` (no consumption), both failing under the old truthiness logic.
Area guides now state the contract: the frame is per-send, the middleware is its only producer, the note rides api_content / the label display_metadata, and 'canceled' never consumes queued words.
The note/label queued beside a pending steer/redirect is now written and read by module-level helpers (`_ic_queue_correction_note` / `_ic_take_correction_note`) that mirror `_ic_lock`/`_ic_slot`: `getattr`-based, lock-optional, so the `__init__`-less stubs the lock-semantics tests build keep working. `AIAgent.steer()` calling the previous bound method broke `tests/agent/test_lock_fallback_base_semantics.py` (2 failures vs the baseline: the slot-reads-fail-loud case and the steer/drain roundtrip). Delivery sites import the helpers instead of reaching through the agent. Also covers the stub contract in the frame contract suite.
`_run_prompt_submit` now calls `_prepare_turn_input(..., turn_note=...)`, so the rebind namespace in the bot-live-owner test must accept keyword arguments. With the old `lambda *args: None` the refusal path raised inside the turn, the recovery branch hit a stale name (the template namespace has no `_recover_turn_exception`) and the failed-mailbox receipt was never committed — the test asserted `'failed'` but saw `'claimed'`.
A queued send drained with whatever mode was live AT DRAIN TIME: queueCurrentDraft enqueued {text, attachments} and the drain re-ran the middleware chain, so changing modes with entries still queued stamped every drain with the new mode (and an Agent switch erased the queued Plan/Ask/Debug framing entirely).
Run the chain once at enqueue and seal {mode, note} onto the QueuedPromptEntry; every drain (foreground runDrain, background drain, steerQueuedNow) hands the sealed frame back as submit options with fromQueue, and the wrapper passes note/mode/fromQueue INTO the chain so middleware can hand the frame back untouched instead of re-deriving.
Store: the frame persists through enqueue and survives text edits. Hook: the chain runs once at enqueue (seal) and a drain forwards the sealed frame with fromQueue — proven against a registered middleware, never the live mode.
The queue freeze sealed frames only inside queueCurrentDraft; a steer rejected mid-turn (reconnect / settle race) fell into the raw requeue at use-composer-submit — a frameless entry, so its drain arrived note=0 (observed live: ask note lost during a backend recycle). Extract sealQueuedFrame (runs the chain once, cancel => empty frame) and seal on every enqueue path; queueCurrentDraft now reuses it.
…g one-shot copy) Primacy+recency for instruction compliance: the ask note already led the run message via _prepare_turn_input; also park it in the one-shot slot (gated on the [mode:ask] head) so _merge_gateway_notes appends it — api_content = note + text + note. Ask-only: plan/debug keep their single leading copy (cost). Session titles read the pristine persist override first so the scaffold never leaks into a title.
LisandroNahuelH
force-pushed
the
feat/composer-mode-note
branch
from
September 12, 2026 23:40
621f81b to
8e67de8
Compare
This was referenced Sep 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Adds an optional per-turn frame (
note+mode) to the turn RPCs, so a client can frame ONE send — a Cursor-style composer mode, an internal note, a surface hint — without typing it into the user's message:note(sanitized + capped viaparse_turn_note) is a model instruction for THIS send. It is delivered through the per-turnapi_contentsidecar — the same channel the existing per-turn notes use (/steermarkers, speech-interrupted, hud surface) — so the durablecontentand every bubble keep the user's own words.modeis an opaque, display-only label stored in the row'sdisplay_metadata(popped from every outbound copy). It never reaches the wire and is never policy.Why the core: only the backend knows which model/provider serves a turn, and a mid-turn steer never runs a client's composer middleware — so a plugin can only fake this by rewriting the draft (the note then lives in the transcript forever) or silently losing the mode on every steer.
Fixes #108241
Related work
Adjacent, still-open PRs on the same surfaces — cross-linked, no overlap:
display_metadatafor every user message, derived from sourcedisplay_metadatainsession_searchoutputapi_contentsidecar when merging consecutive assistantsType of Change
Changes Made
Gateway (
tui_gateway/)methods_prompt.py—parse_turn_note(params)+prompt.submitthreadsturn_note/turn_modethrough_run_after_agent_ready, the busy path, and the compute-host dispatch.prompt_turn.py—_run_prompt_submitmergesmodeinto display_metadata and prependsnoteto the run message in_prepare_turn_input(a mode-only turn still persists display_metadata).session_auto_continue.py—_handle_busy_submit/_ac_try_correction/_enqueue_promptcarry the frame; a framed arrival keeps its OWN queue envelope (never merged into a plain slot) and survives_sanitize_queued_entry_vs_inflight_user; the drain hands it back to the turn.methods_session.py—session.steer/session.redirectparse the frame and pass it to the agent (only when present, so agents without the kwargs keep working).compute_host_bridge.py/compute_host.py— the isolated-turn frame carriesnote/mode.Agent (
agent/)interrupt_control.py—steer()/redirect()acceptnote/mode, queue them beside their pending slot (_queue_correction_note, notes concatenate, label last-wins) and expose a one-shot_take_correction_note; interrupt/clear paths drop the frame with their slot.prompt_builder.py(steer_user_row) — note →api_content, label →display_metadata.conversation_loop.py(_apply_active_turn_redirect) — note prepended to the correction'sapi_content, label carried on the row.agent_runtime_helpers.py/turn_finalizer.py— the steer delivery takes the frame one-shot; a requeued or leftover steer keeps/drops it explicitly, so a stale frame can never attach to a later correction.Desktop (
apps/desktop/src/)composer/contrib.ts+composer/index.tsx—ComposerModeFrame/ComposerDraft.note|mode, forwarded intoSubmitTextOptions→prompt.submit {note, mode}.use-prompt-actions/index.ts(redirectPrompt) — runsrunComposerMiddlewareexactly ONCE, before the RPC and before the session-not-found retry; returns'canceled'for a cancel (never confused with a rejection, which keeps queueing the words).session-tile-actions.ts(steerPrompt) — same, BEFORE the optimistic append so a cancel leaves no bubble behind.use-composer-submit.ts(steerDraft) —'canceled'restores the draft (loadIntoComposer) and queues nothing;use-composer-queue.ts(steerQueuedNow) consumes a queued entry only on a delivered redirect (accepted === true) —'canceled'is a truthy string, so the old truthiness test silently ate the queued words.store/composer-queue.ts+use-composer-queue.ts— a queued entry OWNS its frame:queueCurrentDraftruns the chain once at enqueue and seals{mode, note}onto theQueuedPromptEntry; the foreground drain, the background drain, andsteerQueuedNowhand the sealed frame back as submit options withfromQueue, and the wrapper passesnote/mode/fromQueueINTO the chain so the middleware hands the frame back untouched. Changing modes while entries are still queued no longer restamps every drain with the live mode (nor erases a queued Plan/Ask/Debug frame on an Agent switch).composer/queue-frame.ts(sealQueuedFrame) + the enqueue paths — every requeue path seals: a steer rejected mid-turn (reconnect / settle race) used to requeue raw text (use-composer-submitfallback) — a frameless entry whose drain arrived note-less (observed live during a backend recycle). The chain runs once per seal; a cancel yields an empty frame — queueing never loses the words over a frame lookup.Docs —
apps/desktop/AGENTS.md,tui_gateway/AGENTS.md.Reference client compatibility (
composer-modesv12.1, verified)The local plugin that motivated the frame now SHIPS the frame (its v10.10–v12 line,
independent of this change):
composer.middlewarederives{mode, note}per send — every send carries itsown frame (Agent = no note, by design) and a queued drain passes the sealed entry
frame through untouched (
fromQueueshort-circuits re-derivation). The wrapper andqueue plumbing added here are what make that pass-through possible;
prompt.submit {session_id, text, display_kind}(the plan-approve path)keeps working — the frame params are optional and only sent when present;
as a known limit — the simple Enter during a live turn went straight to
session.redirect, bypassing the chain). The mode now rides the steer instead ofbeing dropped;
after switching the plugin to Agent kept
content= the user's words whileapi_contentcarried the Ask note (the frame frozen at enqueue), and the 4-modematrix (ask/plan/debug framed, agent clean) landed as designed;
{text, note, mode}, thetranscript keeps the user's words, and the label lands in
display_metadata;(allowed/forbidden lists + a mandatory Spanish closing sentence on actionable
requests) and rides BOTH ends of the send —
api_content = note + text + note,ask-only (the sandwich this PR implements). A rejected steer's requeue seals its
frame too (
sealQueuedFrame), closing the one frameless path a livebackend-recycle repro exposed;
agent._persist_user_message_overridefirst), so API-only scaffolding — ask sandwich included — never becomes a sidebar title.
How to Test
python -m pytest tests/tui_gateway/test_turn_note_frame.py -q→ 7 passed (new contract suite; its 6 behaviour tests fail on the pre-fix tree).python -m pytest tests/run_agent/test_steer.py tests/test_tui_gateway_queue_on_busy.py tests/agent/test_api_content_sidecar.py tests/agent/test_gateway_turn_sidecar.py tests/agent/test_compression_busy_steer_anchor.py -q→ 111 passed.cd apps/desktop && npx vitest run --project ui src/store/composer-queue.test.ts src/app/chat/composer/hooks/use-composer-queue.test.tsx src/app/chat/composer/hooks/use-composer-submit.test.tsx src/app/chat/composer/contrib.test.ts src/app/chat/session-tile-actions.test.ts src/app/session/hooks/use-prompt-actions/index.test.tsx src/app/session/hooks/use-message-stream/steer-arrival-order.test.tsx→ 216 passed (the cancel-semantics cases, the queued-frame seal/drain cases, and the steer-fallback seal fail with the old logic). The gateway contract suite stays green with the ask sandwich added:bash scripts/run_tests.sh tests/tui_gateway/test_turn_note_frame.py→ 8 passed.npx tsc -p apps/desktop/tsconfig.json --noEmit→ clean;ruff checkon the touched Python files → clean;eslinton the touched TS files → clean.content(no note text); then type mid-turn (steer) in a mode and check the correction row carries the note inapi_contentand the label indisplay_metadata.Checklist
Code
fix(scope):,feat(scope):, etc.)scripts/run_tests.sh(the same runnertests.ymluses): 41,629 tests, 1,107 failures on this Windows box. Every failing file was re-run against a cleanmainworktree: 327 of the 334 fail identically there (environment/OS), and the run caught 3 real regressions that this PR fixes (2 lock-semantics cases — the_ic_*helpers are now module-level and stub-safe — and a stale_prepare_turn_inputtest double). After those fixes the candidate set is green except one known-flaky log-append race that also fails 5/5 on cleanmain.Documentation & Housekeeping
apps/desktop/AGENTS.md+tui_gateway/AGENTS.mddocument the frame contractcli-config.yaml.exampleif I added/changed config keys — N/A (no config keys)CONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — done (both area guides)Screenshots / Logs
Full-suite A/B (local, Windows 11)