Skip to content

feat (chat): add queued mid-turn steer handling#1501

Merged
esengine merged 3 commits into
esengine:mainfrom
donle:donle/busy-steer-queue
May 22, 2026
Merged

feat (chat): add queued mid-turn steer handling#1501
esengine merged 3 commits into
esengine:mainfrom
donle:donle/busy-steer-queue

Conversation

@donle

@donle donle commented May 21, 2026

Copy link
Copy Markdown

Summary

This PR adds queued mid-turn steering for busy turns.

When a turn is already running, users can now type ordinary prompt text to steer the current task instead of waiting for the turn to finish. The steer text is not executed immediately and does not interrupt the in-flight model request. It is queued and applied at the next loop iteration boundary.

Motivation

Previously, busy turns blocked prompt input. The first local change opened the input while busy, but it had two problems:

  • busy-time submissions could be confused with normal commands such as /new or /clear
  • multiple steer submissions could overwrite each other because the loop only held one pending steer value

This PR makes the behavior explicit:

  • busy input means “steer the current turn”
  • commands are not accepted as steer input
  • multiple steer messages are preserved and consumed in order

Execution Flow

During a normal idle submit, prompt handling is unchanged.

During a busy turn:

  1. The TUI/Dashboard input remains editable.
  2. The submitted text is checked before it reaches the loop.
  3. Command-like input is rejected while busy:
    • slash commands, such as /new, /clear, /model
    • shell-style commands, such as !ls
    • memory commands, such as #note
  4. Ordinary text is accepted as a steer message.
  5. The loop appends that steer text to an internal FIFO queue.
  6. The currently running model request or tool execution continues untouched.
  7. At the next model-iteration boundary, the loop consumes one queued steer.
  8. That steer is added to the conversation as user-role guidance.
  9. If more steer messages are queued, the loop keeps running and consumes the next one at the following iteration boundary.

This guarantees that steer input affects only future request payloads, never the request that is already in flight.

Queue Semantics

The loop now stores pending steer messages in a FIFO queue instead of a single pending value.

This changes the behavior from “last steer wins” to “all accepted steers are applied in order”.

Example:

  1. The model is running a tool.
  2. User submits focus on the frontend issue.
  3. User submits also check mobile layout.
  4. The first steer is consumed after the current step.
  5. The second steer is consumed at the next iteration boundary.

No steer message is silently overwritten.

UI Behavior

The TUI and Dashboard prompt areas now communicate the busy-steer mode directly:

  • busy prompt input stays editable
  • placeholders explain that the user is steering the current task
  • slash/picker behavior is disabled while busy
  • accepted steer messages are cleared from the input
  • consumed steer messages are mirrored into the Dashboard conversation stream

The Changes panel is also aligned with the main Chat panel: busy input can now actually submit steer text instead of appearing editable while the send path silently returns.

Tests

The loop tests now cover:

  • steer text is persisted as wrapped user-role guidance
  • multiple steer messages are queued
  • queued steer messages are consumed in order
  • one steer is consumed per loop iteration boundary
  • existing steer-consumption state still resets correctly between turns

@donle donle changed the title Add queued mid-turn steer handling feat (chat): add queued mid-turn steer handling May 21, 2026
@esengine

Copy link
Copy Markdown
Owner

Thanks for taking this on, @donle — the core idea is a real improvement and I want to land it. The loop-side FIFO is clean, the six exit-path queue-clears are right, and the repairedCalls.length === 0 && queue.length > 0 → continue patch is exactly the bug that would have orphaned queued steers otherwise. Test for "two steers consumed in order" is the one I would have asked for.

A few things to address before I merge:

1. Comments — code-as-documentation is the project rule. A handful of new comments restate what the code already says, which the tests/comment-policy.test.ts gate doesn't always catch but I do enforce manually. Specific spots to delete or compress to one why-line:

  • src/cli/ui/App.tsx ~L2671-2683: the 5-line "Busy with an active turn: inject text as a mid-turn steer message…" block — the call site loop.steer(text) already documents itself. Delete the comment.
  • src/cli/ui/App.tsx ~L3375: "The ghost preview from handleSubmit's steer branch already confirmed…" — delete; the empty branch is self-explanatory in context.
  • src/loop.ts ~L702-704: "Consume one queued steer between model calls. This never mutates the currently running request…" — the loop is short enough to read; if anything stays, one line explaining why we consume between calls (not what).
  • src/cli/ui/PromptInput.tsx L46-48 and src/cli/ui/ComposerArea.tsx L51: internal interface fields don't get JSDoc — the project rule is JSDoc only on src/index.ts re-exports. Names already say enough.
  • src/cli/ui/PromptInput.tsx ~L133: the inputFrozen / inputActive derivation is self-evident from the names; the two-line block above is redundant.
  • dashboard/src/panels/chat.ts ~L773-774: drop the "When busy, the server steers the text…" comment — the call site does the work.

2. The wrapper string is mine to author. This text:

[Mid-turn steer queued by the user. Do not treat this as a new task; use it only as additional guidance for the current task after completing the current step.]

is a prompt-engineering choice that affects model behavior across every steer, and I'd like to land that line myself in a follow-up after this PR merges. For this PR, please either:

  • (a) Hoist the wrapper into a single exported constant (e.g. MID_TURN_STEER_WRAPPER) at the top of src/loop.ts so it's the one line I edit later, or
  • (b) Drop the wrapper for now — inject the raw text as a user message and let me layer the wrapper in once we've decided whether it should be user-role text vs. a structured marker the model recognizes.

I lean (a). Either is fine.

3. pendingUser const-ification. Confirmed: on main, pendingUser is initialized null at step entry, never reassigned to non-null anywhere in the function, but still read at loop.ts:741 (allowEmpty: pendingUser !== null) and loop.ts:744. The let → const is behaviorally equivalent today, but the variable's existence implies there used to be a non-null path that got refactored out. Please either:

  • Leave let and add a one-line // TODO(#nnnn): pendingUser is currently always null; either wire it back or remove after opening a tracking issue, or
  • Remove pendingUser entirely from step() plus the call sites at L706/L716/L741/L744 (collapse to null literal) — clearer than leaving dead state.

4. Open a tracking issue for "mid-turn steer queue", and link it from the PR. The interaction primitive deserves a public design pointer for the next person who touches this surface — busy-input behavior is something I get asked about. One paragraph is enough; the PR description already has most of the material.

Once 1-4 are addressed I'll merge. CI side is already in great shape — just substance and policy.

@donle

donle commented May 22, 2026

Copy link
Copy Markdown
Author

Addressed the local review items:

  • removed the redundant comments called out in the review
  • hoisted the steer wrapper into MID_TURN_STEER_WRAPPER
  • removed the always-null pendingUser path instead of leaving dead state

Opened follow-up tracking issue ^

@donle donle force-pushed the donle/busy-steer-queue branch from f2fa4e6 to df05310 Compare May 22, 2026 08:46
@donle donle force-pushed the donle/busy-steer-queue branch from df05310 to 1574836 Compare May 22, 2026 10:00
@esengine esengine merged commit c7368be into esengine:main May 22, 2026
4 checks passed
esengine added a commit that referenced this pull request May 22, 2026
…se (#1565)

* chore(release): 0.49.0 — static-history TUI, queued steers, Bing default, lifecycle plans

Headline themes:
- TUI: Static-history renderer is the only path; virtual-viewport layers removed (#1529 stages 1-4)
- Chat: queued mid-turn steer handling so input mid-render doesn't drop or fight the live frame (#1501)
- Web search: default switches to Bing; dashboard engine switcher; Mojeek dropped (#1558)
- Plans: lifecycle evidence summaries surface why a plan is ready to accept (#1500)
- Desktop: native OS notifications for approvals + completion (#1519)
- i18n: CLI command output (/mcp /sessions /prune /theme) + approval-prompt labels translated (#1524, #1560)
- Security: SSRF block in web_fetch (#1544), edit-snapshot path containment (#1454), shell redirect sandbox (#1457), Task integrity guardrail (#1516)
- Tools: per-turn dispatch-rate limit (#1356); run_command discourages shell-based edits (#1514)
- Client: DeepSeek 429 → concurrency-limit hint (#1526); timeoutMs honored with AbortSignal (#1535); --no-proxy opt-out for direct route (#1507)
- Files: read/edit/restore preserves source encoding (GB18030 / UTF-8 BOM) (#1518)
- Context: pinned constraints survive folds + full tail capture (#1515, #1552)
- Refactor: lifecycle risk policy extracted into its own module (#1557)

See CHANGELOG for the full list.

* fix(context): align fold summary prefix with main agent for cache reuse

The summarizer call was sending a bespoke "You compress conversation
history" system prompt and no tools, guaranteeing a 0% cache hit
against the main agent's just-cached prefix. Reshape the request so
system + tools + head bytes mirror the live agent's last call — the
only novel bytes are the trailing summarize instruction.

Skill-pin handling now collects bodies read-only instead of stubbing
mid-head, so the cache prefix stays unbroken. The summarize
instruction names pinned skills so the model knows not to paraphrase
their bodies (which we append verbatim regardless).

Measured on a real session at 48.7K prompt tokens:
  OLD shape: 0.0% cache hit  → $0.145 per fold
  NEW shape: 99.6% cache hit → $0.015 per fold
  saving: 89.6% per fold

* tools: add fold-cache shape + live benchmarks

bench-fold-cache-shape.mjs replays real session jsonls, simulates
OLD vs NEW summary-call shapes at the fold point, and reports
byte-level shared-prefix with the main agent's preceding request.
Pure local — no API required.

bench-fold-cache-live.mjs sends one priming + two summary calls to
DeepSeek and reports prompt_cache_hit_tokens / cost for each shape.
Used to confirm the shape change actually translates to API-side
cache hits.

---------

Co-authored-by: reasonix <reasonix@deepseek.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants