diff --git a/docs/feature-map/README.md b/docs/feature-map/README.md index d8de316d65b..24843065ca9 100644 --- a/docs/feature-map/README.md +++ b/docs/feature-map/README.md @@ -141,6 +141,7 @@ this area is reached from inside it unless stated otherwise. | Merged edit rows | Back-to-back edits to the same file show as ONE transcript row: the first edit's row carries a chip with the run's combined count ("3 edits -6 +2"), and opening it lists each edit's diff in order under an "Edit N of M" label, with every edit's output in the row's details panel. Rows that draw nothing (completion markers, approvals, thinking) do not break a run; a read, a command, a message or an edit to another file does. An edit that went through an approval prompt, was rejected or blocked, carries a tool-risk badge, or has an oversized or truncated diff or output keeps its own row; an edit you approved at a prompt also shows an "Approved" tag beside its chip, so it does not read as a duplicate of the run above it. File names in the row title, the chip and the diff card headers are shown percent-decoded (`%20` as a space) while the real path is kept for opening the file | Session transcript → consecutive edits to one file | `pages/chat/editRuns.ts` (groups the runs), `pages/chat/ToolCallLine.tsx` (renders the leader and hides followers), `components/DiffBlock.tsx` (stacked diffs), `utils/pathBasename.ts` (display decoding) | client-only (no handler) | none | | Tool risk badge | A small note under a tool card — **Jev: risky (0.88)** — in a session that approves its own tool calls, saying the Decisions (Jev) seam looked at that call and thought it was worth a second look. An ANNOTATION and nothing else: the permission DECISION is taken without consulting it, so the note changes nothing about what is allowed. It is not a verdict on whether the call RAN either -- a host gate or a PreToolUse hook can still refuse a call that carries a note, and the SEL tool-invocation audit is where that outcome lives. The tooltip says so in words — it states that flagging changed nothing about whether the call was allowed and that the call may still have been refused, never that it ran — and `risky` is drawn in the warning colour rather than the danger colour this transcript uses for a call that WAS blocked, because a coloured word on a tool card otherwise reads as "this was refused". Only the two flagged tiers are drawn, and each of them has to be BELIEVED: a `safe` answer is recorded in the log and leaves the card exactly as it looks without the seam, and so does a `caution` or `risky` answer the seam was under that tier's OWN bar confident about (two separately movable constants, both 0.80 today, and a plain file write or edit never draws `caution` once its write identity is known -- a two-phase call whose diff or kind arrives only on a later refinement is judged by what the initial card had, since the badge is never revised once shown), so the note's presence is the flag. Both bars are measured rather than picked -- the calls a `risky` answer BELOW its bar suppresses were a queued monitor edit and a comment posted on a pull request, and at 0.90 eleven real force-pushes went unflagged; a quarter of the `caution` badges the bar alone still draws are an ordinary workspace write, which the file-write carve-out now suppresses (measured distribution: see `CAUTION_CONFIDENCE_THRESHOLD` in `decisions/points/tool_risk.py`). A session that PROMPTS is never annotated — the person reading the call is the annotation, and a second opinion beside a control they are operating is noise. Carries the same thumbs pair as the decision strip (side `jev`, one shared component), so a reader can say the risk was read wrong. At most twenty notes per turn, and a slow or failed answer draws nothing. The one cost is timing: the note is awaited before this turn's next event is read, so a sampled call's approval can be answered up to the seam's own wait budget later -- bounded, and paid only by a session the switch is on for | Under a tool card in the transcript, when the seam answered for that call and the session auto-approves. Needs BOTH consents: Settings → Developer → Feature Previews → **Decisions (Jev)**, and the second switch under it, **Also send tool-call arguments so Jev can flag risky calls**, which starts off even for an owner who already had the first one on | `pages/chat/ToolRiskBadge.tsx`, `pages/chat/toolRiskRecord.ts` (the pure reader — validates the record and refuses the `safe` tier, no feature of its own), `pages/chat/DecisionVerdictThumbs.tsx` (the verdict control, shared with the decision strip), `pages/chat/ToolCallLine.tsx` (its host) | `decisions/points/tool_risk.py` (the point), `chat_turn/decisions_hooks.py` (`_tool_risk_meta`, which stamps the record on the tool row), `handlers/decisions.py` (the verdict route) | No route of its own. The thumbs post to `POST /api/decisions/feedback` | | Auto (Jev) send while busy | A third mode on the composer's split send button, beside Steer and Queue: Jev decides per message whether text sent into a RUNNING turn interrupts that turn or waits for the next one (`message.steer`). Both paths are the shipped ones and neither changes — the mode only chooses between them, and EVERY refusal (the seam off, this session unsampled, a timeout, an answer outside the two options) takes the steer path the button has always defaulted to, so the feature can only ever pick the other one. Picking Steer or Queue by hand asks nothing and sends nothing: only `steer: "auto"` is decided, and only for a send the dashboard owner made while a turn is running — an app token, an integration and a cron are never decided for and keep falling through to the fail-closed queue. The mode is drawn only while the gateway reports BOTH the fleet ceiling (`decisions_enabled`) and the owner's consent (`permits`), fail-closed on each, AND a turn is actually running — not merely a busy composer, which also covers a slot whose sub-agents are still working and has no turn to decide about — and a stored `auto` resolves back to Steer while any of those is missing, so a withdrawn consent cannot leave a mode on screen whose send would not be decided. The request carries the new message and, only inside the consented `history_budget_chars` ceiling (default `0`, so nothing by default), a redacted extract of the turn in progress: its own request and the newest assistant text and tool activity, never its private reasoning. The receipt rides the USER row it is about as `meta.decisions_strip` and renders as one line — what Jev CHOSE (the choice, never the delivery, because a chosen steer whose client is gone falls through to the queue), how sure it was, how long it took, and the same `jev` thumbs the decision strip uses. The key is reserved to the gateway: a request's own `meta` cannot carry one, so no caller can stamp a decision nobody made. ⌘↩ still performs the OTHER action for one send, which from this mode is a manual queue that asks nothing. A send nobody decided for carries no record and draws nothing | Composer split send button while a turn runs → **Auto (Jev)** (needs Settings → Developer → Feature Previews → **Decisions (Jev)**) | `components/BusySendButton.tsx`, `components/ChatInput.tsx`, `pages/chat/useJevAutoSend.ts` (the availability read), `pages/chat/SteerDecisionLine.tsx`, `pages/chat/decisionRecord.ts` (`readSteerRecord`), `pages/chat/UserMessage.tsx`, `pages/ChatPage.tsx` (steer routing in `pages/chat/page/busyTurnControls.ts`; its composer in `pages/chat/page/ComposerDockView.tsx`) and `components/ChatPane.tsx` (its two hosts) | `decisions/points/message_steer.py`, `chat_handlers.py` (the busy branch that asks), `chat_delivery.py` (the stamp on each path) | `POST /api/chat` (`steer: "auto"`), `GET /api/dashboard/config` (`decisions_enabled`), `GET /api/decisions/consent` (`permits`). The thumbs post to `POST /api/decisions/feedback` | +| Take back a pending steer | A message sent into a running turn waits until the agent can read it. While it waits, on a kiro-cli session, its "Steering…" line offers **Queue instead** and, one menu deep, **Edit** and **Unsend**. The click asks kiro-cli to drop the steers it holds; what the turn reports next decides the outcome. A dropped steer is taken back: its row stays as a one-line note without the text, an Edit puts the text back in the composer, a Queue instead puts it in the queue, and the other steers the drop took are sent again in order. A steer the turn read first stays steered, and one whose turn ended first never ran (a Queue instead then runs as the requeued message). Offered only on the session's own composer steers, never a peer's, a channel's or an automation's | A pending steer's "Steering…" line, while the turn runs | `pages/chat/UserMessage.tsx`, `hooks/useSteerRetract.ts`, `pages/chat/page/busyTurnControls.ts` | `chat_api/prompt_queue.py` (`api_chat_slot_steer_retract`), `steer_retract.py`, `chat_delivery.py` (marks a retractable row), `chat_turn/steer_queue.py` (the drop, settle and teardown hooks) | `POST /api/chat/slots/{slot}/steers/{mid}/retract` | | Tool approvals | Approve or deny a tool call the agent proposes | Inline card in the transcript | `components/ApprovalCard.tsx` | `handlers/sessions_parts/live_turn.py` | `GET /api/approvals`, `POST /api/approvals/{id}/{action}` | | MCP elicitation | Answer a question an MCP server itself asks mid tool call: a small form built from the server's schema, or a link to visit and confirm. No trust setting answers it; the person does, or the wait runs out and the ask is cancelled | Inline card in the transcript | `pages/chat/ElicitationCard.tsx`, `pages/chat/page/transcriptRendering.tsx` | `dashboard/handlers/chat_elicitation.py`, `dashboard/chat_turn/elicitation_prompt.py`, `acp/elicitation.py` | `POST /api/chat/slots/{slot}/elicitation` | | Question cards | Agent asks a multiple-choice question in chat and waits; the answers return as the tool result and show as a collapsible "N questions answered" card under the tool row | Pending: card above the composer. Answered: \"N questions answered\" card under the `ask_question` tool row in the transcript | `components/PendingQuestionCard.tsx`, `pages/chat/AskAnswersCard.tsx` | `handlers/ask_question.py` | `POST /api/ask-question`, `GET /api/ask-question/pending`, `POST /api/ask-question/dismiss`, `POST /api/ask-question/{ask_id}/answer`, `POST /api/agent-ask/open`, `POST /api/agent-ask/{ask_id}/wait`, `POST /api/agent-ask/{ask_id}/withdraw` | diff --git a/docs/system-specs/modules/harness-onboarding.md b/docs/system-specs/modules/harness-onboarding.md index 6a3f70ad772..c84af04f87a 100644 --- a/docs/system-specs/modules/harness-onboarding.md +++ b/docs/system-specs/modules/harness-onboarding.md @@ -93,6 +93,7 @@ vocabulary. | `ACP_BACKENDS_STEER` | The `_session/steer` extension. A steer sent to a non-implementer answers `-32601`. Also the deny-notice gate (`supports_refusal_steer`). | | `ACP_BACKENDS_STEERING_REQUEST` | A user's mid-turn steer sent as a request whose answer is awaited, where an accepted steer can still be dropped with its turn (`steer_needs_loss_recovery`). The `_session/steering` request (codex-acp, claude-agent-acp), whose `injected` / `startedNewTurn` / `failed` / `promptRequired` answer is the delivery evidence, spoken by `AcpSessionHandle` for a shared-runtime host and by `AcpClient` for a per-session one, which also needs the handshake's `_meta.steering.supported` and claude-agent-acp at or above `CLAUDE_ACP_STEERING_MIN_VERSION`; goose's `_goose/unstable/session/steer` (`{runId, messageId}`, then a `user_message_chunk` echo marked `_meta.goose.steer`), spoken by `AcpClient` through `_CLIENT_STEERING_REQUEST_METHODS`; and pi, which takes no request: `AcpClient` writes a sealed steer file Crew's gate extension hands to pi's steering queue, echoed as a pi-acp notify chunk (`acp/pi_steer_turn.py`). Not a deny-notice channel. | | `ACP_BACKENDS_STEER_CONCURRENT_PROMPT` | A user's mid-turn steer sent as a second `session/prompt` that joins the running loop (opencode); its answer arriving with the turn's own is the delivery evidence. Spoken only by `AcpClient`; not a deny-notice channel, and a member also needs loss recovery. | +| `ACP_BACKENDS_STEER_RETRACT` | `_session/steer/clear` drops the steers the running turn has not read and names exactly those, so a pending steer can be taken back (the dashboard's Unsend, Edit and Queue instead). The clear's answer is no evidence: the drop is the notification that follows it. kiro-cli only, on a live capture; KAS's clear names steers its turn already read. | | `ACP_BACKENDS_INTERNAL_SANDBOX` | The harness sandboxes itself, so Kiro Crew's own wrapper stands down. Security-relevant: wrong membership hands isolation to a layer that never starts (H7). | | `ACP_BACKENDS_POD_HOME_REMAP` | A pod-spawned child may have `$HOME` relocated onto the pod tree so home-derived OAuth artifacts remain pod-scoped. Keep this separate from internal-sandbox membership because the two claims have different security effects. | | `ACP_BACKENDS_ACP_RUNTIME` | Driven through `AcpRuntime` — one process demultiplexing N sessions — rather than its own per-session `AcpClient` spawn branch. Every reader takes the frozenset itself: `AcpProvider.is_acp_runtime_backend` for the FOREGROUND start path, and `session._bg_runtime_backends`, which intersects it with the set below and with selectability. Membership states the TRANSPORT and nothing more — the `cli.json` effort and Tool Search overlay is gated on its readers' own sets (`ACP_BACKENDS_EFFORT_OVERLAY`, `ACP_BACKENDS_TOOL_SEARCH_OVERLAY`) at every site that writes, reads or clears it, so a member reading no such file never collects one. | diff --git a/docs/system-specs/modules/harness-parity.md b/docs/system-specs/modules/harness-parity.md index 7b5fef889c0..ca87d10df78 100644 --- a/docs/system-specs/modules/harness-parity.md +++ b/docs/system-specs/modules/harness-parity.md @@ -103,7 +103,7 @@ already reads TRUE for KAS on a plain public build. | Id | Guarantees | Pinned by | Constrains | |---|---|---|---| | H5 | Harness identity is a positive comparison against a named constant, or membership in a named set. `not is_claude_backend`, `!= ACP_BACKEND_KAS`, and `== "kas"` (bare literal) are all forbidden; `is_kiro_backend` and `backend in ACP_BACKENDS_` are the forms. Enforced on the lines a change ADDS, not whole-tree — see the gate doc for why. | `scripts/check_harness_parity.py` (six rules, self-tested), `test_harness_parity.py::test_added_line_gate_self_test_passes`, `::test_added_line_gate_flags_a_planted_negative_test` | every module reading `AcpClient.backend` / `AcpProvider.is_*_backend` | -| H6 | A capability is granted by opt-in membership, never by negation. `is_session_sharing_eligible` reads `ACP_BACKENDS_SESSION_SHARING` and `supports_steer` reads the steer tables described below, so a harness that has not demonstrated the capability does not inherit it from a set it was never added to. Codex is PRESENT in both the sharing set and `ACP_BACKENDS_SESSION_EVICTION` while KAS is present only in the second, and the two answers have different owners — which is why they are two sets, and why the same harness can answer them differently. Eviction is the harness's own property, earned by measurement: the teardown Crew sends it is the standard `session/close` as a request, after which the sessionId stops answering (it was excluded while that verb was `session/cancel`, which frees nothing; the same `close` sent as a notification is ignored, so the delivery is part of the fact). That one membership is what admits codex to every create-and-destroy path on the shared process at once — the high-churn background handles, warm pooled reuse, the entitlement probe — instead of each path gating codex by name. Sharing is granted, and on a fact the eviction answer does not contain: the thread codex persists under `CODEX_HOME` SURVIVES that evicting close, so a `session/load` on the closed id restores the conversation — measured on the same adapter, including from a restarted adapter process, which is the shape a `spawn_continue` actually takes. Teardown is unchanged by it: a shared subagent session is closed like any other, because one left resident would hold its own MCP fleet on a runtime nobody is using. KAS is the harness that now shows why the two sets cannot be one — its `_kiro/session/delete` frees the session (eviction) by removing the record a continuation would load (no sharing), so the two memberships read the same verb and reach opposite answers. **Both drivers answer from the tables, not one of them:** `AcpSessionHandle.supports_steer` reads them through the runtime's own backend id, because a constant that was true while `AcpRuntime` served a single host becomes a claim about the second host the moment one is added — and an advertised steer is met with `-32601` at the user's mid-turn correction. Steer is three tables because it is three verbs and two questions: kiro-cli's fire-and-forget `_session/steer` (`ACP_BACKENDS_STEER`), an awaited steering request (`ACP_BACKENDS_STEERING_REQUEST`): the `_session/steering` request of codex-acp and claude-agent-acp, whose answer (`injected` / `startedNewTurn` / `failed`, and claude's `promptRequired`) is the delivery evidence, and goose's `_goose/unstable/session/steer`, which only `AcpClient` speaks (`_CLIENT_STEERING_REQUEST_METHODS`); and opencode's second `session/prompt`, which joins the running loop and is answered at the same idle as the turn (`ACP_BACKENDS_STEER_CONCURRENT_PROMPT`, spoken only by `AcpClient`). The handle speaks `_session/steering` for codex; `AcpClient` speaks it for claude, which runs one adapter per session, so the turn's own read loop reads the answer. claude earns membership on its own evidence, driven live on 0.65.0 and 0.89.1 (real adapter, Agent SDK and `claude` binary, model API served locally): the adapter advertises `initialize._meta.steering.supported`, pushes the text into the running Agent SDK turn and holds that turn open until a result answers the steer, so the model's next request carries the steered text and the `session/prompt` terminal follows its reply; a request sent with `idleBehavior: "promptRequired"` to a session whose turn has settled runs nothing and answers `promptRequired`. 0.64.x answers `injected` and then ends the turn without running the text, so `AcpClient` speaks the request only from 0.65.0 (`CLAUDE_ACP_STEERING_MIN_VERSION`). So a claude steer is True only for `injected` into the same still-running turn, is reported consumed only at a clean `end_turn`, and is queued on every other answer or ending -- including a turn that ends before the steer lands. A claude session below 0.65.0, or one whose handshake did not advertise the request, takes no steer and its steer queues; the harness card's `mid_turn_steer` line reads membership only, so a new dashboard chat whose adapter reports a release below its steer floor (claude-agent-acp 0.65.0, or goose 1.38.0, the first release with `_goose/unstable/session/steer`, block/goose#9560) gets one notice row naming the installed version, the minimum and the upgrade command (`steer_version_issue`, read through `AcpProvider.steer_upgrade_notice`). The session still runs; a session that reports no readable version gets no notice, as pi's floor reads it. Crew's claude reject is a `deny` without `interrupt`, which does not end the turn, so a denial drops no claude steer; a cancel still can, which is why claude is in `steer_needs_loss_recovery` with codex. `AcpClient` sends no claude steer while its turn is parked on an approval either: nothing reads the answer until the approval is answered, so the steer is queued. A user's steer reads any of them (`supports_steer` on the handle is the union of the first two, and on the client and the card's `mid_turn_steer` line the union of all three); a deny notice steered into a refused turn reads `ACP_BACKENDS_STEER` alone through `supports_refusal_steer`, because codex's approval answer cancels the turn and drops what was injected into it. For the same reason the handle sends no codex steer while a turn is parked on an approval; the steer is queued instead. A steer codex accepted is not safe from a LATER denial or a cancel either: codex drops it with the cut turn. So a codex steer is reported consumed only when its turn ends cleanly, with no denied approval, no cancel and no refusal; on any other ending nothing is reported and the composer's pending entry is queued by the turn's teardown, so the text runs as its own turn instead of being lost. Only the dashboard composer keeps such an entry, so only it steers codex: `steer_needs_loss_recovery` makes `AcpProvider.supports_steer` False and `AcpProvider.steer` refuse, which sends the messaging channels and Side Chat down their queue paths, and `spawn_steer` refuses with `steer_unsupported` in favour of `mode='follow_up'`. The composer's wait for codex's answer is bounded below the browser's send abort, and a slower answer takes the queue path. An opencode steer follows the same consumed-only-at-a-clean-end rule with different evidence: the turn's clean `end_turn` must come with the steer's own `end_turn` answer within a short grace and with no other loop's output before it, and no approval in the turn may have been denied, because a denial ends opencode's loop before it reads what came after it. A steer whose turn ended first runs a loop of its own; the client cancels it and drains its frames, at the turn's end or before the next prompt, so none reach another turn, and the composer queues the text. An opencode build that refuses a second prompt answers it with an error, which is the same queue path. A codex steer is at-least-once: codex-acp orders the steering answer against the prompt's terminal in no way and sends no consumption echo, so an `injected` answer read after the terminal, or a steer codex acted on before a later cut, leaves the steer queued as well, and it runs a second time as its own turn. goose earns the same membership on its own wire, measured on a live goose 1.54.0: the steer names the run goose announced in `session_info_update._meta.goose.activeRunId`, the answer `{runId, messageId}` means queued for that run, and goose echoes the steer as a `user_message_chunk` marked `_meta.goose.steer` with the same `messageId` once the turn reads it; that echo, matched by id, is the only thing reported consumed. A steer still queued when the run ends is discarded by goose (a cancel after the answer leaves no echo and no model call sees the text), so the composer's pending entry is queued by the teardown, and `steer_needs_loss_recovery` routes every other surface to its queue path, as for codex. No goose steer is sent between turns, before the run id arrives, or while an approval is open. `store_session_config` is the same case for a model list: a host whose models arrive as a `model` select in `configOptions` rather than as a `models` object has them folded in by `session_models_envelope`, gated on `ACP_BACKENDS_ADVERTISED_MODEL_SELECTION` and read by BOTH the session-init capture and the entitlement probe, or its picker is empty on the runtime path while full on the client one and the probe cannot heal a degraded snapshot. **A COMPLETENESS gate backs all of these, because the per-site pins above only catch the sites that exist.** Two halves in `test_harness_parity.py`: every runtime-path per-host answer is asserted equal to its table for every backend in `ACP_BACKENDS_KNOWN` — not only for the hosts the site was written against, so a divergence is caught before a third host is admitted — and every backend-identity comparison in `acp/runtime.py`, `acp/session_handle.py` and the runtime and session_handle owner modules their code moved to must appear in `_DECLARED_IDENTITY_TESTS` with a reason, so a NEW site answering from identity goes red until its author either points it at the table that already answers it or records why identity is honest there. The declaration list is pruned by its own test, so it cannot rot into a blanket pre-approval. One gate run answers for every site and every known host at once, which is what a per-site pin cannot do. Every *tuning channel* follows the same rule, one set per channel because a harness can implement one and not another: `ACP_BACKENDS_MODEL_VIA_CONFIG_OPTION` (model switch), `ACP_BACKENDS_EFFORT_VIA_CONFIG_OPTION` (effort push), `ACP_BACKENDS_MODEL_EFFORT_PAIR_IDS` (whether an advertised `[]` id is applied as two config-option writes -- and, with it, whether a refusal of an advertised id reads as an adapter mismatch rather than an entitlement verdict), and `ACP_BACKENDS_KIRO_SLASH_COMMANDS` — membership in the last decides who is sent `_kiro.dev/commands/execute`, `/effort` included. The two effort channels are disjoint sets (a test pins it): KAS is outside the command set because it refuses the verb with `-32603` (`test/fixtures/acp_frames/kas/commands-refused-live.jsonl`) and takes effort as a config option only. Every effort site reads the channel through `capabilities_for(backend)` (`effort_via_config_option` / `effort_via_slash_command`), never raw set membership. The kiro-cli-or-KAS branch of the effort live path (`clear_effort`, the unpinned re-apply) and the gate in front of the overlay writers in `providers/acp.py` read `ACP_BACKENDS_KIRO_FAMILY` instead, a family set rather than a capability; the workspace `cli.json` **effort** keys have their own narrower reader set, `ACP_BACKENDS_EFFORT_OVERLAY` (kiro-cli only), which every site that writes, reads back or clears them is gated on, because KAS never opens the file and the file is shared by every session in the work dir; the Tool Search keys in that same file have a narrower reader set too, `ACP_BACKENDS_TOOL_SEARCH_OVERLAY` (kiro-cli only), because KAS takes that setting from `initialize`'s `_meta.kiro.settings` instead — `ACP_BACKENDS_CLIENT_META_SETTINGS`, read by the harness's `client_meta_settings` and filled by `AcpRuntime._handshake_client_capabilities`. A harness in none of these must not inherit a channel that answers `-32601`, nor collect an overlay it never reads and the membership-gated clear can never remove, nor have a settings channel it never opened filled for it. Running on the shared `AcpRuntime` does not imply reading that overlay: `AcpProvider.is_acp_runtime_backend` reads `ACP_BACKENDS_ACP_RUNTIME` and answers the TRANSPORT question only, so the overlay sites key off the overlay's own reader sets (`ACP_BACKENDS_EFFORT_OVERLAY`, `ACP_BACKENDS_TOOL_SEARCH_OVERLAY`) rather than off it — codex and KAS run on the runtime, read no `cli.json`, and take effort over `session/set_config_option`. `ACP_BACKENDS_MCP_CONFIG_HOT_RELOAD` follows the same rule for a *skip*: membership is what lets the dashboard's MCP sync leave running sessions alone after a config write, because the harness reconciles the agent file itself (kiro-cli, from the release its reconcile was verified on; `mcp_hot_reload.py` holds every live process to that floor, read from its own `initialize` handshake). A harness that never demonstrated the reconcile would otherwise have its users' freshly installed servers stay unmounted with nothing red to say why. `ACP_BACKENDS_MCP_WIRE_REINJECT` is the OTHER way that same skip is earned, and it is a separate set because it is a separate property: KAS's servers travel inside its projected agent and the engine registers the batch once, so no file watcher applies a change there; Crew re-sends the projection at the session's next turn boundary (`session/close` of the resident session, `session/load` of the same id with the new batch, `session/set_mode` -- measured in `test/fixtures/acp_frames/kas/mcp-live-apply-live.jsonl`; a re-sent `set_mode` and a load of a resident session were both measured ignored). `AcpSessionProvider.mcp_config_wire_reinject` declares it, `mcp_hot_reload.live_sessions_apply_mcp_live` reads both sets, and the mark that makes the skip correct (`mark_mcp_config_changed`) is handed only to members. The added path adapts to the seams the first load already runs through -- `AcpRuntime.load_session` with every guard it carries -- and adds nothing to the kiro-cli path, which keeps its own watcher. `ACP_BACKENDS_POD_HOME_REMAP` is a further instance and the clearest illustration of why one set per capability matters: it decides whose pod-spawned child has its ambient `HOME` relocated so the harness's `$HOME`-derived OAuth grant store stays pod-scoped (`acp.client_spawn_env._apply_pod_home_remap`). Its membership is identical to `ACP_BACKENDS_INTERNAL_SANDBOX` today, and reusing that set would still be wrong — "carries its own OS sandbox" and "relocating `HOME` moves its credential store" are different properties, so a harness added there for sandbox reasons would silently inherit credential relocation it never opted into. A channel's option ID is a per-harness spelling rather than a membership question, so it is resolved through `effort_config_option_id(backend)` (default `effort`, which claude and opencode take; `EFFORT_CONFIG_OPTION_IDS` names the exceptions: `reasoning_effort` for codex and deepseek, `thinking_effort` for goose, `effortLevel` for KAS, `thought_level` for pi) and read at every effort site; a site naming one spelling writes an option the other adapter answers "unknown config option" to, which each caller reads as "no effort selector" and skips, so the failure is silent and the session runs a level the UI does not report. `ACP_BACKENDS_SIDE_READONLY` gates whether a Side Chat turn may execute read-only tools under `READ_ONLY` at all: a harness's own pre-approval surface is one the host gate cannot see, so a member confines its side session where that surface cannot reach a write (kiro-cli through the derived `--readonly` spec read off disk, KAS through the same spec received as a projected custom agent with its `permissions` emptied, claude through a session started with only its read built-ins and pinned to `default` mode, goose through a goose home of its own under the session's scratch window with no `permission.yaml` and no MCP server, and deepseek through Crew's verified gate plugin with stock-tool ownership proved per call), and off the set the side turn runs `REJECT_ALL` (see `side.md`). A KAS member proves the same reads as kiro-cli: its tool_call frame names no tool, so the gate reads the permission frame's engine-written `_meta.kiro.toolId` under its kiro-cli policy name, on KAS alone (`kas_builtin_ids`), and four ids reach the host read-only allowlist (`read_file`, `list_directory`, `grep_search`, `file_search`); an MCP tool that calls itself `read_file` carries a server and is refused (`test_side_readonly_kas.py`). A goose member proves a read from the (extension, tool) pair goose stamps, as written: `(developer, tree)` and `(developer, read_image)` only, so a server named `developer` serving `tree` (stated `developer__tree`) is refused (`test_side_readonly_goose.py`). A deepseek member proves only stock dsh-owned `read`, `grep` and `glob`: the verified gate plugin turns stock tool allows into asks, the cached tool name maps to the read-only policy identity only on a READ_ONLY turn, and unknown, shadowed, MCP, pathless, relative-path and command tools remain refused (`deepseek_read_only_owner_issue`, `test_side_readonly_deepseek.py`). `ACP_BACKENDS_MARKDOWN_AGENT_SPECS` (read through the harness seam `reads_markdown_agent_specs`) says which hosts load the markdown agent form Crew's roster lists for every backend; a non-member is never refused before its spawn -- the existing activation guard is where a markdown-only agent already fails, and membership only decides whether that refusal explains the markdown file or the missing JSON one. The same membership reaches the agent pickers as data, never as a gate: `GET /api/agents/catalog` sends `markdown_spec_backends` (the set, config spelling), `markdown_refused_backends` (the `Routing.AGENT_SPEC` hosts outside that set, which are the hosts whose activation guard refuses; a test pins this to the harnesses' `verifies_agent_activation` and `reads_markdown_agent_specs` answers) beside the asking chat's `session_backend` and a per-row `markdown_only`. A picker badges such a row "KAS only" only on a refusing host, and leaves it selectable.| `test_harness_parity.py::test_session_sharing_is_opt_in`, `::test_steer_is_opt_in`, `::test_model_switch_channel_is_opt_in`, `::test_effort_channel_is_opt_in`, `::test_only_overlay_readers_are_written_to`, `::test_mcp_config_hot_reload_is_opt_in`, `::test_mcp_wire_reinject_is_opt_in`, `test_kas_mcp_live_reinject.py`, `test_acp_pod_home_remap.py::TestTheCapabilitySetIsItsOwnDecision`, `test_acp_codex_harness.py::TestTheEffortChannelIsReadFromItsOwnTable`, `::TestWhatTheHandleAdvertisesForCodex`, `test_acp_kas_effort.py`, `test_codex_steering_request.py`, `test_claude_steering_request.py`, `test_goose_steer_request.py`, `test_opencode_concurrent_prompt_steer.py`, `test_harness_parity.py::test_steer_advertisement_matches_the_steer_table`, `::test_agent_activation_by_mode_matches_the_routing_table`, `::test_the_model_select_fold_matches_the_advertised_selection_table`, `::test_the_session_mcp_array_is_mirror_built_for_exactly_the_mirrored_hosts`, `::test_every_runtime_path_identity_test_is_declared`, `::test_the_identity_test_declarations_are_all_still_live` | `providers/acp.py` (`AcpProvider.is_session_sharing_eligible`), `providers/acp_model_effort.py` (`change_effort`, `clear_effort`, `_apply_initial_effort`), `providers/acp_cli_overlay.py` (`_apply_effort_overlay`, `_apply_tool_search_overlay`), `providers/acp_turns.py` (`stream_command`), `acp/client_class.py` (`AcpClient.supports_steer`, `steer_needs_loss_recovery`), `acp/client_turn_control.py` (`_steer_via_steering_request`, `_steer_via_request`, `_steer_via_concurrent_prompt`, `_settle_concurrent_steers`), `acp/client_spawn_env.py` (`_apply_pod_home_remap`), `acp/session_handle_class.py` (`AcpSessionHandle.supports_steer`, `supports_refusal_steer`), `acp/session_handle_steering.py` (`_steer_via_steering_request`), `acp/session_handle_model_selection.py` (`store_session_config`, `models_from_config_options`), `acp_backends.py`, `mcp_hot_reload.py` (`mcp_hot_reload_supported`, `live_sessions_apply_mcp_live`), `acp/session_provider_mcp_reinject.py` | +| H6 | A capability is granted by opt-in membership, never by negation. `is_session_sharing_eligible` reads `ACP_BACKENDS_SESSION_SHARING` and `supports_steer` reads the steer tables described below, so a harness that has not demonstrated the capability does not inherit it from a set it was never added to. Codex is PRESENT in both the sharing set and `ACP_BACKENDS_SESSION_EVICTION` while KAS is present only in the second, and the two answers have different owners — which is why they are two sets, and why the same harness can answer them differently. Eviction is the harness's own property, earned by measurement: the teardown Crew sends it is the standard `session/close` as a request, after which the sessionId stops answering (it was excluded while that verb was `session/cancel`, which frees nothing; the same `close` sent as a notification is ignored, so the delivery is part of the fact). That one membership is what admits codex to every create-and-destroy path on the shared process at once — the high-churn background handles, warm pooled reuse, the entitlement probe — instead of each path gating codex by name. Sharing is granted, and on a fact the eviction answer does not contain: the thread codex persists under `CODEX_HOME` SURVIVES that evicting close, so a `session/load` on the closed id restores the conversation — measured on the same adapter, including from a restarted adapter process, which is the shape a `spawn_continue` actually takes. Teardown is unchanged by it: a shared subagent session is closed like any other, because one left resident would hold its own MCP fleet on a runtime nobody is using. KAS is the harness that now shows why the two sets cannot be one — its `_kiro/session/delete` frees the session (eviction) by removing the record a continuation would load (no sharing), so the two memberships read the same verb and reach opposite answers. **Both drivers answer from the tables, not one of them:** `AcpSessionHandle.supports_steer` reads them through the runtime's own backend id, because a constant that was true while `AcpRuntime` served a single host becomes a claim about the second host the moment one is added — and an advertised steer is met with `-32601` at the user's mid-turn correction. Steer is three tables because it is three verbs and two questions: kiro-cli's fire-and-forget `_session/steer` (`ACP_BACKENDS_STEER`), an awaited steering request (`ACP_BACKENDS_STEERING_REQUEST`): the `_session/steering` request of codex-acp and claude-agent-acp, whose answer (`injected` / `startedNewTurn` / `failed`, and claude's `promptRequired`) is the delivery evidence, and goose's `_goose/unstable/session/steer`, which only `AcpClient` speaks (`_CLIENT_STEERING_REQUEST_METHODS`); and opencode's second `session/prompt`, which joins the running loop and is answered at the same idle as the turn (`ACP_BACKENDS_STEER_CONCURRENT_PROMPT`, spoken only by `AcpClient`). The handle speaks `_session/steering` for codex; `AcpClient` speaks it for claude, which runs one adapter per session, so the turn's own read loop reads the answer. claude earns membership on its own evidence, driven live on 0.65.0 and 0.89.1 (real adapter, Agent SDK and `claude` binary, model API served locally): the adapter advertises `initialize._meta.steering.supported`, pushes the text into the running Agent SDK turn and holds that turn open until a result answers the steer, so the model's next request carries the steered text and the `session/prompt` terminal follows its reply; a request sent with `idleBehavior: "promptRequired"` to a session whose turn has settled runs nothing and answers `promptRequired`. 0.64.x answers `injected` and then ends the turn without running the text, so `AcpClient` speaks the request only from 0.65.0 (`CLAUDE_ACP_STEERING_MIN_VERSION`). So a claude steer is True only for `injected` into the same still-running turn, is reported consumed only at a clean `end_turn`, and is queued on every other answer or ending -- including a turn that ends before the steer lands. A claude session below 0.65.0, or one whose handshake did not advertise the request, takes no steer and its steer queues; the harness card's `mid_turn_steer` line reads membership only, so a new dashboard chat whose adapter reports a release below its steer floor (claude-agent-acp 0.65.0, or goose 1.38.0, the first release with `_goose/unstable/session/steer`, block/goose#9560) gets one notice row naming the installed version, the minimum and the upgrade command (`steer_version_issue`, read through `AcpProvider.steer_upgrade_notice`). The session still runs; a session that reports no readable version gets no notice, as pi's floor reads it. Crew's claude reject is a `deny` without `interrupt`, which does not end the turn, so a denial drops no claude steer; a cancel still can, which is why claude is in `steer_needs_loss_recovery` with codex. `AcpClient` sends no claude steer while its turn is parked on an approval either: nothing reads the answer until the approval is answered, so the steer is queued. A user's steer reads any of them (`supports_steer` on the handle is the union of the first two, and on the client and the card's `mid_turn_steer` line the union of all three); a deny notice steered into a refused turn reads `ACP_BACKENDS_STEER` alone through `supports_refusal_steer`, and taking back a steer the turn has not read reads `ACP_BACKENDS_STEER_RETRACT` through `supports_steer_retract` (kiro-cli's `_session/steer/clear`; KAS answers the same request but names steers its turn already read, so it is not a member), because codex's approval answer cancels the turn and drops what was injected into it. For the same reason the handle sends no codex steer while a turn is parked on an approval; the steer is queued instead. A steer codex accepted is not safe from a LATER denial or a cancel either: codex drops it with the cut turn. So a codex steer is reported consumed only when its turn ends cleanly, with no denied approval, no cancel and no refusal; on any other ending nothing is reported and the composer's pending entry is queued by the turn's teardown, so the text runs as its own turn instead of being lost. Only the dashboard composer keeps such an entry, so only it steers codex: `steer_needs_loss_recovery` makes `AcpProvider.supports_steer` False and `AcpProvider.steer` refuse, which sends the messaging channels and Side Chat down their queue paths, and `spawn_steer` refuses with `steer_unsupported` in favour of `mode='follow_up'`. The composer's wait for codex's answer is bounded below the browser's send abort, and a slower answer takes the queue path. An opencode steer follows the same consumed-only-at-a-clean-end rule with different evidence: the turn's clean `end_turn` must come with the steer's own `end_turn` answer within a short grace and with no other loop's output before it, and no approval in the turn may have been denied, because a denial ends opencode's loop before it reads what came after it. A steer whose turn ended first runs a loop of its own; the client cancels it and drains its frames, at the turn's end or before the next prompt, so none reach another turn, and the composer queues the text. An opencode build that refuses a second prompt answers it with an error, which is the same queue path. A codex steer is at-least-once: codex-acp orders the steering answer against the prompt's terminal in no way and sends no consumption echo, so an `injected` answer read after the terminal, or a steer codex acted on before a later cut, leaves the steer queued as well, and it runs a second time as its own turn. goose earns the same membership on its own wire, measured on a live goose 1.54.0: the steer names the run goose announced in `session_info_update._meta.goose.activeRunId`, the answer `{runId, messageId}` means queued for that run, and goose echoes the steer as a `user_message_chunk` marked `_meta.goose.steer` with the same `messageId` once the turn reads it; that echo, matched by id, is the only thing reported consumed. A steer still queued when the run ends is discarded by goose (a cancel after the answer leaves no echo and no model call sees the text), so the composer's pending entry is queued by the teardown, and `steer_needs_loss_recovery` routes every other surface to its queue path, as for codex. No goose steer is sent between turns, before the run id arrives, or while an approval is open. `store_session_config` is the same case for a model list: a host whose models arrive as a `model` select in `configOptions` rather than as a `models` object has them folded in by `session_models_envelope`, gated on `ACP_BACKENDS_ADVERTISED_MODEL_SELECTION` and read by BOTH the session-init capture and the entitlement probe, or its picker is empty on the runtime path while full on the client one and the probe cannot heal a degraded snapshot. **A COMPLETENESS gate backs all of these, because the per-site pins above only catch the sites that exist.** Two halves in `test_harness_parity.py`: every runtime-path per-host answer is asserted equal to its table for every backend in `ACP_BACKENDS_KNOWN` — not only for the hosts the site was written against, so a divergence is caught before a third host is admitted — and every backend-identity comparison in `acp/runtime.py`, `acp/session_handle.py` and the runtime and session_handle owner modules their code moved to must appear in `_DECLARED_IDENTITY_TESTS` with a reason, so a NEW site answering from identity goes red until its author either points it at the table that already answers it or records why identity is honest there. The declaration list is pruned by its own test, so it cannot rot into a blanket pre-approval. One gate run answers for every site and every known host at once, which is what a per-site pin cannot do. Every *tuning channel* follows the same rule, one set per channel because a harness can implement one and not another: `ACP_BACKENDS_MODEL_VIA_CONFIG_OPTION` (model switch), `ACP_BACKENDS_EFFORT_VIA_CONFIG_OPTION` (effort push), `ACP_BACKENDS_MODEL_EFFORT_PAIR_IDS` (whether an advertised `[]` id is applied as two config-option writes -- and, with it, whether a refusal of an advertised id reads as an adapter mismatch rather than an entitlement verdict), and `ACP_BACKENDS_KIRO_SLASH_COMMANDS` — membership in the last decides who is sent `_kiro.dev/commands/execute`, `/effort` included. The two effort channels are disjoint sets (a test pins it): KAS is outside the command set because it refuses the verb with `-32603` (`test/fixtures/acp_frames/kas/commands-refused-live.jsonl`) and takes effort as a config option only. Every effort site reads the channel through `capabilities_for(backend)` (`effort_via_config_option` / `effort_via_slash_command`), never raw set membership. The kiro-cli-or-KAS branch of the effort live path (`clear_effort`, the unpinned re-apply) and the gate in front of the overlay writers in `providers/acp.py` read `ACP_BACKENDS_KIRO_FAMILY` instead, a family set rather than a capability; the workspace `cli.json` **effort** keys have their own narrower reader set, `ACP_BACKENDS_EFFORT_OVERLAY` (kiro-cli only), which every site that writes, reads back or clears them is gated on, because KAS never opens the file and the file is shared by every session in the work dir; the Tool Search keys in that same file have a narrower reader set too, `ACP_BACKENDS_TOOL_SEARCH_OVERLAY` (kiro-cli only), because KAS takes that setting from `initialize`'s `_meta.kiro.settings` instead — `ACP_BACKENDS_CLIENT_META_SETTINGS`, read by the harness's `client_meta_settings` and filled by `AcpRuntime._handshake_client_capabilities`. A harness in none of these must not inherit a channel that answers `-32601`, nor collect an overlay it never reads and the membership-gated clear can never remove, nor have a settings channel it never opened filled for it. Running on the shared `AcpRuntime` does not imply reading that overlay: `AcpProvider.is_acp_runtime_backend` reads `ACP_BACKENDS_ACP_RUNTIME` and answers the TRANSPORT question only, so the overlay sites key off the overlay's own reader sets (`ACP_BACKENDS_EFFORT_OVERLAY`, `ACP_BACKENDS_TOOL_SEARCH_OVERLAY`) rather than off it — codex and KAS run on the runtime, read no `cli.json`, and take effort over `session/set_config_option`. `ACP_BACKENDS_MCP_CONFIG_HOT_RELOAD` follows the same rule for a *skip*: membership is what lets the dashboard's MCP sync leave running sessions alone after a config write, because the harness reconciles the agent file itself (kiro-cli, from the release its reconcile was verified on; `mcp_hot_reload.py` holds every live process to that floor, read from its own `initialize` handshake). A harness that never demonstrated the reconcile would otherwise have its users' freshly installed servers stay unmounted with nothing red to say why. `ACP_BACKENDS_MCP_WIRE_REINJECT` is the OTHER way that same skip is earned, and it is a separate set because it is a separate property: KAS's servers travel inside its projected agent and the engine registers the batch once, so no file watcher applies a change there; Crew re-sends the projection at the session's next turn boundary (`session/close` of the resident session, `session/load` of the same id with the new batch, `session/set_mode` -- measured in `test/fixtures/acp_frames/kas/mcp-live-apply-live.jsonl`; a re-sent `set_mode` and a load of a resident session were both measured ignored). `AcpSessionProvider.mcp_config_wire_reinject` declares it, `mcp_hot_reload.live_sessions_apply_mcp_live` reads both sets, and the mark that makes the skip correct (`mark_mcp_config_changed`) is handed only to members. The added path adapts to the seams the first load already runs through -- `AcpRuntime.load_session` with every guard it carries -- and adds nothing to the kiro-cli path, which keeps its own watcher. `ACP_BACKENDS_POD_HOME_REMAP` is a further instance and the clearest illustration of why one set per capability matters: it decides whose pod-spawned child has its ambient `HOME` relocated so the harness's `$HOME`-derived OAuth grant store stays pod-scoped (`acp.client_spawn_env._apply_pod_home_remap`). Its membership is identical to `ACP_BACKENDS_INTERNAL_SANDBOX` today, and reusing that set would still be wrong — "carries its own OS sandbox" and "relocating `HOME` moves its credential store" are different properties, so a harness added there for sandbox reasons would silently inherit credential relocation it never opted into. A channel's option ID is a per-harness spelling rather than a membership question, so it is resolved through `effort_config_option_id(backend)` (default `effort`, which claude and opencode take; `EFFORT_CONFIG_OPTION_IDS` names the exceptions: `reasoning_effort` for codex and deepseek, `thinking_effort` for goose, `effortLevel` for KAS, `thought_level` for pi) and read at every effort site; a site naming one spelling writes an option the other adapter answers "unknown config option" to, which each caller reads as "no effort selector" and skips, so the failure is silent and the session runs a level the UI does not report. `ACP_BACKENDS_SIDE_READONLY` gates whether a Side Chat turn may execute read-only tools under `READ_ONLY` at all: a harness's own pre-approval surface is one the host gate cannot see, so a member confines its side session where that surface cannot reach a write (kiro-cli through the derived `--readonly` spec read off disk, KAS through the same spec received as a projected custom agent with its `permissions` emptied, claude through a session started with only its read built-ins and pinned to `default` mode, goose through a goose home of its own under the session's scratch window with no `permission.yaml` and no MCP server, and deepseek through Crew's verified gate plugin with stock-tool ownership proved per call), and off the set the side turn runs `REJECT_ALL` (see `side.md`). A KAS member proves the same reads as kiro-cli: its tool_call frame names no tool, so the gate reads the permission frame's engine-written `_meta.kiro.toolId` under its kiro-cli policy name, on KAS alone (`kas_builtin_ids`), and four ids reach the host read-only allowlist (`read_file`, `list_directory`, `grep_search`, `file_search`); an MCP tool that calls itself `read_file` carries a server and is refused (`test_side_readonly_kas.py`). A goose member proves a read from the (extension, tool) pair goose stamps, as written: `(developer, tree)` and `(developer, read_image)` only, so a server named `developer` serving `tree` (stated `developer__tree`) is refused (`test_side_readonly_goose.py`). A deepseek member proves only stock dsh-owned `read`, `grep` and `glob`: the verified gate plugin turns stock tool allows into asks, the cached tool name maps to the read-only policy identity only on a READ_ONLY turn, and unknown, shadowed, MCP, pathless, relative-path and command tools remain refused (`deepseek_read_only_owner_issue`, `test_side_readonly_deepseek.py`). `ACP_BACKENDS_MARKDOWN_AGENT_SPECS` (read through the harness seam `reads_markdown_agent_specs`) says which hosts load the markdown agent form Crew's roster lists for every backend; a non-member is never refused before its spawn -- the existing activation guard is where a markdown-only agent already fails, and membership only decides whether that refusal explains the markdown file or the missing JSON one. The same membership reaches the agent pickers as data, never as a gate: `GET /api/agents/catalog` sends `markdown_spec_backends` (the set, config spelling), `markdown_refused_backends` (the `Routing.AGENT_SPEC` hosts outside that set, which are the hosts whose activation guard refuses; a test pins this to the harnesses' `verifies_agent_activation` and `reads_markdown_agent_specs` answers) beside the asking chat's `session_backend` and a per-row `markdown_only`. A picker badges such a row "KAS only" only on a refusing host, and leaves it selectable.| `test_harness_parity.py::test_session_sharing_is_opt_in`, `::test_steer_is_opt_in`, `::test_model_switch_channel_is_opt_in`, `::test_effort_channel_is_opt_in`, `::test_only_overlay_readers_are_written_to`, `::test_mcp_config_hot_reload_is_opt_in`, `::test_mcp_wire_reinject_is_opt_in`, `test_kas_mcp_live_reinject.py`, `test_acp_pod_home_remap.py::TestTheCapabilitySetIsItsOwnDecision`, `test_acp_codex_harness.py::TestTheEffortChannelIsReadFromItsOwnTable`, `::TestWhatTheHandleAdvertisesForCodex`, `test_acp_kas_effort.py`, `test_codex_steering_request.py`, `test_claude_steering_request.py`, `test_goose_steer_request.py`, `test_opencode_concurrent_prompt_steer.py`, `test_harness_parity.py::test_steer_advertisement_matches_the_steer_table`, `::test_agent_activation_by_mode_matches_the_routing_table`, `::test_the_model_select_fold_matches_the_advertised_selection_table`, `::test_the_session_mcp_array_is_mirror_built_for_exactly_the_mirrored_hosts`, `::test_every_runtime_path_identity_test_is_declared`, `::test_the_identity_test_declarations_are_all_still_live` | `providers/acp.py` (`AcpProvider.is_session_sharing_eligible`), `providers/acp_model_effort.py` (`change_effort`, `clear_effort`, `_apply_initial_effort`), `providers/acp_cli_overlay.py` (`_apply_effort_overlay`, `_apply_tool_search_overlay`), `providers/acp_turns.py` (`stream_command`), `acp/client_class.py` (`AcpClient.supports_steer`, `steer_needs_loss_recovery`), `acp/client_turn_control.py` (`_steer_via_steering_request`, `_steer_via_request`, `_steer_via_concurrent_prompt`, `_settle_concurrent_steers`), `acp/client_spawn_env.py` (`_apply_pod_home_remap`), `acp/session_handle_class.py` (`AcpSessionHandle.supports_steer`, `supports_refusal_steer`), `acp/session_handle_steering.py` (`_steer_via_steering_request`), `acp/session_handle_model_selection.py` (`store_session_config`, `models_from_config_options`), `acp_backends.py`, `mcp_hot_reload.py` (`mcp_hot_reload_supported`, `live_sessions_apply_mcp_live`), `acp/session_provider_mcp_reinject.py` | | H7 | `is_kiro_cli` is a positive Kiro test at every call site. It drives internal-sandbox delegation: macOS skips Kiro Crew's seatbelt because Kiro's sandbox cannot nest inside it, and Windows permits the official Kiro backend to run despite having no Kiro Crew OS wrapper. Passed for a harness with no internal sandbox, it hands isolation to a layer that never starts; this is the only Group B row that is also a security invariant. **Windows requires `is_kiro_cli is True` exactly** — `None` and `_spawns_kiro_cli` basename inference can never grant the backend-less-host exception. On macOS a site may grant membership explicitly or pass `None` to defer to the positive basename test. | `test_harness_parity.py::test_is_kiro_cli_is_positive`, `test_sandbox_argv.py::TestKiroInternalSandboxExclusion` | `acp/launch.py` (`launch`, the one tail both drivers take, for every known host), `acp/client_spawn_env.py` (`apply_pod_bundle_spawn`), `sandbox_wrap.py` (`wrap_argv`), `sandbox_delegation.py` (`_spawns_kiro_cli`) | | H8 | New harness identifiers live in `agent_sdk/backends.py` — a LEAF module behind the agent-SDK boundary, so every consumer can name the constants rather than copy them — and are added to `ACP_BACKENDS_KNOWN`; every capability set is defined there or in one of the leaf `backends_.py` owners that `VOCABULARY_PATHS` lists and the module re-exports, and is a subset of it; and `AcpProvider.__init__` rejects anything outside it. `ACP_BACKEND_KIRO` is the empty string, so a value that falls through every identity check spawns `kiro-cli` under a foreign label. `acp/types.py` and the `acp_backends` shim both re-export the vocabulary and remain import sites for existing callers. | `test_harness_parity.py::test_capability_sets_are_subsets_of_known_backends`, `::test_unknown_backend_rejected_at_construction`, `::test_codex_is_selectable_and_answerable` | `agent_sdk/backends.py` (`ACP_BACKENDS_KNOWN`), `providers/acp.py` (`AcpProvider.__init__`), `scripts/check_harness_parity.py` (`VOCABULARY_PATHS`) | diff --git a/docs/system-specs/modules/learn-cron-dashboard.md b/docs/system-specs/modules/learn-cron-dashboard.md index aa7b9347dae..b7d6785a554 100644 --- a/docs/system-specs/modules/learn-cron-dashboard.md +++ b/docs/system-specs/modules/learn-cron-dashboard.md @@ -2891,7 +2891,9 @@ caller re-sends it as a fresh message. The dying turn's teardown entry meta (set on the entry directly when the teardown requeued it before the RPC's own stall surfaced), and the drain runs it with `_run_chat(_steer_possibly_delivered=True)`, which leads only the final model -input with `STEER_POSSIBLY_DELIVERED_NOTE`. The next turn checks before acting +input with `STEER_POSSIBLY_DELIVERED_NOTE`. A partial echo of distinct raw steers +sharing one escaped and redacted key marks the whole collision group possibly delivered. +The next turn checks before acting on it again, while slash dispatch, the mirror legs and the user's row keep the text as typed. Such an entry drains alone (`_dequeue_next_message` never merges it), and the mark is cleared when @@ -2923,6 +2925,57 @@ MERGES two requeued steers keeps one `sendId` and the other send keeps the over-keep default. Pinned by the requeue sendId tests in `test_steer_requeue_entry_meta.py`. +**Taking back a steer the turn has not read (`dashboard/steer_retract.py`).** A +`written` steer row carries `meta.steerRetract: true` when it can be taken back: +the session's own human typed it into the composer (not a peer, a channel, an +automation or a crewmate's responsive turn), and the slot's live client answers +`supports_steer_retract` (`ACP_BACKENDS_STEER_RETRACT`, kiro-cli). The dashboard +draws Unsend, Edit and Queue instead from that mark alone, and the +`steer_push` frame carries it too. A hard Stop discards the pending steers +without settling their rows, and a gateway restart mid-turn leaves them as saved, +so a row can keep the mark after its turn is over: each top-level turn starts by +withdrawing it (`withdraw_stale_offers`) from every `written` row that still +carries it, unless a steer is pending or a take-back is armed. +`POST /api/chat/slots/{slot}/steers/{mid}/retract` +with `{action}` arms the take-back on the slot (`_steer_retract`, plus +`_steer_clear` for the clear in flight and the steers it can drop) and writes +kiro-cli's `_session/steer/clear`, then answers 202. It refuses (409, changing +nothing) a row that is no longer a pending retractable steer, a second take-back +or a steer RPC still in flight (a clear written beside a steer could land on +either side of it), and an app. The clear's answer is `{cleared: true}` whether +or not anything was held, so it decides nothing: an error answer only means the +build has no such request, and the row's mark is withdrawn. What the turn reports +next decides: `AgentExecutionUserMessageCleared` (`EVENT_STEER_DROPPED`) with the +steer still pending means kiro-cli dropped it unread; a consumption echo settling +it first means the turn read it (the row goes `consumed` as for any steer); the +turn ending, including hard-stop cleanup, finding it still pending means it +never reached the turn. `wrap_user_message` escapes tags inside each steer, so the +echo splits one way, and a possibly-delivered steer is never taken back or sent +again. A +taken-back row keeps its place with `steerState: retracted` and +`steerRetractAction`, but its content is cleared (`_mark_retracted`), so nothing +that later reads the transcript as the conversation presents it as something the +person said, and context replay skips it like a `requeued` one. Regenerate and +the cancelled-turn restore step past a taken-back row to the prompt or steer +before it. Edit +leaves the text to the dashboard, which restores it into the composer from the +row as it stood when the take-back was asked and moves focus there; Queue instead appends it to the slot's queue as a new +composer admission, except when the teardown overtook it, where the ordinary +requeue (head of the queue, row `requeued`) is that outcome. On the dashboard a +row that settles as taken back while on screen collapses from its bubble into a +note in place (straight to the note under reduced motion, and for a row read back +from history); the Edit and Queue instead notes ("You moved this message back to +your message box", "You moved this message to the queue") are buttons that move +focus to the composer, and the Unsend note is plain text. The clear drops +EVERY steer kiro-cli holds, so the others pending when it was written are sent +again in their order, and a steer sent while a clear is unanswered waits for that +answer before it is written (`wait_for_clear`), so the clear cannot drop it. The +wait ends at the clear's answer. The steers the drop took besides the taken-back +one are sent again only when kiro-cli reports the drop, so a steer sent between +the two can reach the turn before them. A +resend that fails leaves its steer pending for the teardown's requeue. Pinned by +`test_steer_retract.py` and `test_steer_clear_handle.py`. + ### Wait countdown and early end (`/api/session-keepalive` as a control channel) While the MCP `wait` tool sleeps, the transcript shows a live countdown and an diff --git a/docs/system-specs/modules/side.md b/docs/system-specs/modules/side.md index 201b1c691ee..bc270eb99ac 100644 --- a/docs/system-specs/modules/side.md +++ b/docs/system-specs/modules/side.md @@ -421,7 +421,9 @@ return the id for the frame that announces the row. `settle_consumed_steers(pending, snapshot)` — pure, and shared with the main chat (`chat_runner._settle_consumed_steers` delegates to it). Matches by EQUALITY and is count-aware: containment would false-positive a short steer against a longer -one, and a falsely-settled steer is never requeued, so the question is lost. +one, and a falsely-settled steer is never requeued, so the question is lost. Each +steer is wrapped by `wrap_user_message`, which escapes tags inside the text, so the +echo splits one way and settling compares the escaped text. ### `dashboard/side_prompts.py` diff --git a/scripts/check_harness_parity.py b/scripts/check_harness_parity.py index 0dc0b129dca..960b0e0fcc5 100644 --- a/scripts/check_harness_parity.py +++ b/scripts/check_harness_parity.py @@ -392,6 +392,7 @@ def added_lines(frm: str, path: str) -> set[int]: "ACP_BACKENDS_STEER": None, "ACP_BACKENDS_STEERING_REQUEST": None, "ACP_BACKENDS_STEER_CONCURRENT_PROMPT": None, + "ACP_BACKENDS_STEER_RETRACT": None, "ACP_BACKENDS_COMPACT": None, "ACP_BACKENDS_SIDE_READONLY": None, "ACP_BACKENDS_MEMBER_CAPABILITIES": None, diff --git a/src/kiro_crew/acp/_dispatch.py b/src/kiro_crew/acp/_dispatch.py index fe763aa1de4..15b3d30f731 100644 --- a/src/kiro_crew/acp/_dispatch.py +++ b/src/kiro_crew/acp/_dispatch.py @@ -538,6 +538,7 @@ def classify_notification(msg: JsonRpcMessage) -> str: "steering_cleared", "AgentExecutionUserMessageQueued", "AgentExecutionSteeringInjected", + "AgentExecutionUserMessageCleared", ): return "steer" if msg.is_method(METHOD_SESSION_UPDATE): diff --git a/src/kiro_crew/acp/client_turn_control.py b/src/kiro_crew/acp/client_turn_control.py index 6a0f401caf2..4cb2edd969e 100644 --- a/src/kiro_crew/acp/client_turn_control.py +++ b/src/kiro_crew/acp/client_turn_control.py @@ -72,6 +72,7 @@ class binds it by name, so it reads and writes the session's own state on the in AcpEvent, JsonRpcMessage, steering_outcome, + wrap_user_message, ) from kiro_crew.agent_sdk.backends import ( ACP_BACKEND_CLAUDE, @@ -461,7 +462,7 @@ async def steer(self: AcpClient, message: str) -> bool: return await self._steer_via_steering_request(text) if self.backend in ACP_BACKENDS_STEER_CONCURRENT_PROMPT: return await self._steer_via_concurrent_prompt(text) - wrapped = f"\n{text}\n" + wrapped = wrap_user_message(text) await self._send_request("_session/steer", {"sessionId": self._session_id, "message": wrapped}) # See AcpSessionHandle.steer for why the stamp is taken at the write. self._last_steer_monotonic = time.monotonic() @@ -600,7 +601,7 @@ def _register(request_id: int) -> None: # Injected, but its turn is already over: nothing will report it # consumed, so the caller queues the text. return False - self._steers_accepted.append((aimed_at, f"\n{text}\n")) + self._steers_accepted.append((aimed_at, wrap_user_message(text))) self._last_steer_monotonic = time.monotonic() return True @@ -692,7 +693,7 @@ async def _steer_via_request(self: AcpClient, method: str, text: str) -> bool: if len(held) + len(answers) >= MAX_STEERING_ANSWERS: return False answer: asyncio.Future[str] = asyncio.get_running_loop().create_future() - wrapped = f"\n{text}\n" + wrapped = wrap_user_message(text) req_ids: list[int] = [] def _register(req_id: int) -> None: @@ -768,7 +769,7 @@ async def _steer_via_concurrent_prompt(self: AcpClient, text: str) -> bool: return False if len(text) > _MAX_CONCURRENT_STEER_CHARS or len(turn.steers) >= _MAX_CONCURRENT_STEERS: return False - wrapped = f"\n{text}\n" + wrapped = wrap_user_message(text) sent: list[int] = [] steers = turn.steers diff --git a/src/kiro_crew/acp/pi_steer_turn.py b/src/kiro_crew/acp/pi_steer_turn.py index 00c344d54af..5ac0a535e52 100644 --- a/src/kiro_crew/acp/pi_steer_turn.py +++ b/src/kiro_crew/acp/pi_steer_turn.py @@ -33,6 +33,7 @@ UPDATE_AGENT_MESSAGE_CHUNK, AcpEvent, JsonRpcMessage, + wrap_user_message, ) if TYPE_CHECKING: @@ -115,7 +116,7 @@ async def steer(client: AcpClient, text: str) -> bool: return False if len(text) > MAX_STEERING_TEXT_CHARS: return False - wrapped = f"\n{text}\n" + wrapped = wrap_user_message(text) async with client._pi_steer_lock: pending = client._pi_steers_pending if ( diff --git a/src/kiro_crew/acp/session_handle_class.py b/src/kiro_crew/acp/session_handle_class.py index d2d3a1b95b9..366896699d0 100644 --- a/src/kiro_crew/acp/session_handle_class.py +++ b/src/kiro_crew/acp/session_handle_class.py @@ -46,6 +46,7 @@ from kiro_crew.acp.skill_reads import SkillReadTracker from kiro_crew.acp.types import ( ACP_BACKENDS_STEER, + ACP_BACKENDS_STEER_RETRACT, ACP_BACKENDS_STEERING_REQUEST, AcpEvent, AcpPromptStats, @@ -160,6 +161,7 @@ class AcpSessionHandle: _sync_effort_levels = _sh_models._sync_effort_levels # Steering a live turn: ``kiro_crew.acp.session_handle_steering``. steer = _sh_steering.steer + clear_pending_steers = _sh_steering.clear_pending_steers _steer_via_steering_request = _sh_steering._steer_via_steering_request _settle_abandoned_steering = _sh_steering._settle_abandoned_steering _take_injected_steers = _sh_steering._take_injected_steers @@ -798,6 +800,10 @@ def awaiting_permission(self) -> bool: # mixing the two clocks is how a suspend-resume silently reorders them. _last_steer_monotonic: float = 0.0 + # Set once kiro-cli refuses ``_session/steer/clear`` (a build without it), so + # the session stops offering a pending steer back. See ``clear_pending_steers``. + _steer_clear_refused: bool = False + @property def last_steer_monotonic(self) -> float: """Monotonic time of the last steer written to the backend (0.0 if none).""" @@ -820,6 +826,19 @@ def supports_steer(self) -> bool: backend = self._runtime.acp_backend return backend in ACP_BACKENDS_STEER or backend in ACP_BACKENDS_STEERING_REQUEST + @property + def supports_steer_retract(self) -> bool: + """True when a steer this session has not read yet can be taken back. + + Membership in ``ACP_BACKENDS_STEER_RETRACT``, read from the runtime's own + backend id, and False once the host refused the request + (:meth:`clear_pending_steers`), so a build without it is not offered again. + """ + return ( + self._runtime.acp_backend in ACP_BACKENDS_STEER_RETRACT + and not self._steer_clear_refused + ) + @property def supports_refusal_steer(self) -> bool: """True when a deny notice steered into the refused turn reaches the model. diff --git a/src/kiro_crew/acp/session_handle_dispatch.py b/src/kiro_crew/acp/session_handle_dispatch.py index c00b1884c30..f9e6051228e 100644 --- a/src/kiro_crew/acp/session_handle_dispatch.py +++ b/src/kiro_crew/acp/session_handle_dispatch.py @@ -69,6 +69,7 @@ class binds it by name, so it reads and writes the session's own state on the in EVENT_MCP_SERVER_INITIALIZED, EVENT_STEER_CLEARED, EVENT_STEER_CONSUMED, + EVENT_STEER_DROPPED, EVENT_STEER_QUEUED, EVENT_STRUCTURED_STATUS, EVENT_SUBAGENT_ACTIVITY, @@ -933,6 +934,11 @@ async def _dispatch_events( yield AcpEvent(kind=EVENT_STEER_CONSUMED, text=_text, runtime_global=_ownerless) elif _disc == "steering_cleared": yield AcpEvent(kind=EVENT_STEER_CLEARED, runtime_global=_ownerless) + elif _disc == "AgentExecutionUserMessageCleared": + # The steers a ``_session/steer/clear`` dropped unread. Only + # the fact is carried: kiro-cli's message ids name nothing + # Crew keeps, so the caller decides which steers it held. + yield AcpEvent(kind=EVENT_STEER_DROPPED, runtime_global=_ownerless) elif action == "metadata": self._track_metadata(msg) elif action == "compaction": diff --git a/src/kiro_crew/acp/session_handle_steering.py b/src/kiro_crew/acp/session_handle_steering.py index da89bf3cfb1..887151f895b 100644 --- a/src/kiro_crew/acp/session_handle_steering.py +++ b/src/kiro_crew/acp/session_handle_steering.py @@ -1,7 +1,8 @@ """Steering a live turn. -The steer entry, the codex steering-request protocol, settling abandoned answers, and -the injected, proven and denied steer bookkeeping the dispatch loop reads. +The steer entry, kiro-cli's request to drop the steers a turn has not read, the codex +steering-request protocol, settling abandoned answers, and the injected, proven and +denied steer bookkeeping the dispatch loop reads. Each function here whose first parameter is ``self`` is an ``AcpSessionHandle`` method: the class binds it by name, so it reads and writes the session's own state on the instance, @@ -24,16 +25,20 @@ class binds it by name, so it reads and writes the session's own state on the in from kiro_crew.acp.session_handle_runtime_contract import AcpRuntimeDead from kiro_crew.acp.types import ( + ACP_BACKENDS_STEER_RETRACT, ACP_BACKENDS_STEERING_REQUEST, MAX_STEERING_ANSWERS, MAX_STEERING_TEXT_CHARS, METHOD_CANCEL, + METHOD_SESSION_STEER_CLEAR, METHOD_SESSION_STEERING, + STEER_CLEAR_ANSWER_WAIT_SECS, STEERING_ANSWER_WAIT_SECS, STEERING_INJECTED, STEERING_STARTED_NEW_TURN, STOP_REASON_END_TURN, steering_outcome, + wrap_user_message, ) if TYPE_CHECKING: @@ -71,7 +76,7 @@ async def steer(self: AcpSessionHandle, message: str) -> bool: return False if self._runtime.acp_backend in ACP_BACKENDS_STEERING_REQUEST: return await self._steer_via_steering_request(text) - wrapped = f"\n{text}\n" + wrapped = wrap_user_message(text) await self._runtime.send_request( "_session/steer", {"sessionId": self._session_id, "message": wrapped}, @@ -85,6 +90,50 @@ async def steer(self: AcpSessionHandle, message: str) -> bool: return True +async def clear_pending_steers(self: AcpSessionHandle) -> bool | None: + """Ask kiro-cli to drop every steer it holds that the turn has not read. + + Only a host in ``ACP_BACKENDS_STEER_RETRACT`` has the request; anywhere else, + and with no session, nothing is sent and the answer is False. True means + kiro-cli answered the request, which it does with ``{cleared: true}`` whether or + not anything was pending. It is not evidence of a drop: that is the + ``AgentExecutionUserMessageCleared`` notification which follows the answer and + reaches the turn as ``EVENT_STEER_DROPPED``. False means the request was + refused (a build without it answers ``-32601``), so nothing was dropped and the + session stops offering it. None means no answer came within + ``STEER_CLEAR_ANSWER_WAIT_SECS``, so whether anything was dropped is unknown. + + The answer is read off the session queue (``send_request_for_answer``), so the + turn's own dispatch loop never sees it, and this call never waits on that loop. + A runtime that dies under the write raises, as a steer's write does. + """ + if ( + self._runtime.acp_backend not in ACP_BACKENDS_STEER_RETRACT + or not self._session_id + or self._steer_clear_refused + ): + return False + answer = await self._runtime.send_request_for_answer( + METHOD_SESSION_STEER_CLEAR, {"sessionId": self._session_id} + ) + done, _ = await asyncio.wait({answer}, timeout=STEER_CLEAR_ANSWER_WAIT_SECS) + if not done: + self._runtime.forget_request(answer) + return None + if answer.cancelled(): + return None + error = answer.exception() + if isinstance(error, AcpRuntimeDead): + return None + if error is not None: + # The class only, as for a refused steering request: the text is the + # backend's. + logger.info("steer clear on %s refused: %s", self._session_id, type(error).__name__) + self._steer_clear_refused = True + return False + return True + + async def _steer_via_steering_request(self: AcpSessionHandle, text: str) -> bool: """Deliver a user steer over codex-acp's ``_session/steering`` request. @@ -155,7 +204,7 @@ async def _steer_via_steering_request(self: AcpSessionHandle, text: str) -> bool entry: list[tuple[asyncio.Future[dict[str, Any]], int, str]] = [] def _register(fut: "asyncio.Future[dict[str, Any]]") -> None: - entry.append((fut, aimed_at, f"\n{text}\n")) + entry.append((fut, aimed_at, wrap_user_message(text))) self._steering_answers.append(entry[0]) self._steering_settled[fut] = asyncio.get_running_loop().create_future() diff --git a/src/kiro_crew/acp/session_provider.py b/src/kiro_crew/acp/session_provider.py index 925d7290a6e..2dd1ac25cd0 100644 --- a/src/kiro_crew/acp/session_provider.py +++ b/src/kiro_crew/acp/session_provider.py @@ -725,6 +725,15 @@ def supports_steer(self) -> bool: """True when the backing handle takes a user's mid-turn steer.""" return self._handle.supports_steer + @property + def supports_steer_retract(self) -> bool: + """True when the backing handle can take back a steer its turn has not read.""" + return self._handle.supports_steer_retract is True + + async def clear_pending_steers(self) -> bool | None: + """Forward a steer clear to the session handle (kiro ``_session/steer/clear``).""" + return await self._guarded(self._handle.clear_pending_steers(), own_write_only=True) + @property def steer_needs_loss_recovery(self) -> bool: """True when a delivered steer can still be lost (see the handle).""" diff --git a/src/kiro_crew/acp/types.py b/src/kiro_crew/acp/types.py index 0dcba678ce8..00ac800552d 100644 --- a/src/kiro_crew/acp/types.py +++ b/src/kiro_crew/acp/types.py @@ -69,6 +69,7 @@ ACP_BACKENDS_SPEC_SERVERS_OFF_WIRE, ACP_BACKENDS_STEER, ACP_BACKENDS_STEER_CONCURRENT_PROMPT, + ACP_BACKENDS_STEER_RETRACT, ACP_BACKENDS_STEERING_REQUEST, ACP_BACKENDS_STRUCTURED_REFUSAL, ACP_BACKENDS_TOOL_SEARCH_OVERLAY, @@ -81,6 +82,10 @@ overlay_project_scope, selectable_backends, ) +from kiro_crew.agent_sdk.backends import ( # noqa: F401 - re-exported for importers + escape_user_message_markup, + wrap_user_message, +) # Declared per harness rather than listed as a capability set: whether a # ``kiro-cli logout`` retires a running child is a fact about how that harness signs @@ -137,6 +142,11 @@ EVENT_STEER_QUEUED = "steer_queued" EVENT_STEER_CONSUMED = "steer_consumed" EVENT_STEER_CLEARED = "steer_cleared" +#: kiro-cli dropped every steer it was still holding, unread, because +#: ``_session/steer/clear`` asked it to (``AgentExecutionUserMessageCleared``). +#: Not KAS's ``steering_cleared``, which arrives after a turn ends and names steers +#: the turn already read (``EVENT_STEER_CLEARED``). +EVENT_STEER_DROPPED = "steer_dropped" #: A typed :class:`StructuredStatus` from the EXECUTION LAYER (the versioned #: ``kirocrew/status`` extension on a routed ``session/update`` frame, or the #: watchdog's own post-stall classification). Never derived from model text — @@ -177,6 +187,12 @@ # (``SEND_ABORT_MS``), so an answer slower than this sends the steer down the # caller's queue path instead of leaving the request to time out. STEERING_ANSWER_WAIT_SECS = 8.0 +# kiro-cli's request to drop every steer it holds and has not yet handed to the +# turn (``ACP_BACKENDS_STEER_RETRACT``), and how long its answer is waited for. +# The answer comes from kiro-cli's request handler, not from the turn, so a +# healthy session answers in milliseconds. +METHOD_SESSION_STEER_CLEAR = "_session/steer/clear" +STEER_CLEAR_ANSWER_WAIT_SECS = 5.0 def steering_outcome(result: object) -> str: diff --git a/src/kiro_crew/acp_backends.py b/src/kiro_crew/acp_backends.py index 0ab9cba4d2d..da31c547bee 100644 --- a/src/kiro_crew/acp_backends.py +++ b/src/kiro_crew/acp_backends.py @@ -92,6 +92,7 @@ ACP_BACKENDS_SPEC_SERVERS_OFF_WIRE, ACP_BACKENDS_STEER, ACP_BACKENDS_STEER_CONCURRENT_PROMPT, + ACP_BACKENDS_STEER_RETRACT, ACP_BACKENDS_STEERING_REQUEST, ACP_BACKENDS_STRUCTURED_REFUSAL, ACP_BACKENDS_TOOL_SEARCH_OVERLAY, @@ -189,6 +190,7 @@ "ACP_BACKENDS_STEER", "ACP_BACKENDS_STEERING_REQUEST", "ACP_BACKENDS_STEER_CONCURRENT_PROMPT", + "ACP_BACKENDS_STEER_RETRACT", "ACP_BACKENDS_STRUCTURED_REFUSAL", "ACP_BACKENDS_TOOL_SEARCH_OVERLAY", "ACP_BACKENDS_UNATTRIBUTED_TERMINAL_ERROR", diff --git a/src/kiro_crew/agent_sdk/backend_cards.py b/src/kiro_crew/agent_sdk/backend_cards.py index 302f3252629..e90d44e8988 100644 --- a/src/kiro_crew/agent_sdk/backend_cards.py +++ b/src/kiro_crew/agent_sdk/backend_cards.py @@ -372,6 +372,14 @@ class _LineSpec: "wire spec. The worker gets the same prompt either way, so there is nothing " "for a user choosing a harness to weigh" ), + "ACP_BACKENDS_STEER_RETRACT": ( + "whether a pending steer can be taken back (the dashboard's Unsend, Edit and " + "Queue instead). The steer itself is the mid-turn-steer line's claim, and these " + "controls are drawn only on a steer the running harness can take back, so no " + "reader is promised an undo the harness lacks. A wrong membership offers a " + "control whose clear names nothing, which leaves the steer to run, and is a " + "defect" + ), "ACP_BACKENDS_SERIAL_SESSION_STARTS": ( "which backend's process answers session starts and mode switches one at a " "time. It decides only what a timed-out start's error says about the " diff --git a/src/kiro_crew/agent_sdk/backends.py b/src/kiro_crew/agent_sdk/backends.py index 8e68c09d65f..e63ec14e220 100644 --- a/src/kiro_crew/agent_sdk/backends.py +++ b/src/kiro_crew/agent_sdk/backends.py @@ -114,6 +114,9 @@ * - ``ACP_BACKENDS_STEER_CONCURRENT_PROMPT`` - pre-session registry query (whether a user steer travels as a second ``session/prompt`` that joins the running turn) + * - ``ACP_BACKENDS_STEER_RETRACT`` + - driver-internal (whether a pending steer can be taken back with + ``_session/steer/clear``) * - ``ACP_BACKENDS_COMPACT`` - pre-session registry query (whether manual ``/compact`` is offered at all) * - ``ACP_BACKENDS_INLINE_COMPACTION`` @@ -912,8 +915,11 @@ def resolve_selected_backend(value: object) -> str: ACP_BACKENDS_REPORT_USAGE, ACP_BACKENDS_STEER, ACP_BACKENDS_STEER_CONCURRENT_PROMPT, + ACP_BACKENDS_STEER_RETRACT, ACP_BACKENDS_STEERING_REQUEST, ACP_BACKENDS_STRUCTURED_REFUSAL, ACP_BACKENDS_UNATTRIBUTED_TERMINAL_ERROR, ACP_BACKENDS_UNMARKED_COMPACTION_PAIR, + escape_user_message_markup, + wrap_user_message, ) diff --git a/src/kiro_crew/agent_sdk/backends_turns.py b/src/kiro_crew/agent_sdk/backends_turns.py index 35ff6493272..3542218809d 100644 --- a/src/kiro_crew/agent_sdk/backends_turns.py +++ b/src/kiro_crew/agent_sdk/backends_turns.py @@ -27,6 +27,19 @@ ACP_BACKEND_PI, ) + +def escape_user_message_markup(text: str) -> str: + """Escape delimiters so they cannot occur inside the text they delimit.""" + return text.replace("", "</user_message>").replace( + "", "<user_message>" + ) + + +def wrap_user_message(text: str) -> str: + """Wrap escaped text so its echo splits one way and cannot close the model's wrapper.""" + return f"\n{escape_user_message_markup(text)}\n" + + # Backends implementing the ``_session/steer`` extension (mid-turn steer). # claude-agent-acp does not implement it, so a steer sent there is answered with # method-not-found rather than reaching the turn. It takes a user's steer over @@ -219,6 +232,33 @@ # denial already ended the turn it was meant for. ACP_BACKENDS_STEER_CONCURRENT_PROMPT = frozenset({ACP_BACKEND_OPENCODE}) +# Backends whose ``_session/steer/clear`` drops the steers the running turn has not +# read yet and names exactly those, so a pending steer can be taken back. Read by +# ``supports_steer_retract``; the dashboard's Unsend, Edit and Queue instead on a +# pending steer exist only on a member. +# +# kiro-cli is a member on a LIVE capture against kiro-cli 2.33.1: +# ``test/fixtures/acp_frames/kiro/steering-clear-live.jsonl``. Two steers sent while +# a shell call ran were each answered ``{queued: true}`` and echoed as +# ``AgentExecutionUserMessageQueued``. ``_session/steer/clear`` was answered +# ``{cleared: true}`` and followed by ``AgentExecutionUserMessageCleared`` naming +# both, and the turn's answer obeyed neither. A steer sent after the clear was +# injected and obeyed. The clear drops EVERY pending steer, so the caller sends +# again the ones it did not mean to take back. +# ``test/fixtures/acp_frames/kiro/steering-clear-after-inject-live.jsonl`` is the +# other order: a steer already reported by ``AgentExecutionSteeringInjected`` is not +# named by a later clear, which names only the steer still pending, and a clear +# with nothing pending sends no ``AgentExecutionUserMessageCleared`` at all. The +# answer is the same ``{cleared: true}`` either way, so only that notification is +# evidence of a drop. +# +# KAS is not a member, measured against KAS through kiro-cli 2.33.1 +# (``--agent-engine v3``): its clear answered ``{cleared: true, messageIds: [...]}`` +# naming two steers ``steering_injected`` had already reported, and the turn obeyed +# both. Its answer and its ``steering_cleared`` name steers the turn already read, +# so neither can tell a dropped steer from a delivered one. +ACP_BACKENDS_STEER_RETRACT = frozenset({ACP_BACKEND_KIRO}) + # Backends that can serve a MANUAL ``/compact`` (the user-typed slash command). # Every member acts on the ``/compact`` prompt that ``AcpProvider.compact()`` # sends. They do not all answer it the same way, and that split is diff --git a/src/kiro_crew/context.py b/src/kiro_crew/context.py index ece2a39b68e..489e362f36e 100644 --- a/src/kiro_crew/context.py +++ b/src/kiro_crew/context.py @@ -1142,10 +1142,13 @@ def build_cancelled_turn_preamble( is observed on the session. """ try: - recent = conversation_log.recent(session_key, max_messages=20) + # Whole rows: ``recent()`` keeps only role and content, and the walk reads + # ``meta`` to step past a steer the person took back. Both callers run this + # in a worker thread, since it reads the whole transcript. + rows = _without_warm_task_brief_messages(conversation_log.read_messages(session_key)) except Exception: return "" - return _replay.cancelled_turn_preamble(recent, user_cap=user_cap, assist_cap=assist_cap) + return _replay.cancelled_turn_preamble(rows[-20:], user_cap=user_cap, assist_cap=assist_cap) # ── Provider-Agnostic Session Replay ── diff --git a/src/kiro_crew/context_assembly/replay.py b/src/kiro_crew/context_assembly/replay.py index 988fe0ed64e..0a1a1c23f08 100644 --- a/src/kiro_crew/context_assembly/replay.py +++ b/src/kiro_crew/context_assembly/replay.py @@ -90,6 +90,11 @@ def _replace_json(m: re.Match[str]) -> str: # dashboard's chat delivery). Spelled here because this layer does not import the # dashboard; a test pins the two together. _STEER_STATE_REQUEUED = "requeued" +# The ``meta.steerState`` of a steer the person took back before the turn read it +# (``STEER_STATE_RETRACTED``, same reason for the spelling): it never reached the +# turn either. +_STEER_STATE_RETRACTED = "retracted" +_STEER_STATES_NOT_TAKEN = frozenset({_STEER_STATE_REQUEUED, _STEER_STATE_RETRACTED}) def _is_steer_row(row: dict) -> bool: @@ -149,7 +154,8 @@ def build_interrupted_turn_preamble( A mid-turn steer (:func:`_is_steer_row`) is walked past rather than taken as the opener, and is carried in the partial output, in the order it was sent, under ``[Steered mid-turn]`` -- unless its ``steerState`` is ``requeued``, - which means the turn never took it and it runs as its own turn. + which means the turn never took it and it runs as its own turn, or + ``retracted``, which means the person took it back before the turn read it. Returns "" when no opener is found. The caller mints the result AFTER the prompt's egress scrub (its markers are in ``_STRUCTURAL_MARKER_RES``); the payload is scrubbed here instead. @@ -205,10 +211,11 @@ def build_interrupted_turn_preamble( # The cap fell on the separator itself: the kept text ends with the # previous chunk, so the marker closes that chunk. assistant_parts[last_assist] += "… [truncated]" - elif _is_steer_row(m) and m["meta"].get("steerState") != _STEER_STATE_REQUEUED: + elif _is_steer_row(m) and m["meta"].get("steerState") not in _STEER_STATES_NOT_TAKEN: # A requeued steer did not redirect this turn: it waits in the queue as # its own turn, or the user dismissed it. Carrying it would have the - # model act on it here and again when it drains. + # model act on it here and again when it drains. A taken-back steer + # never reached the turn at all. if len(text) > user_cap: text = text[:user_cap] + "… [truncated]" assistant_parts.append(f"{_STEER_LABEL} {text}") @@ -346,6 +353,8 @@ def _merge_replay_rows(disk: list[dict], pending: list[dict], current: dict | No def _quota_tail(messages: list[dict], *, conv_max: int, inject_max: int) -> list[dict]: """The newest rows under separate conversation and ``inject`` row quotas, oldest first. + A steer the person took back (``steerState: retracted``) is left out. + A role-filtered tail slice lets a run of ``inject`` rows longer than the bound be the entire read, so conversation disappears. Counting the two quotas separately lets notes reach the model without competing with user/assistant turns for the @@ -364,6 +373,10 @@ def _quota_tail(messages: list[dict], *, conv_max: int, inject_max: int) -> list conv = inj = 0 for m in reversed(messages): role = m["role"] + if _is_steer_row(m) and m["meta"].get("steerState") == _STEER_STATE_RETRACTED: + # Taken back before the turn read it: it was never part of the + # conversation, and its text was cleared when it was taken back. + continue if role == "inject": if inj >= inject_max: continue @@ -509,7 +522,9 @@ def cancelled_turn_preamble(recent: list[dict], *, user_cap: int, assist_cap: in """The ``[PREVIOUS TURN WAS CANCELLED …]`` restore for *recent* rows, or ``""``. Scans backwards for a ``stop_event`` marker, then takes the user message - immediately before it plus any assistant text in between. + immediately before it plus any assistant text in between. A steer the person + took back (``steerState: retracted``) keeps its row with its text cleared and + is stepped past, so *recent* must carry each row's ``meta``. """ if not recent: return "" @@ -540,9 +555,13 @@ def cancelled_turn_preamble(recent: list[dict], *, user_cap: int, assist_cap: in search_end = stop_idx if stop_idx >= 0 else len(recent) user_idx = -1 for i in range(search_end - 1, -1, -1): - if recent[i].get("role") == "user": - user_idx = i - break + row = recent[i] + if row.get("role") != "user": + continue + if _is_steer_row(row) and row["meta"].get("steerState") == _STEER_STATE_RETRACTED: + continue + user_idx = i + break if user_idx < 0: return "" # Collect any assistant text between user_idx and the boundary. diff --git a/src/kiro_crew/dashboard/chat.py b/src/kiro_crew/dashboard/chat.py index ffd2ed03316..e15829968fa 100644 --- a/src/kiro_crew/dashboard/chat.py +++ b/src/kiro_crew/dashboard/chat.py @@ -79,6 +79,7 @@ api_chat_slot_selection_capabilities, api_chat_slot_source_link_unlink, api_chat_slot_source_links, + api_chat_slot_steer_retract, api_chat_slot_stop, api_chat_slot_summary, api_chat_slot_summary_generate, diff --git a/src/kiro_crew/dashboard/chat_api/prompt_queue.py b/src/kiro_crew/dashboard/chat_api/prompt_queue.py index a6f7317e1f8..2bca0074f16 100644 --- a/src/kiro_crew/dashboard/chat_api/prompt_queue.py +++ b/src/kiro_crew/dashboard/chat_api/prompt_queue.py @@ -1,4 +1,5 @@ -"""The queued-prompt routes: cancel, edit and reorder.""" +"""The waiting-prompt routes: cancel, edit and reorder a queued message, and take +back a steer the running turn has not read yet.""" from __future__ import annotations @@ -228,3 +229,65 @@ async def api_chat_slot_queue_reorder(request: web.Request) -> web.Response: metadata={"slot": name, "order_len": len(order)}, ) return web.json_response({"ok": True}) + + +async def api_chat_slot_steer_retract(request: web.Request) -> web.Response: + """POST /api/chat/slots/{slot}/steers/{mid}/retract — take back a pending steer. + + Body ``{"action": "unsend" | "edit" | "queue"}``. Answers 202 once the take-back + is armed. Whether the turn read the steer first is decided afterwards and lands + on the steer's own row (``steer_retract``), which is what the dashboard reads. + 409 when the steer is not pending, another steer or take-back is in + flight, or the harness cannot drop a steer. Refused to an app: a take-back + is offered only on the session's own human's steer. + """ + state: DashboardState = request.app["state"] + name = request.match_info["slot"] + mid = request.match_info["mid"] + slot = state._slots.get(name) + if not slot: + return slot_not_found() + denied = deny_app_slot_access(request.get("app", ""), slot, name, "slot_steer_retract") + if denied is not None: + return denied + request_app = request.get("app", "") + if request_app: + reason = "only the person who sent a steer can take it back" + sel().log_api_access( + caller=request_app, + operation="slot_steer_retract", + outcome="denied", + source="app_isolation", + resources=f"slot={name}", + error=reason, + ) + return web.json_response({"error": reason, "code": "steer_retract_forbidden"}, status=403) + body, body_err = await read_bounded_json(request) + if body_err is not None: + return body_err + assert body is not None # read_bounded_json returns (dict, None) on success + from kiro_crew.dashboard import steer_retract + + action = body.get("action") + if not isinstance(action, str) or action not in steer_retract.RETRACT_ACTIONS: + return web.json_response( + { + "error": "action must be one of: edit, queue, unsend", + "code": "steer_retract_bad_action", + }, + status=400, + ) + outcome = await steer_retract.begin(state, slot, mid, action) + refusal = steer_retract.REFUSALS.get(outcome) + if refusal is not None: + return web.json_response({"error": refusal[0], "code": refusal[1]}, status=409) + sel().log_tool_invocation( + session_key=f"dashboard:{name}", + agent="kirocrew", + source="dashboard", + tool_name="steer_retract", + tool_kind="permission", + outcome="allowed", + metadata={"slot": name, "action": action}, + ) + return web.json_response({"ok": True, "pending": True}, status=202) diff --git a/src/kiro_crew/dashboard/chat_api/turn_stop.py b/src/kiro_crew/dashboard/chat_api/turn_stop.py index 8db6495f558..9c4d2b079b9 100644 --- a/src/kiro_crew/dashboard/chat_api/turn_stop.py +++ b/src/kiro_crew/dashboard/chat_api/turn_stop.py @@ -536,6 +536,11 @@ async def stop_slot_turn( # when it was pressed, and a later send stays queued for the next turn. discarded = list(slot._queue) slot._queue.clear() + # A drop reported while the settle below suspends must find no take-back, + # or Queue instead would queue text this kill already discarded. + from kiro_crew.dashboard import steer_retract + + steer_retract.on_hard_stop(slot) # A cross-session delivery among the taken entries has a sender holding a # "queued, will run later" receipt; nothing else here reaches that sender. # Told before the settle can suspend, so a cancellation of this stop diff --git a/src/kiro_crew/dashboard/chat_delivery.py b/src/kiro_crew/dashboard/chat_delivery.py index 2873d635399..bbddf9ff1fc 100644 --- a/src/kiro_crew/dashboard/chat_delivery.py +++ b/src/kiro_crew/dashboard/chat_delivery.py @@ -70,6 +70,15 @@ STEER_STATE_WRITTEN = "written" STEER_STATE_CONSUMED = "consumed" STEER_STATE_REQUEUED = "requeued" +# The fourth state, reached only from `written`: the person took the steer back +# before the turn read it (`dashboard/steer_retract.py`). The row stays, so the +# transcript keeps its place, and says what became of the text. +STEER_STATE_RETRACTED = "retracted" +# Row meta: this `written` steer can be taken back. Stamped only where the take-back +# works -- the session's own human typed it into the composer, and the running +# harness can drop a steer its turn has not read -- so the dashboard draws the +# controls from the row alone. +STEER_RETRACTABLE_META = "steerRetract" # Upper bound on a client-minted ``meta.sendId`` accepted into the steer path. # Client mints are ~17 chars; the bound exists because the value is raw client @@ -243,6 +252,22 @@ def _queued_entry_id(slot: Any, delivery_id: str) -> str: return "" +def is_retracted_steer_row(row: dict[str, Any]) -> bool: + """Whether *row* is a steer the person took back before the turn read it. + + The row keeps its place in the transcript with its text cleared, so a walk + that takes the nearest ``user`` row as the request has to step past it: the + turn never read it, and there is nothing in it to run again. + """ + meta = row.get("meta") + return ( + row.get("role") == "user" + and isinstance(meta, dict) + and meta.get("steer") is True + and meta.get("steerState") == STEER_STATE_RETRACTED + ) + + def find_written_steer_row( slot: Any, message: str, siblings: list[str] | None = None ) -> dict[str, Any] | None: @@ -338,6 +363,16 @@ def _engine_has_no_prompt(client: Any) -> bool: return False +def _steer_target(slot: Any) -> tuple[object, bool, int]: + """The turn a steer is aimed at: its task, whether it has finished, and the stop count.""" + task = getattr(slot, "task", None) + return ( + task, + bool(task is not None and task.done()), + int(getattr(slot, "_stop_generation", 0) or 0), + ) + + async def steer_into_running_turn( state: "DashboardState", slot: "_ChatSlot", @@ -446,6 +481,18 @@ async def steer_into_running_turn( send_id = normalize_send_id(send_id) quote = quote_meta(attachments) attachments = attachment_meta(attachments) + # A take-back's `_session/steer/clear` drops every steer kiro-cli holds when it + # runs, so this steer is not written until that clear has been answered. + # circular import: steer_retract imports this module at module level. + from kiro_crew.dashboard import steer_retract + + # The clear's answer can come from the turn ending or from a Stop, and either + # one's teardown finishes before this resumes. A steer written then reaches no + # turn, so it goes back to the caller's queue path instead. + aimed_turn = _steer_target(slot) + await steer_retract.wait_for_clear(slot) + if _steer_target(slot) != aimed_turn: + return STEER_UNAVAILABLE client = getattr(slot, "_acp_client", None) if client is None or not getattr(client, "supports_steer", False): return STEER_UNAVAILABLE @@ -715,6 +762,7 @@ def _record_steer_requeued(queue_id: str) -> None: # above means this is the only matching entry, which is why this is a # plain remove and not an index dance over possible duplicates. slot._pending_steers.remove(message) + slot._steer_possibly_delivered.discard(message) responsive_chat.forget_steer(slot, message) slot._steer_delivery_ids.pop(message, None) slot._steer_send_ids.pop(message, None) @@ -928,6 +976,19 @@ def _record_steer_requeued(queue_id: str) -> None: meta.update(attachments) if quote: meta.update(quote) + # Offered only on a steer still waiting for the turn, typed by the session's + # own human into the composer, on a harness that can drop it unread. A peer's, + # a channel's or an automation's steer is not that person's to take back. + _retractable = ( + _state == STEER_STATE_WRITTEN + and user_origin + and not channel_origin + and nudge_meta is None + and not responsive + and getattr(client, "supports_steer_retract", False) is True + ) + if _retractable: + meta[STEER_RETRACTABLE_META] = True # The row survives a page reload via the dirty-flush cycle. The session's own # human's steer is stored as typed, like an ordinary send's row; a peer's is # stored sanitized, because its text has no human author to be its reader. A @@ -969,6 +1030,8 @@ def _record_steer_requeued(queue_id: str) -> None: # requeued; a row already persisted as consumed is terminal. "steerState": _state, } + if _retractable: + push_payload[STEER_RETRACTABLE_META] = True # The row's own id, so the client stores it and the later state patch -- which # is keyed on `mid` -- can find this row. Without it the client row has no # `mid`, the mid-keyed patch matches nothing, and the promotion is a silent @@ -1031,6 +1094,7 @@ def queue_for_next_turn( quote: dict[str, Any] | None = None, commands_off: bool = False, steer_intent: bool = False, + admission: dict[str, Any] | None = None, ) -> str: """Append *message* to the slot's queue and announce it; return the queue id. @@ -1042,6 +1106,10 @@ def queue_for_next_turn( hold releases it at the turn's end rather than parking it behind the wave. Only the composer's steer fallback passes it. + *admission* supplies containment captured before an asynchronous queue path + began. When absent, containment is captured here at enqueue like every direct + caller. + *commands_off* stamps :data:`COMMANDS_OFF_META_KEY` on the entry: the drained turn hands the text to the model as content and never runs its first word as a dashboard command. Passed by the channel hand-off, whose text is a channel @@ -1096,7 +1164,9 @@ def queue_for_next_turn( # circular import: session_control imports this module at module level. from kiro_crew.dashboard.session_control import CHANNEL_RECIPIENT_META_KEY, containment_meta - meta: dict[str, Any] = dict(containment_meta(state, slot)) + meta: dict[str, Any] = ( + dict(admission) if admission is not None else dict(containment_meta(state, slot)) + ) if channel_recipient: meta[CHANNEL_RECIPIENT_META_KEY] = dict(channel_recipient) if turn_actor: diff --git a/src/kiro_crew/dashboard/chat_handlers.py b/src/kiro_crew/dashboard/chat_handlers.py index caa5226c6ba..bf4ee796b5b 100644 --- a/src/kiro_crew/dashboard/chat_handlers.py +++ b/src/kiro_crew/dashboard/chat_handlers.py @@ -165,6 +165,7 @@ api_chat_slot_queue_cancel, api_chat_slot_queue_edit, api_chat_slot_queue_reorder, + api_chat_slot_steer_retract, ) from kiro_crew.dashboard.chat_api.resume import ( # noqa: F401 _hydrate_slot_from_history, diff --git a/src/kiro_crew/dashboard/chat_regenerate.py b/src/kiro_crew/dashboard/chat_regenerate.py index c13d002d612..da7ed1388ce 100644 --- a/src/kiro_crew/dashboard/chat_regenerate.py +++ b/src/kiro_crew/dashboard/chat_regenerate.py @@ -9,7 +9,7 @@ from aiohttp import web from kiro_crew.dashboard.body_numbers import body_int -from kiro_crew.dashboard.chat_delivery import queued_text_for_display +from kiro_crew.dashboard.chat_delivery import is_retracted_steer_row, queued_text_for_display from kiro_crew.dashboard.chat_persistence import save_slot_off_loop from kiro_crew.dashboard.chat_runner import _run_chat, _start_next_queued_turn from kiro_crew.dashboard.chat_utils import ( @@ -114,6 +114,10 @@ async def api_chat_slot_regenerate(request: web.Request) -> web.Response: ai_idx = -1 for i in range(len(msgs) - 1, -1, -1): role = msgs[i].get("role") + # A taken-back steer keeps its row, text cleared, inside the turn it + # was sent into. The turn never read it, so it is not a user turn. + if is_retracted_steer_row(msgs[i]): + continue # Never cross a real user turn: the truncation below deletes # everything after the target reply's user row, so a reply found # PAST a newer user row (e.g. a /compact row awaiting only its @@ -137,7 +141,7 @@ async def api_chat_slot_regenerate(request: web.Request) -> web.Response: ) u_idx = -1 for i in range(ai_idx - 1, -1, -1): - if msgs[i].get("role") == "user": + if msgs[i].get("role") == "user" and not is_retracted_steer_row(msgs[i]): u_idx = i break if u_idx < 0: diff --git a/src/kiro_crew/dashboard/chat_runner.py b/src/kiro_crew/dashboard/chat_runner.py index b2cd48c2d89..ebc49da075d 100644 --- a/src/kiro_crew/dashboard/chat_runner.py +++ b/src/kiro_crew/dashboard/chat_runner.py @@ -56,6 +56,7 @@ EVENT_MCP_SERVER_INIT_FAILURE, EVENT_MCP_SERVER_INITIALIZED, EVENT_STEER_CONSUMED, + EVENT_STEER_DROPPED, STOP_CLASS_FAILED, STOP_REASON_CANCELLED, STOP_REASON_COMPACTION_FAILED, @@ -213,6 +214,7 @@ from kiro_crew.dashboard.chat_turn.steer_queue import ( # noqa: F401 _has_user_queued_followup, _on_steer_consumed, + _on_steer_dropped, _requeue_unconsumed_steers, ) from kiro_crew.dashboard.chat_turn.stop_recovery import ( @@ -325,7 +327,10 @@ build_stale_recovery_prompt, context_entry_expired, ) -from kiro_crew.dashboard.steer_settle import settle_consumed_steers +from kiro_crew.dashboard.steer_settle import ( + possibly_consumed_steers, + settle_consumed_steers, +) from kiro_crew.dashboard.turn_dispatch import ( spawn_guarded_turn, tool_approval_timeout_secs, @@ -992,6 +997,8 @@ async def _run_chat( continue elif event.kind == EVENT_STEER_CONSUMED: await _on_steer_consumed(turn, event) + elif event.kind == EVENT_STEER_DROPPED: + await _on_steer_dropped(turn, event) elif event.kind == EVENT_COMPACTION_STATUS: await _on_compaction_status(turn, event) elif event.kind == EVENT_CLEAR_STATUS: diff --git a/src/kiro_crew/dashboard/chat_turn/prompt_assembly.py b/src/kiro_crew/dashboard/chat_turn/prompt_assembly.py index 0dde1a4d9b8..7f237bc4677 100644 --- a/src/kiro_crew/dashboard/chat_turn/prompt_assembly.py +++ b/src/kiro_crew/dashboard/chat_turn/prompt_assembly.py @@ -643,8 +643,10 @@ async def _turn_build_prompt(turn: _ChatTurn) -> None: build_cancelled_turn_preamble, # circular: context -> dashboard.chat -> chat_runner (can't top-level: context imports chat at module load); circular: context -> chat -> chat_runner; circular: context -> chat ) - preamble = build_cancelled_turn_preamble( - state.context_builder.conversation_log, session_key + preamble = await asyncio.to_thread( + build_cancelled_turn_preamble, + state.context_builder.conversation_log, + session_key, ) if preamble: message = preamble + "\n\n" + message diff --git a/src/kiro_crew/dashboard/chat_turn/steer_queue.py b/src/kiro_crew/dashboard/chat_turn/steer_queue.py index 534384c6f0b..9ab7af24497 100644 --- a/src/kiro_crew/dashboard/chat_turn/steer_queue.py +++ b/src/kiro_crew/dashboard/chat_turn/steer_queue.py @@ -35,6 +35,7 @@ _ChatSlot, crew_log_emit, logger, + possibly_consumed_steers, settle_consumed_steers, ) from kiro_crew.dashboard.chat_turn.turn_locals import _ChatTurn @@ -126,6 +127,11 @@ def _settle_consumed_steers( previous = list(slot._pending_steers) from kiro_crew.dashboard import responsive_chat + possibly_delivered = responsive_chat.settle_steers( + slot, previous, snapshot, possibly_consumed_steers + ) + newly_possibly_delivered = set(possibly_delivered) - slot._steer_possibly_delivered + slot._steer_possibly_delivered.update(possibly_delivered) remaining = responsive_chat.settle_steers(slot, previous, snapshot, settle_consumed_steers) settled_count = len(previous) - len(remaining) logger.debug( @@ -214,6 +220,10 @@ def _settle_consumed_steers( slot._steer_automation.pop(settled_msg, None) slot._steer_decision_strips.pop(settled_msg, None) slot._steer_possibly_delivered.discard(settled_msg) + if settled_count or newly_possibly_delivered: + from kiro_crew.dashboard import steer_retract + + steer_retract.on_settled(slot) def _requeue_unconsumed_steers(state: "DashboardState", slot: "_ChatSlot") -> None: @@ -231,7 +241,14 @@ def _requeue_unconsumed_steers(state: "DashboardState", slot: "_ChatSlot") -> No "discard" dismisses it with one click; nothing is ever silently lost. A hard kill never reaches here with pending steers (the force-stop handler clears ``_pending_steers`` alongside ``_queue``). + + A steer the person is taking back is settled first + (``steer_retract.on_turn_ending``): an Unsend or an Edit never reached the + turn, so it is not requeued, and a Queue instead is left to this requeue. """ + from kiro_crew.dashboard import steer_retract + + steer_retract.on_turn_ending(state, slot) if not slot._pending_steers: return from kiro_crew.dashboard import responsive_chat @@ -653,6 +670,19 @@ def _drop_stale_admissions(state: DashboardState, slot: _ChatSlot) -> None: ) +async def _on_steer_dropped(turn: _ChatTurn, event) -> None: + """kiro-cli dropped the steers it held, unread, on a take-back's clear. + + A copy fanned out to every session on the process names no owner, so it + settles nothing here. + """ + if getattr(event, "runtime_global", False): + return + from kiro_crew.dashboard import steer_retract + + await steer_retract.on_dropped(turn.state, turn.slot) + + async def _on_steer_consumed(turn: _ChatTurn, event) -> None: """A steer the session took in mid-turn.""" _person_steered_turn = turn._person_steered_turn diff --git a/src/kiro_crew/dashboard/chat_turn/turn_entry.py b/src/kiro_crew/dashboard/chat_turn/turn_entry.py index c3335ca3629..e2b25cde2c6 100644 --- a/src/kiro_crew/dashboard/chat_turn/turn_entry.py +++ b/src/kiro_crew/dashboard/chat_turn/turn_entry.py @@ -118,10 +118,13 @@ async def _turn_open(turn: _ChatTurn) -> _Flow | None: state = turn.state turn_exit = turn.turn_exit turn_exit.depth = _prompt_depth - from kiro_crew.dashboard import responsive_chat + from kiro_crew.dashboard import responsive_chat, steer_retract if _prompt_depth == 0: responsive_chat.begin_turn(slot, protected=_responsive_protected) + # Before this turn can be steered: a take-back still offered on a row now + # belongs to a turn that ended without settling it (a hard Stop, a restart). + steer_retract.withdraw_stale_offers(state, slot) _responsive_turn = responsive_chat.turn_state(slot) turn._responsive_turn = _responsive_turn _responsive_owner = asyncio.current_task() diff --git a/src/kiro_crew/dashboard/routes/chat.py b/src/kiro_crew/dashboard/routes/chat.py index 8090c27ef5a..f7d687231ad 100644 --- a/src/kiro_crew/dashboard/routes/chat.py +++ b/src/kiro_crew/dashboard/routes/chat.py @@ -127,6 +127,9 @@ def register(app: web.Application) -> None: ) app.router.add_patch("/api/chat/slots/{slot}/queue/{queue_id}", chat.api_chat_slot_queue_edit) app.router.add_put("/api/chat/slots/{slot}/queue/order", chat.api_chat_slot_queue_reorder) + app.router.add_post( + "/api/chat/slots/{slot}/steers/{mid}/retract", chat.api_chat_slot_steer_retract + ) app.router.add_delete("/api/chat/slots/{slot}", chat.api_chat_slot_delete) app.router.add_post( "/api/chat/slots/{slot}/reset-conversation", chat.api_chat_slot_reset_conversation diff --git a/src/kiro_crew/dashboard/state_parts/chat_slot.py b/src/kiro_crew/dashboard/state_parts/chat_slot.py index 2fb574d682e..3abcbf8fbe5 100644 --- a/src/kiro_crew/dashboard/state_parts/chat_slot.py +++ b/src/kiro_crew/dashboard/state_parts/chat_slot.py @@ -36,6 +36,7 @@ if TYPE_CHECKING: from kiro_crew.dashboard.git_repo_touches import TouchCandidates from kiro_crew.dashboard.state_parts.dashboard_state import DashboardState + from kiro_crew.dashboard.steer_retract import SteerClear, SteerRetract from kiro_crew.subagent import SubagentDelivery from kiro_crew.trust_patterns import GrantDeadline @@ -318,6 +319,8 @@ class _ChatSlot: "_steer_decision_strips", "_steer_possibly_delivered", "_steer_rpc_in_flight", + "_steer_retract", + "_steer_clear", "_steer_audience_fences", "_steer_audience_fence_holders", "_steer_attachment_meta", @@ -589,6 +592,8 @@ class _ChatSlot: _steer_decision_strips: dict[str, dict] _steer_possibly_delivered: set[str] _steer_rpc_in_flight: set[str] + _steer_retract: SteerRetract | None + _steer_clear: SteerClear | None _steer_audience_fences: dict[str, dict] _steer_audience_fence_holders: dict[str, int] _steer_attachment_meta: dict[str, dict[str, Any]] diff --git a/src/kiro_crew/dashboard/state_parts/slot_ladder.py b/src/kiro_crew/dashboard/state_parts/slot_ladder.py index 351417c4afb..4e349273fc0 100644 --- a/src/kiro_crew/dashboard/state_parts/slot_ladder.py +++ b/src/kiro_crew/dashboard/state_parts/slot_ladder.py @@ -27,9 +27,9 @@ ``_stale_recovery_retries``, ``_steer_act_now``, ``_steer_admissions``, ``_steer_attachment_meta``, ``_steer_audience_fence_holders``, ``_steer_audience_fences``, ``_steer_automation``, ``_steer_channel_origin``, -``_steer_confirmed``, +``_steer_clear``, ``_steer_confirmed``, ``_steer_decision_strips``, ``_steer_delivery_ids``, ``_steer_possibly_delivered``, -``_steer_rpc_in_flight``, ``_steer_segment_cut``, ``_steer_send_ids``, +``_steer_retract``, ``_steer_rpc_in_flight``, ``_steer_segment_cut``, ``_steer_send_ids``, ``_steer_user_origin``, ``_subagent_deliveries_inflight``, ``_subagents_inline_collected``, ``_synthesis_completion_turns``, ``_synthesis_inflight``, ``_synthesis_recheck``, ``_synthesis_rechecks``, @@ -424,6 +424,12 @@ def init_ladder_fields(self: _ChatSlot) -> None: # already be in the pipe, and the RPC's own verdict arrives too late to # mark an entry the drain may already have run. self._steer_rpc_in_flight = set() + # A pending steer the person is taking back, and the ``_session/steer/clear`` + # written for it whose drop kiro-cli has not reported yet. Both live for one + # turn at most: the turn's report or its teardown settles them + # (``dashboard/steer_retract.py``). None when nothing is being taken back. + self._steer_retract = None + self._steer_clear = None # Admission snapshots of the peer steers that influenced THIS turn, keyed by # an opaque token. The turn consults them before publishing its CROSS-SURFACE # reply leg and withholds it when a constraint newly holds: diff --git a/src/kiro_crew/dashboard/steer_retract.py b/src/kiro_crew/dashboard/steer_retract.py new file mode 100644 index 00000000000..35d95f29440 --- /dev/null +++ b/src/kiro_crew/dashboard/steer_retract.py @@ -0,0 +1,485 @@ +"""Taking back a mid-turn steer the running turn has not read yet. + +A steer is handed to kiro-cli the moment it is sent, and kiro-cli holds it until +the turn reaches a point where it can read it. Until then it can be taken back: +``_session/steer/clear`` drops every steer kiro-cli is holding, and kiro-cli reports +that it did with ``AgentExecutionUserMessageCleared``, which reaches the turn as +``EVENT_STEER_DROPPED`` (``ACP_BACKENDS_STEER_RETRACT``). + +The person picks what happens to the text: ``unsend`` discards it, ``edit`` hands +it back to their composer (the dashboard does that, from the row it already +draws), and ``queue`` puts it in the slot's queue to run after the turn. + +The outcome is decided by what the turn reports after the clear is written, never +by the clear's own answer, which is ``{cleared: true}`` whether anything was held +or not: + +* ``EVENT_STEER_DROPPED`` while the steer is still pending: kiro-cli dropped it + unread, so it is taken back (:func:`on_dropped`). +* the steer settled by ``EVENT_STEER_CONSUMED`` first: the turn read it, and there + is nothing to take back (:func:`on_settled`). +* the turn ending with the steer still pending: it never reached the turn, so it + is taken back too (:func:`on_turn_ending`). +* a hard Stop ending the turn outside the runner: kiro-cli's report that the turn + read or dropped the steer may still be queued, so the take-back is released and + its row left unchanged (:func:`on_hard_stop`). + +The person sees which one happened on the steer's own row: ``retracted`` (with +the action), ``consumed``, or ``requeued`` for a ``queue`` take-back the turn's end +overtook, since that requeue is exactly what was asked. The route only arms the +take-back and writes the clear, and answers before any of that is known. + +kiro-cli drops EVERY steer it holds, not only the one asked for. The steers +pending when the clear was written are its batch, and the ones the drop took +besides the taken-back steer are sent again, in their order. A steer sent while a +clear is in flight waits for the clear's answer first (:func:`wait_for_clear`), so +the clear cannot drop it. A deny notice the turn steered in and kiro-cli still held +is dropped too; it is not in the batch, and the turn's refusal recovery delivers it +when the turn ends, the fallback for any notice the turn never confirmed. +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from kiro_crew.dashboard import responsive_chat +from kiro_crew.dashboard.chat_delivery import ( + STEER_RETRACTABLE_META, + STEER_STATE_RETRACTED, + STEER_STATE_WRITTEN, + attachment_meta, + find_written_steer_row, + queue_for_next_turn, + quote_meta, +) + +if TYPE_CHECKING: # pragma: no cover - typing only + from kiro_crew.dashboard.state import DashboardState, _ChatSlot + +logger = logging.getLogger(__name__) + +RETRACT_UNSEND = "unsend" +RETRACT_EDIT = "edit" +RETRACT_QUEUE = "queue" +RETRACT_ACTIONS = frozenset({RETRACT_UNSEND, RETRACT_EDIT, RETRACT_QUEUE}) + +#: The action a taken-back steer's row records, so the row says what became of +#: the text. +RETRACT_ACTION_META = "steerRetractAction" + +# What :func:`begin` answers. +RETRACT_ACCEPTED = "accepted" +RETRACT_NOT_PENDING = "not_pending" +RETRACT_BUSY = "busy" +RETRACT_UNSUPPORTED = "unsupported" + +#: What the route answers a refused take-back, by :func:`begin`'s outcome: +#: ``(error, code)``. +REFUSALS: dict[str, tuple[str, str]] = { + RETRACT_NOT_PENDING: ("this steer is no longer waiting for the agent", "steer_not_pending"), + RETRACT_BUSY: ("another message is being sent or taken back; try again", "steer_retract_busy"), + RETRACT_UNSUPPORTED: ("this agent cannot take back a steer", "steer_retract_unsupported"), +} + +#: How long a steer sent during a take-back waits for the clear's answer. The +#: clear's own wait is the bound that matters; this one only stops a steer +#: waiting on a clear whose writer died without saying so. +CLEAR_GATE_WAIT_SECS = 6.0 + + +@dataclass +class SteerRetract: + """A steer being taken back, until the turn says what became of it.""" + + #: The steer's text: its key in ``_pending_steers`` and its lockstep maps. + message: str + #: Its transcript row. + mid: str + action: str + #: The containment in effect when the person asked to queue this steer. + admission: dict[str, Any] | None = None + #: Whether the session's own human asked for this from the composer. + directive_user_origin: bool = True + #: The turn it was asked during (``slot.task``). A record of a turn that is + #: over is stale, whatever path ended that turn. + turn: object = None + + +@dataclass +class SteerClear: + """A ``_session/steer/clear`` whose drop has not been reported yet.""" + + #: Every steer pending when the clear was written, in send order: what the + #: clear can have dropped. + batch: tuple[str, ...] + #: The turn it was written during (``slot.task``). + turn: object = None + #: Set once the clear's answer is in (or never will be), so a steer written + #: after it cannot be dropped by it. + answered: asyncio.Event = field(default_factory=asyncio.Event) + + +def _row(slot: "_ChatSlot", mid: str) -> dict[str, Any] | None: + """The transcript row whose ``mid`` is *mid*, searched newest first.""" + for row in reversed(slot.messages): + meta = row.get("meta") + if isinstance(meta, dict) and meta.get("mid") == mid: + return row + return None + + +def _drop_stale(slot: "_ChatSlot") -> None: + """Forget a take-back whose turn is over. + + The turn's own teardown settles a take-back (:func:`on_turn_ending`). This is + the backstop for an exit that skipped it: a record left behind would make + every later take-back on the slot answer busy, and a clear left unanswered + would hold every later steer at :func:`wait_for_clear`. + """ + record, clear = slot._steer_retract, slot._steer_clear + if record is not None and record.turn is not slot.task: + slot._steer_retract = None + if clear is not None and clear.turn is not slot.task: + clear.answered.set() + slot._steer_clear = None + + +def pending_steer_row(slot: "_ChatSlot", mid: str) -> dict[str, Any] | None: + """The row of a steer that can still be taken back, by its ``mid``.""" + for row in reversed(slot.messages): + meta = row.get("meta") + if not isinstance(meta, dict) or meta.get("mid") != mid: + continue + if ( + row.get("role") == "user" + and meta.get("steer") is True + and meta.get("steerState") == STEER_STATE_WRITTEN + and meta.get(STEER_RETRACTABLE_META) is True + ): + return row + return None + return None + + +async def begin(state: "DashboardState", slot: "_ChatSlot", mid: str, action: str) -> str: + """Start taking back the pending steer whose row is *mid*; return a ``RETRACT_*``. + + ``accepted`` means the take-back is armed and the clear was written, or its + answer is still owed; the outcome reaches the row later. Anything else + changes nothing. Only one take-back runs at a time on a slot, and none while + a steer is still being written, because a clear written beside a steer could + land on either side of it. None starts while a hard Stop is discarding the + turn and its pending steers. + """ + if slot._stop_state == "killing": + return RETRACT_NOT_PENDING + row = pending_steer_row(slot, mid) + message = row.get("content") if row is not None else None + if ( + not isinstance(message, str) + or message not in slot._pending_steers + or find_written_steer_row(slot, message) is not row + or message in slot._steer_possibly_delivered + ): + return RETRACT_NOT_PENDING + client = getattr(slot, "_acp_client", None) + if client is None or getattr(client, "supports_steer_retract", False) is not True: + return RETRACT_UNSUPPORTED + _drop_stale(slot) + if slot._steer_retract is not None or slot._steer_clear is not None: + return RETRACT_BUSY + if slot._steer_rpc_in_flight: + return RETRACT_BUSY + from kiro_crew.dashboard import session_control + + record = SteerRetract( + message=message, + mid=mid, + action=action, + admission=( + session_control.containment_meta(state, slot) if action == RETRACT_QUEUE else None + ), + directive_user_origin=True, + turn=slot.task, + ) + clear = SteerClear(batch=tuple(slot._pending_steers), turn=slot.task) + slot._steer_retract = record + slot._steer_clear = clear + try: + answered = await client.clear_pending_steers() + except Exception as exc: + # Whether the frame reached kiro-cli is unknown, so the take-back stays + # armed: a drop it did cause is still reported, and the turn's end + # settles it otherwise. + logger.warning("steer clear for slot %s failed: %s", slot.key, type(exc).__name__) + answered = None + finally: + clear.answered.set() + if answered is False: + if slot._steer_clear is clear: + slot._steer_clear = None + if slot._steer_retract is not record: + # The row already carries the outcome. Acceptance keeps browser recovery + # until the browser reads that settled row. + return RETRACT_ACCEPTED + # Refused while still armed: nothing was dropped, so the row stops + # offering the take-back. + slot._steer_retract = None + _withdraw_offer(state, slot, record) + return RETRACT_UNSUPPORTED + return RETRACT_ACCEPTED + + +async def wait_for_clear(slot: "_ChatSlot") -> None: + """Hold a steer about to be written until an in-flight clear is answered.""" + _drop_stale(slot) + clear = slot._steer_clear + if clear is None or clear.answered.is_set(): + return + try: + await asyncio.wait_for(clear.answered.wait(), CLEAR_GATE_WAIT_SECS) + except asyncio.TimeoutError: + logger.warning("steer for slot %s sent without the clear's answer", slot.key) + + +async def on_dropped(state: "DashboardState", slot: "_ChatSlot") -> None: + """kiro-cli dropped the steers it held: settle the take-back, resend the rest.""" + clear = slot._steer_clear + slot._steer_clear = None + if clear is None: + # No clear of ours is waiting on this, so nothing can say which steers it + # names. Those still pending are requeued by the turn's teardown. + return + possibly_delivered = slot._steer_possibly_delivered + dropped = [ + message + for message in clear.batch + if message in slot._pending_steers and message not in possibly_delivered + ] + record = slot._steer_retract + if record is not None and record.message in possibly_delivered: + slot._steer_retract = None + if record is not None and record.message in dropped: + slot._steer_retract = None + dropped.remove(record.message) + take_back(state, slot, record) + await _send_again(slot, dropped) + + +def on_settled(slot: "_ChatSlot") -> None: + """The turn read some steers: a take-back of one of them is over.""" + record = slot._steer_retract + if record is not None and ( + record.message not in slot._pending_steers + or record.message in slot._steer_possibly_delivered + ): + # The turn read it first, or its delivery is unknown (a recovery, an + # ambiguous write or a partly echoed collision marked it possibly delivered). + slot._steer_retract = None + logger.info("steer take-back for slot %s overtaken: the turn may have read it", slot.key) + clear = slot._steer_clear + if clear is not None and not any(m in slot._pending_steers for m in clear.batch): + # Everything the clear could have dropped was read, so it drops nothing + # and kiro-cli sends no report for it. + slot._steer_clear = None + + +def on_turn_ending(state: "DashboardState", slot: "_ChatSlot") -> None: + """The turn is over: a steer still being taken back never reached it. + + Runs before the teardown requeues the pending steers. ``unsend`` and ``edit`` + are taken back here, so the requeue does not run the text. ``queue`` is left + pending: the requeue puts it at the head of the queue, which is what was + asked. + """ + record = slot._steer_retract + clear = slot._steer_clear + slot._steer_retract = None + slot._steer_clear = None + if clear is not None: + clear.answered.set() + if ( + record is None + or record.message not in slot._pending_steers + or record.message in slot._steer_possibly_delivered + ): + return + if record.action == RETRACT_QUEUE: + if record.admission is not None: + slot._steer_admissions[record.message] = record.admission + slot._steer_user_origin[record.message] = record.directive_user_origin + return + take_back(state, slot, record) + + +def on_hard_stop(slot: "_ChatSlot") -> None: + """Release a take-back when a hard Stop ends the turn outside the runner. + + The runner may still hold kiro-cli's report that the turn read or dropped the + steer, so nothing confirms the take-back. The steer is discarded with the + other pending steers and its row is left as the hard Stop leaves theirs. The + page hands an Edit's or a Queue instead's text back. + """ + clear = slot._steer_clear + slot._steer_retract = None + slot._steer_clear = None + if clear is not None: + clear.answered.set() + + +def withdraw_stale_offers(state: "DashboardState", slot: "_ChatSlot") -> None: + """A new turn is starting: a take-back still offered belongs to a turn that is over. + + A steer stays ``written`` and offered when its turn ends without settling it: + a hard Stop discards the pending steers without touching their rows, and a + gateway restart mid-turn leaves them as persisted. The row would then offer + Queue instead, Edit and Unsend for every later turn, and each click would be + refused with ``steer_not_pending``. So each such row stops offering it here, its + state and text unchanged. + + Only while nothing is pending or being taken back: then no row still offered + can belong to the turn starting now. Any page holding a take-back on one of + these rows has already seen its turn end. + """ + if slot._pending_steers or slot._steer_retract is not None or slot._steer_clear is not None: + return + stale = [ + meta["mid"] + for row in slot.messages + if row.get("role") == "user" + and isinstance(meta := row.get("meta"), dict) + and meta.get(STEER_RETRACTABLE_META) is True + and meta.get("steer") is True + and meta.get("steerState") == STEER_STATE_WRITTEN + and isinstance(meta.get("mid"), str) + and meta["mid"] + ] + for mid in stale: + _patch_row_meta(state, slot, mid, {STEER_RETRACTABLE_META: False}) + if stale: + logger.info( + "steer take-back withdrawn from %d ended steer(s) on slot %s", len(stale), slot.key + ) + + +def take_back(state: "DashboardState", slot: "_ChatSlot", record: SteerRetract) -> None: + """Take *record*'s steer out of the turn and do what the person asked with it.""" + held = dict(slot._steer_attachment_meta.get(record.message) or {}) + _forget(slot, record.message) + _mark_retracted(state, slot, record) + if record.action == RETRACT_QUEUE: + # The queue admission is the audience that held when the person asked, + # before the clear could suspend and let that audience change. + queue_for_next_turn( + state, + slot, + record.message, + directive_user_origin=record.directive_user_origin, + attachments=attachment_meta(held), + quote=quote_meta(held).get("quote"), + admission=record.admission, + ) + logger.info("steer taken back for slot %s (%s)", slot.key, record.action) + + +def _forget(slot: "_ChatSlot", message: str) -> None: + """Drop *message* from the pending steers and every map kept in lockstep.""" + if message in slot._pending_steers: + slot._pending_steers.remove(message) + responsive_chat.forget_steer(slot, message) + for held in ( + slot._steer_delivery_ids, + slot._steer_send_ids, + slot._steer_user_origin, + slot._steer_channel_origin, + slot._steer_admissions, + slot._steer_attachment_meta, + slot._steer_decision_strips, + slot._steer_automation, + ): + held.pop(message, None) + slot._steer_act_now.discard(message) + slot._steer_possibly_delivered.discard(message) + + +def _patch_row_meta( + state: "DashboardState", + slot: "_ChatSlot", + mid: str, + changes: dict, + *, + content: str | None = None, +) -> None: + """Merge *changes* into the meta of the row *mid* and tell open clients. + + *content*, when given, replaces the row's text as well. Keyed on ``mid``, the + row's own identity: ``ts`` is not one, since two rows minted in the same clock + tick share it. + """ + row = _row(slot, mid) + if row is None: + return + ts = str(row.get("ts") or "") + meta = {**(row.get("meta") or {}), **changes} + if slot.update_message(ts, content=content, meta=meta, mid=mid) is None: + return + frame: dict[str, Any] = {"slot": slot.key, "ts": ts, "mid": mid, "meta": meta} + if content is not None: + frame["content"] = content + state.broadcast_ws("chat_message_update", frame) + + +def _mark_retracted(state: "DashboardState", slot: "_ChatSlot", record: SteerRetract) -> None: + """Record on the steer's row that it was taken back, and what for. + + The row keeps its place but not its text. Everything that later reads the + transcript as the conversation -- a cold start's history replay, the memory + consolidator, a summary, a fork, history search -- would otherwise present a + message the person took back as something they said. An Edit's text went back + to the composer, and a Queue instead's runs as its own row when it drains. + """ + _patch_row_meta( + state, + slot, + record.mid, + { + "steerState": STEER_STATE_RETRACTED, + RETRACT_ACTION_META: record.action, + STEER_RETRACTABLE_META: False, + }, + content="", + ) + + +def _withdraw_offer(state: "DashboardState", slot: "_ChatSlot", record: SteerRetract) -> None: + """The backend refused the clear: the row stops offering a take-back.""" + _patch_row_meta(state, slot, record.mid, {STEER_RETRACTABLE_META: False}) + + +async def _send_again(slot: "_ChatSlot", messages: list[str]) -> None: + """Resend steers a clear dropped besides the one taken back, in their order. + + Each stays pending throughout, so one whose resend fails, or whose turn ends + first, is requeued by the turn's teardown like any steer the turn never read. + """ + client = getattr(slot, "_acp_client", None) + if client is None: + return + for message in messages: + if message not in slot._pending_steers or getattr(slot, "_acp_client", None) is not client: + continue + wire = responsive_chat.turn_state(slot).steers.get(message, message) + slot._steer_rpc_in_flight.add(message) + try: + sent = await client.steer(wire) + except Exception as exc: + sent = False + if getattr(exc, "ambiguous_delivery", False) is True: + slot._steer_possibly_delivered.add(message) + finally: + slot._steer_rpc_in_flight.discard(message) + if not sent: + logger.info("steer for slot %s not sent again after a clear", slot.key) diff --git a/src/kiro_crew/dashboard/steer_settle.py b/src/kiro_crew/dashboard/steer_settle.py index 6324a4fcc12..75efb2a45de 100644 --- a/src/kiro_crew/dashboard/steer_settle.py +++ b/src/kiro_crew/dashboard/steer_settle.py @@ -11,11 +11,16 @@ import re from kiro_crew.acp._dispatch import redact_text +from kiro_crew.agent_sdk.backends import escape_user_message_markup #: kiro-cli wraps each injected steer in this envelope inside the echo text. _BLOCK_RE = re.compile(r"\n(.*?)\n", re.DOTALL) +def _settlement_key(message: str) -> str: + return redact_text(escape_user_message_markup(message.strip())) + + def settle_consumed_steers( pending: list[str], snapshot: str, @@ -43,6 +48,8 @@ def settle_consumed_steers( A NON-empty echo carrying no envelope is treated as one bare block rather than as no evidence, so it settles only the entry it EQUALS -- unrelated prose still matches nothing and still settles nothing. + + Crew escapes each wrapped steer, so its echoed blocks split only one way. """ if not snapshot.strip(): return list(pending) @@ -83,7 +90,8 @@ def settle_consumed_steers( # and a falsely-unmatched steer is requeued — running an # already-injected question a second time, on exactly the questions where # that is least acceptable. Both sides go through the same redactor. - keys = [redact_text(message.strip()) for message in pending] + # ``wrap_user_message`` escapes each steer, so its echoed blocks split one way. + keys = [_settlement_key(message) for message in pending] group_size: dict[str, int] = {} group_raw: dict[str, set[str]] = {} for message, key in zip(pending, keys): @@ -120,3 +128,21 @@ def settle_consumed_steers( else: remaining.append(message) return remaining + + +def possibly_consumed_steers(pending: list[str], snapshot: str) -> list[str]: + """Return collision-group entries that *snapshot* may have consumed.""" + groups: dict[str, list[str]] = {} + for message in pending: + groups.setdefault(_settlement_key(message), []).append(message) + + ambiguous: set[str] = set() + for key, messages in groups.items(): + if len({message.strip() for message in messages}) < 2: + continue + representative = messages[0] + remaining = settle_consumed_steers([representative] * len(messages), snapshot) + echoed_count = len(messages) - len(remaining) + if 0 < echoed_count < len(messages): + ambiguous.add(key) + return [message for message in pending if _settlement_key(message) in ambiguous] diff --git a/src/kiro_crew/providers/base.py b/src/kiro_crew/providers/base.py index 3e3f8295ec2..c835c1a7881 100644 --- a/src/kiro_crew/providers/base.py +++ b/src/kiro_crew/providers/base.py @@ -666,6 +666,22 @@ def supports_refusal_steer(self) -> bool: """ return False + @property + def supports_steer_retract(self) -> bool: + """True when a steer the turn has not read yet can be taken back with + :meth:`clear_pending_steers`. Default False, granted by opt-in.""" + return False + + async def clear_pending_steers(self) -> bool | None: + """Ask the backend to drop every steer its turn has not read yet. + + True when the backend answered the request, False when it refused it or + has none, None when no answer came in time. An answer is not evidence that + anything was dropped; the backend reports that separately, as + ``EVENT_STEER_DROPPED`` on the turn. Default False: nothing is sent. + """ + return False + @property def steer_needs_loss_recovery(self) -> bool: """True when a steer this provider accepted can still be dropped. diff --git a/src/kiro_crew/slack/handler_runtime/turn.py b/src/kiro_crew/slack/handler_runtime/turn.py index 1a61a917b27..42a2439176e 100644 --- a/src/kiro_crew/slack/handler_runtime/turn.py +++ b/src/kiro_crew/slack/handler_runtime/turn.py @@ -669,7 +669,9 @@ async def _replay_abandoned(notice: str, replay: _CompactionReplay) -> None: and context_builder.conversation_log ): _session.prev_turn_cancelled = False - _preamble = build_cancelled_turn_preamble(context_builder.conversation_log, session_key) + _preamble = await asyncio.to_thread( + build_cancelled_turn_preamble, context_builder.conversation_log, session_key + ) if _preamble: offset = len(_preamble) + 2 _user_text_range = (offset, offset + len(text)) diff --git a/test/fixtures/acp_frames/README.md b/test/fixtures/acp_frames/README.md index fb3e12e9415..c9d44d2c48c 100644 --- a/test/fixtures/acp_frames/README.md +++ b/test/fixtures/acp_frames/README.md @@ -231,7 +231,7 @@ has never seen, so it is not a safety net — the review step below is. | Directory | Backend id | Provenance | Why | |---|---|---|---| -| `kiro/` | `""` | **live** | `session.jsonl`: a slice of one `kiro-cli acp` 2.21.4 turn reaching all seven classes, plus the `_kiro.dev/session/update` `tool_call_chunk` only kiro-cli sends. `compaction-live.jsonl`: a kiro-cli 2.32.0 `/compact` turn and the `_kiro.dev/compaction/status` `started` / `completed` pair that follows its `end_turn`, between `_kiro.dev/metadata` readings. `notifications.jsonl` stays synthesized: it pins the rest of the `_kiro.dev/*` family, and a turn that runs one shell command reaches five of its members (`metadata`, `mcp/server_initialized`, `subagent/list_update`, `commands/available`, `session/update`) but never `clear/status`, `agent/switched`, `mcp/server_init_failure` or `mcp/oauth_request` — each needs its own scenario, and the OAuth one needs an MCP server mid-authorization. Three more kiro-cli 2.32.0 captures are membership evidence: `steering-live.jsonl` (a `_session/steer` mid-turn, `{queued: true}`, and the `AgentExecutionUserMessageQueued` / `AgentExecutionSteeringInjected` updates), `saved-spec-live.jsonl` (`--agent probe` running a saved spec's MCP tool) and `commands-execute-live.jsonl` (`_kiro.dev/commands/execute` `effort` answered `{success: true}`). `side-read-only-live.jsonl`, on kiro-cli 2.34.0: a side session on the derived `--readonly` spec, four turns (`fs_read`, `grep`, `execute_bash`, `fs_write`); the last two raised `session/request_permission` and were rejected. | +| `kiro/` | `""` | **live** | `session.jsonl`: a slice of one `kiro-cli acp` 2.21.4 turn reaching all seven classes, plus the `_kiro.dev/session/update` `tool_call_chunk` only kiro-cli sends. `compaction-live.jsonl`: a kiro-cli 2.32.0 `/compact` turn and the `_kiro.dev/compaction/status` `started` / `completed` pair that follows its `end_turn`, between `_kiro.dev/metadata` readings. `notifications.jsonl` stays synthesized: it pins the rest of the `_kiro.dev/*` family, and a turn that runs one shell command reaches five of its members (`metadata`, `mcp/server_initialized`, `subagent/list_update`, `commands/available`, `session/update`) but never `clear/status`, `agent/switched`, `mcp/server_init_failure` or `mcp/oauth_request` — each needs its own scenario, and the OAuth one needs an MCP server mid-authorization. Three more kiro-cli 2.32.0 captures are membership evidence: `steering-live.jsonl` (a `_session/steer` mid-turn, `{queued: true}`, and the `AgentExecutionUserMessageQueued` / `AgentExecutionSteeringInjected` updates), `saved-spec-live.jsonl` (`--agent probe` running a saved spec's MCP tool) and `commands-execute-live.jsonl` (`_kiro.dev/commands/execute` `effort` answered `{success: true}`). Two kiro-cli 2.33.1 captures are the evidence for `ACP_BACKENDS_STEER_RETRACT`: `steering-clear-live.jsonl` (two pending steers, `_session/steer/clear` answered `{cleared: true}` and followed by `AgentExecutionUserMessageCleared` naming both, then a re-sent steer injected and obeyed) and `steering-clear-after-inject-live.jsonl` (a clear after one steer was injected names only the steer still pending, and a clear with nothing pending sends no notification). `side-read-only-live.jsonl`, on kiro-cli 2.34.0: a side session on the derived `--readonly` spec, four turns (`fs_read`, `grep`, `execute_bash`, `fs_write`); the last two raised `session/request_permission` and were rejected. | | `kas/` | `kas` | **live** | `session.jsonl`: a slice of one KAS 0.63.3 turn through `kiro-cli acp --agent-engine v3 --auth-method cli`, the production relay spawn, reaching all seven classes. Its initialize response carries no `agentInfo`, which is why KAS is named in `_BACKENDS_WITHOUT_AGENT_VERSION`. Four more captures off KAS 0.66.43 (kiro-cli 2.32.0), the evidence for three memberships and one exclusion. `steering-live.jsonl`: a `_session/steer` sent mid-turn, its `{queued: true}` answer, and the `steering_queued` / `steering_injected` / `steering_cleared` kinds, which arrive as `session_info_update` with the kind under `_meta.kiro`. `compact-live.jsonl`: the `_kiro/session/compact` verb after three turns, its `summarization_completed` and `{success: true}`, and a lower `context_usage` on the next turn. `side-read-only-live.jsonl`: a side session on the derived `--readonly` spec where every tool call raised `session/request_permission`. `commands-refused-live.jsonl`, same build: three `_kiro.dev/commands/execute` requests (`tools`, `context`, `effort`), each answered -32603 because the engine has no persistence classification for the method, which is why KAS is outside `ACP_BACKENDS_KIRO_SLASH_COMMANDS`. Two more on KAS 0.66.43: `saved-spec-live.jsonl` (a saved spec projected to `customAgents` and activated with `session/set_mode`, its MCP tool called) and `member-dispatch-live.jsonl` (a member DM session calling `@kirocrew-dashboard/session_status`). `bundle-tool-path.jsonl` is a second **live** capture, off the bundle route (`agent.kas_bundle`: KAS 0.66.33 spawned from Kiro Crew's own pinned engine with no kiro-cli, recorded through a live gateway with `KIROCREW_ACP_RECORD_FRAMES`): the tool path only -- `tool_call`, `session/request_permission`, the `tool_call_update` frames through `completed`, the chunks, the turn-end `session_info_update` and the `stopReason` response -- so `test_kas_bundle_hooks_parity.py` can lay it beside `session.jsonl` frame class by frame class. Ids and the workspace path were normalized at capture (its `_meta.note` says which), so it carries no baseline entry in `check_acp_frame_host_data.py`. | | `claude/` | `claude` | **live** | All on `@agentclientprotocol/claude-agent-acp` 0.94.0, the adapter delegating to the host `claude` executable. `session.jsonl`: a slice of one turn reaching all seven classes. It advertises `loadSession: true`, and its `session/request_permission` names the tool under `name`. `compact-live.jsonl`: a `/compact` turn finished inside the turn, `preTokens` 31250 -> `postTokens` 4148. `mcp-stdio-mount-live.jsonl`: an `_acp_server_entry`-shaped element mounted, its tool asked and completed. `saved-spec-projection-live.jsonl`: the server the claude mirror projected out of a saved spec, the same round trip. `side-read-ask-live.jsonl` and `side-read-no-ask-live.jsonl`: a side session under Crew's side settings, with and without the Read, Grep and Glob ask rules; with them every read asks, without them in-cwd reads run unasked, and calls under a denied path fail without asking. | | `codex/` | `codex` | **live** + synthesized | Three captures off `codex-acp` 2.2.2, the Real Adapter Contract lane's pin, driven against a loopback model stub. `session-live.jsonl`: one turn reaching all seven classes, its stub asking for an escalated `exec_command` so the adapter sends a `tool_call`, a `session/request_permission` and a `tool_call_update` with the terminal output, plus the `_auth/status_update` sent before `session/new` and the `session_info_update` frames carrying `_meta.codex`. `steering-live.jsonl`: the lane's `_session/steering` run, its initialize answering `_meta.steering.supported: true` and a mid-turn steer answered `injected`. `compact-live-2.2.2.jsonl`: a manual `/compact` pair carrying no `_meta`. `session.jsonl` is synthesized from the parsers in `src/kiro_crew/acp/_dispatch_parts/`, from before a live tool turn existed. | diff --git a/test/fixtures/acp_frames/kiro/steering-clear-after-inject-live.expected.json b/test/fixtures/acp_frames/kiro/steering-clear-after-inject-live.expected.json new file mode 100644 index 00000000000..ba63761f3ad --- /dev/null +++ b/test/fixtures/acp_frames/kiro/steering-clear-after-inject-live.expected.json @@ -0,0 +1,359 @@ +[ + { + "agent_capabilities": { + "auth": {}, + "loadSession": true, + "mcpCapabilities": { + "http": true, + "sse": false + }, + "promptCapabilities": { + "audio": false, + "embeddedContext": false, + "image": true + }, + "sessionCapabilities": {} + }, + "agent_version": "2.33.1", + "frame": "response", + "id": 101, + "protocol_version": 1 + }, + { + "action": "update", + "events": [ + { + "is_shell": true, + "kind": "tool_call", + "raw_tool_params": { + "command": "sleep 10 && echo second-done" + }, + "title": "sleep 10 && echo second-done", + "tool_call_id": "toolu_bdrk_01Cr7of9kbGdLnrTToyMWhxr", + "tool_identity_trusted": true, + "tool_input": "{\n \"command\": \"sleep 10 && echo second-done\"\n}", + "tool_kind": "execute", + "tool_name": "shell", + "wire_title": "Running: sleep 10 && echo second-done" + } + ], + "frame": "session/update", + "session_update": "tool_call" + }, + { + "action": "permission", + "events": [ + { + "is_shell": true, + "kind": "permission_request", + "mcp_identity_trusted": true, + "options": [ + { + "id": "allow_once", + "label": "Yes" + }, + { + "id": "allow_always", + "label": "Always" + }, + { + "id": "reject_once", + "label": "No" + } + ], + "raw_params_trusted": true, + "raw_tool_params": { + "command": "sleep 10 && echo second-done" + }, + "request_id": "synthetic-id-1", + "shell_classified": true, + "title": "Running: sleep 10 && echo second-done", + "tool_call_id": "toolu_bdrk_01Cr7of9kbGdLnrTToyMWhxr", + "tool_input": "{\n \"command\": \"sleep 10 && echo second-done\"\n}", + "tool_name": "shell" + } + ], + "frame": "session/request_permission", + "recorded_options": { + "always": "allow_always", + "once": "allow_once", + "reject": "reject_once" + } + }, + { + "frame": "response", + "id": 104 + }, + { + "action": "steer", + "frame": "_kiro.dev/session/update", + "session_update": "AgentExecutionUserMessageQueued" + }, + { + "action": "update", + "events": [ + { + "kind": "tool_result", + "tool_call_id": "toolu_bdrk_01Cr7of9kbGdLnrTToyMWhxr", + "tool_output": "second-done\n", + "tool_output_bytes": 12, + "tool_output_digest": "539c506203d89bd5a48b0128027aa295bdf733c3df536b0dcfa16729ef564066" + } + ], + "frame": "session/update", + "session_update": "tool_call_update" + }, + { + "action": "update", + "events": [ + { + "kind": "tool_result", + "tool_call_id": "toolu_bdrk_01Cr7of9kbGdLnrTToyMWhxr", + "tool_final": true, + "tool_output": "second-done\n", + "tool_output_bytes": 12, + "tool_output_digest": "539c506203d89bd5a48b0128027aa295bdf733c3df536b0dcfa16729ef564066", + "tool_status": "completed" + }, + { + "is_shell": true, + "kind": "tool_call_update", + "raw_tool_params": { + "command": "sleep 10 && echo second-done" + }, + "title": "sleep 10 && echo second-done", + "tool_call_id": "toolu_bdrk_01Cr7of9kbGdLnrTToyMWhxr", + "tool_input": "{\n \"command\": \"sleep 10 && echo second-done\"\n}", + "tool_kind": "execute", + "wire_title": "Running: sleep 10 && echo second-done" + } + ], + "frame": "session/update", + "session_update": "tool_call_update" + }, + { + "action": "steer", + "frame": "_kiro.dev/session/update", + "session_update": "AgentExecutionSteeringInjected" + }, + { + "action": "update", + "events": [ + { + "is_shell": true, + "kind": "tool_call", + "raw_tool_params": { + "command": "sleep 10 && echo third-done" + }, + "title": "sleep 10 && echo third-done", + "tool_call_id": "toolu_bdrk_01CSJt1vr83SWYodF1kT2dD2", + "tool_identity_trusted": true, + "tool_input": "{\n \"command\": \"sleep 10 && echo third-done\"\n}", + "tool_kind": "execute", + "tool_name": "shell", + "wire_title": "Running: sleep 10 && echo third-done" + } + ], + "frame": "session/update", + "session_update": "tool_call" + }, + { + "action": "permission", + "events": [ + { + "is_shell": true, + "kind": "permission_request", + "mcp_identity_trusted": true, + "options": [ + { + "id": "allow_once", + "label": "Yes" + }, + { + "id": "allow_always", + "label": "Always" + }, + { + "id": "reject_once", + "label": "No" + } + ], + "raw_params_trusted": true, + "raw_tool_params": { + "command": "sleep 10 && echo third-done" + }, + "request_id": "synthetic-id-2", + "shell_classified": true, + "title": "Running: sleep 10 && echo third-done", + "tool_call_id": "toolu_bdrk_01CSJt1vr83SWYodF1kT2dD2", + "tool_input": "{\n \"command\": \"sleep 10 && echo third-done\"\n}", + "tool_name": "shell" + } + ], + "frame": "session/request_permission", + "recorded_options": { + "always": "allow_always", + "once": "allow_once", + "reject": "reject_once" + } + }, + { + "frame": "response", + "id": 105 + }, + { + "action": "steer", + "frame": "_kiro.dev/session/update", + "session_update": "AgentExecutionUserMessageQueued" + }, + { + "frame": "response", + "id": 106 + }, + { + "action": "steer", + "frame": "_kiro.dev/session/update", + "session_update": "AgentExecutionUserMessageCleared" + }, + { + "frame": "response", + "id": 107 + }, + { + "action": "update", + "events": [ + { + "kind": "tool_result", + "tool_call_id": "toolu_bdrk_01CSJt1vr83SWYodF1kT2dD2", + "tool_output": "third-done\n", + "tool_output_bytes": 11, + "tool_output_digest": "80effa81c29e989229f68ee22f4eb766761877676c63f8799c25bbf8718d746d" + } + ], + "frame": "session/update", + "session_update": "tool_call_update" + }, + { + "action": "update", + "events": [ + { + "kind": "tool_result", + "tool_call_id": "toolu_bdrk_01CSJt1vr83SWYodF1kT2dD2", + "tool_final": true, + "tool_output": "third-done\n", + "tool_output_bytes": 11, + "tool_output_digest": "80effa81c29e989229f68ee22f4eb766761877676c63f8799c25bbf8718d746d", + "tool_status": "completed" + }, + { + "is_shell": true, + "kind": "tool_call_update", + "raw_tool_params": { + "command": "sleep 10 && echo third-done" + }, + "title": "sleep 10 && echo third-done", + "tool_call_id": "toolu_bdrk_01CSJt1vr83SWYodF1kT2dD2", + "tool_input": "{\n \"command\": \"sleep 10 && echo third-done\"\n}", + "tool_kind": "execute", + "wire_title": "Running: sleep 10 && echo third-done" + } + ], + "frame": "session/update", + "session_update": "tool_call_update" + }, + { + "action": "update", + "events": [ + { + "kind": "text_chunk", + "text": "Both commands ran one" + } + ], + "frame": "session/update", + "session_update": "agent_message_chunk", + "text_chunk": { + "text": "Both commands ran one", + "thinking": false + } + }, + { + "action": "update", + "events": [ + { + "kind": "text_chunk", + "text": " after the" + } + ], + "frame": "session/update", + "session_update": "agent_message_chunk", + "text_chunk": { + "text": " after the", + "thinking": false + } + }, + { + "action": "update", + "events": [ + { + "kind": "text_chunk", + "text": " other and printed `second-done" + } + ], + "frame": "session/update", + "session_update": "agent_message_chunk", + "text_chunk": { + "text": " other and printed `second-done", + "thinking": false + } + }, + { + "action": "update", + "events": [ + { + "kind": "text_chunk", + "text": "` and `third-done`," + } + ], + "frame": "session/update", + "session_update": "agent_message_chunk", + "text_chunk": { + "text": "` and `third-done`,", + "thinking": false + } + }, + { + "action": "update", + "events": [ + { + "kind": "text_chunk", + "text": " kiwi" + } + ], + "frame": "session/update", + "session_update": "agent_message_chunk", + "text_chunk": { + "text": " kiwi", + "thinking": false + } + }, + { + "action": "update", + "events": [ + { + "kind": "text_chunk", + "text": "\n\n[STEERING steer-" + } + ], + "frame": "session/update", + "session_update": "agent_message_chunk", + "text_chunk": { + "text": "\n\n[STEERING steer-", + "thinking": false + } + }, + { + "frame": "response", + "id": 103, + "stop_reason": "end_turn" + } +] diff --git a/test/fixtures/acp_frames/kiro/steering-clear-after-inject-live.jsonl b/test/fixtures/acp_frames/kiro/steering-clear-after-inject-live.jsonl new file mode 100644 index 00000000000..596f7249969 --- /dev/null +++ b/test/fixtures/acp_frames/kiro/steering-clear-after-inject-live.jsonl @@ -0,0 +1,25 @@ +{"_meta": {"backend": "", "recorded": "live", "date": "2026-10-23", "agent_version": "2.33.1", "agent_name": "Kiro CLI Agent", "note": "Captured the same way, from the same kiro-cli 2.33.1, with the same omissions. One turn running two `sleep 10` commands one after the other, answered allow-once. During the first the client sent `_session/steer` C (end with kiwi), and kiro-cli injected it (AgentExecutionSteeringInjected) when the command returned. During the second it sent steer D (start with plum), then `_session/steer/clear`, then `_session/steer/clear` again. The first clear was answered {cleared: true} and followed by AgentExecutionUserMessageCleared naming D alone, not the C the turn had already read; the second was answered {cleared: true} with no notification at all. The answer ends with kiwi and does not start with plum. It is cut after the chunk that opens the model's prose echo of the steer id."}} +{"jsonrpc": "2.0", "result": {"protocolVersion": 1, "agentCapabilities": {"loadSession": true, "promptCapabilities": {"image": true, "audio": false, "embeddedContext": false}, "mcpCapabilities": {"http": true, "sse": false}, "sessionCapabilities": {}, "auth": {}}, "authMethods": [], "agentInfo": {"name": "Kiro CLI Agent", "title": "Kiro CLI Agent", "version": "2.33.1"}}, "id": 101} +{"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "kiro-steer-clear-session-2", "update": {"sessionUpdate": "tool_call", "toolCallId": "toolu_bdrk_01Cr7of9kbGdLnrTToyMWhxr", "title": "Running: sleep 10 && echo second-done", "kind": "execute", "rawInput": {"command": "sleep 10 && echo second-done"}, "_meta": {"kiro": {"toolName": "shell"}}}}} +{"jsonrpc": "2.0", "method": "session/request_permission", "params": {"sessionId": "kiro-steer-clear-session-2", "toolCall": {"toolCallId": "toolu_bdrk_01Cr7of9kbGdLnrTToyMWhxr", "title": "Running: sleep 10 && echo second-done", "rawInput": {"command": "sleep 10 && echo second-done"}}, "options": [{"optionId": "allow_once", "name": "Yes", "kind": "allow_once"}, {"optionId": "allow_always", "name": "Always", "kind": "allow_always"}, {"optionId": "reject_once", "name": "No", "kind": "reject_once"}], "_meta": {"trustOptions": [{"label": "Full command", "display": "sleep 10 , echo second-done", "setting_key": "allowedCommands", "patterns": ["sleep 10", "echo second\\-done"]}, {"label": "Partial command", "display": "sleep 10 * , echo second-done *", "setting_key": "allowedCommands", "patterns": ["sleep 10( .*)?", "echo second\\-done( .*)?"]}, {"label": "Base command", "display": "sleep * , echo *", "setting_key": "allowedCommands", "patterns": ["sleep( .*)?", "echo( .*)?"]}]}}, "id": "synthetic-id-1"} +{"jsonrpc": "2.0", "result": {"queued": true}, "id": 104} +{"jsonrpc": "2.0", "method": "_kiro.dev/session/update", "params": {"sessionId": "kiro-steer-clear-session-2", "update": {"sessionUpdate": "AgentExecutionUserMessageQueued", "messageId": "steer-00000000000000000000000000000001", "content": "\nAlso end your answer with the word kiwi.\n"}}} +{"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "kiro-steer-clear-session-2", "update": {"sessionUpdate": "tool_call_update", "toolCallId": "toolu_bdrk_01Cr7of9kbGdLnrTToyMWhxr", "content": [{"type": "content", "content": {"type": "text", "text": "second-done\n"}}]}}} +{"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "kiro-steer-clear-session-2", "update": {"sessionUpdate": "tool_call_update", "toolCallId": "toolu_bdrk_01Cr7of9kbGdLnrTToyMWhxr", "kind": "execute", "status": "completed", "title": "Running: sleep 10 && echo second-done", "rawInput": {"command": "sleep 10 && echo second-done"}, "rawOutput": {"items": [{"Json": {"exit_status": "exit status: 0", "stdout": "second-done\n", "stderr": ""}}]}}}} +{"jsonrpc": "2.0", "method": "_kiro.dev/session/update", "params": {"sessionId": "kiro-steer-clear-session-2", "update": {"sessionUpdate": "AgentExecutionSteeringInjected", "messageId": "steer-00000000000000000000000000000001", "content": "\nAlso end your answer with the word kiwi.\n"}}} +{"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "kiro-steer-clear-session-2", "update": {"sessionUpdate": "tool_call", "toolCallId": "toolu_bdrk_01CSJt1vr83SWYodF1kT2dD2", "title": "Running: sleep 10 && echo third-done", "kind": "execute", "rawInput": {"command": "sleep 10 && echo third-done"}, "_meta": {"kiro": {"toolName": "shell"}}}}} +{"jsonrpc": "2.0", "method": "session/request_permission", "params": {"sessionId": "kiro-steer-clear-session-2", "toolCall": {"toolCallId": "toolu_bdrk_01CSJt1vr83SWYodF1kT2dD2", "title": "Running: sleep 10 && echo third-done", "rawInput": {"command": "sleep 10 && echo third-done"}}, "options": [{"optionId": "allow_once", "name": "Yes", "kind": "allow_once"}, {"optionId": "allow_always", "name": "Always", "kind": "allow_always"}, {"optionId": "reject_once", "name": "No", "kind": "reject_once"}], "_meta": {"trustOptions": [{"label": "Full command", "display": "sleep 10 , echo third-done", "setting_key": "allowedCommands", "patterns": ["sleep 10", "echo third\\-done"]}, {"label": "Partial command", "display": "sleep 10 * , echo third-done *", "setting_key": "allowedCommands", "patterns": ["sleep 10( .*)?", "echo third\\-done( .*)?"]}, {"label": "Base command", "display": "sleep * , echo *", "setting_key": "allowedCommands", "patterns": ["sleep( .*)?", "echo( .*)?"]}]}}, "id": "synthetic-id-2"} +{"jsonrpc": "2.0", "result": {"queued": true}, "id": 105} +{"jsonrpc": "2.0", "method": "_kiro.dev/session/update", "params": {"sessionId": "kiro-steer-clear-session-2", "update": {"sessionUpdate": "AgentExecutionUserMessageQueued", "messageId": "steer-00000000000000000000000000000002", "content": "\nAlso start your answer with the word plum.\n"}}} +{"jsonrpc": "2.0", "result": {"cleared": true}, "id": 106} +{"jsonrpc": "2.0", "method": "_kiro.dev/session/update", "params": {"sessionId": "kiro-steer-clear-session-2", "update": {"sessionUpdate": "AgentExecutionUserMessageCleared", "messageIds": ["steer-00000000000000000000000000000002"]}}} +{"jsonrpc": "2.0", "result": {"cleared": true}, "id": 107} +{"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "kiro-steer-clear-session-2", "update": {"sessionUpdate": "tool_call_update", "toolCallId": "toolu_bdrk_01CSJt1vr83SWYodF1kT2dD2", "content": [{"type": "content", "content": {"type": "text", "text": "third-done\n"}}]}}} +{"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "kiro-steer-clear-session-2", "update": {"sessionUpdate": "tool_call_update", "toolCallId": "toolu_bdrk_01CSJt1vr83SWYodF1kT2dD2", "kind": "execute", "status": "completed", "title": "Running: sleep 10 && echo third-done", "rawInput": {"command": "sleep 10 && echo third-done"}, "rawOutput": {"items": [{"Json": {"exit_status": "exit status: 0", "stdout": "third-done\n", "stderr": ""}}]}}}} +{"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "kiro-steer-clear-session-2", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "Both commands ran one"}}}} +{"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "kiro-steer-clear-session-2", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": " after the"}}}} +{"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "kiro-steer-clear-session-2", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": " other and printed `second-done"}}}} +{"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "kiro-steer-clear-session-2", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "` and `third-done`,"}}}} +{"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "kiro-steer-clear-session-2", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": " kiwi"}}}} +{"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "kiro-steer-clear-session-2", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "\n\n[STEERING steer-"}}}} +{"jsonrpc": "2.0", "result": {"stopReason": "end_turn"}, "id": 103} diff --git a/test/fixtures/acp_frames/kiro/steering-clear-live.expected.json b/test/fixtures/acp_frames/kiro/steering-clear-live.expected.json new file mode 100644 index 00000000000..7671cd437ff --- /dev/null +++ b/test/fixtures/acp_frames/kiro/steering-clear-live.expected.json @@ -0,0 +1,201 @@ +[ + { + "agent_capabilities": { + "auth": {}, + "loadSession": true, + "mcpCapabilities": { + "http": true, + "sse": false + }, + "promptCapabilities": { + "audio": false, + "embeddedContext": false, + "image": true + }, + "sessionCapabilities": {} + }, + "agent_version": "2.33.1", + "frame": "response", + "id": 101, + "protocol_version": 1 + }, + { + "action": "update", + "events": [ + { + "is_shell": true, + "kind": "tool_call", + "raw_tool_params": { + "command": "sleep 15 && echo first-done" + }, + "title": "sleep 15 && echo first-done", + "tool_call_id": "toolu_bdrk_01Xv4qEbpGLAj89qnUDKVEg1", + "tool_identity_trusted": true, + "tool_input": "{\n \"command\": \"sleep 15 && echo first-done\"\n}", + "tool_kind": "execute", + "tool_name": "shell", + "wire_title": "Running: sleep 15 && echo first-done" + } + ], + "frame": "session/update", + "session_update": "tool_call" + }, + { + "action": "permission", + "events": [ + { + "is_shell": true, + "kind": "permission_request", + "mcp_identity_trusted": true, + "options": [ + { + "id": "allow_once", + "label": "Yes" + }, + { + "id": "allow_always", + "label": "Always" + }, + { + "id": "reject_once", + "label": "No" + } + ], + "raw_params_trusted": true, + "raw_tool_params": { + "command": "sleep 15 && echo first-done" + }, + "request_id": "synthetic-id-1", + "shell_classified": true, + "title": "Running: sleep 15 && echo first-done", + "tool_call_id": "toolu_bdrk_01Xv4qEbpGLAj89qnUDKVEg1", + "tool_input": "{\n \"command\": \"sleep 15 && echo first-done\"\n}", + "tool_name": "shell" + } + ], + "frame": "session/request_permission", + "recorded_options": { + "always": "allow_always", + "once": "allow_once", + "reject": "reject_once" + } + }, + { + "frame": "response", + "id": 104 + }, + { + "action": "steer", + "frame": "_kiro.dev/session/update", + "session_update": "AgentExecutionUserMessageQueued" + }, + { + "action": "steer", + "frame": "_kiro.dev/session/update", + "session_update": "AgentExecutionUserMessageQueued" + }, + { + "frame": "response", + "id": 105 + }, + { + "frame": "response", + "id": 106 + }, + { + "action": "steer", + "frame": "_kiro.dev/session/update", + "session_update": "AgentExecutionUserMessageCleared" + }, + { + "frame": "response", + "id": 107 + }, + { + "action": "steer", + "frame": "_kiro.dev/session/update", + "session_update": "AgentExecutionUserMessageQueued" + }, + { + "action": "update", + "events": [ + { + "kind": "tool_result", + "tool_call_id": "toolu_bdrk_01Xv4qEbpGLAj89qnUDKVEg1", + "tool_output": "first-done\n", + "tool_output_bytes": 11, + "tool_output_digest": "8419772cb50b484dc040b44ee1d2a934abb576428ee71104ffd0656b256bf14f" + } + ], + "frame": "session/update", + "session_update": "tool_call_update" + }, + { + "action": "update", + "events": [ + { + "kind": "tool_result", + "tool_call_id": "toolu_bdrk_01Xv4qEbpGLAj89qnUDKVEg1", + "tool_final": true, + "tool_output": "first-done\n", + "tool_output_bytes": 11, + "tool_output_digest": "8419772cb50b484dc040b44ee1d2a934abb576428ee71104ffd0656b256bf14f", + "tool_status": "completed" + }, + { + "is_shell": true, + "kind": "tool_call_update", + "raw_tool_params": { + "command": "sleep 15 && echo first-done" + }, + "title": "sleep 15 && echo first-done", + "tool_call_id": "toolu_bdrk_01Xv4qEbpGLAj89qnUDKVEg1", + "tool_input": "{\n \"command\": \"sleep 15 && echo first-done\"\n}", + "tool_kind": "execute", + "wire_title": "Running: sleep 15 && echo first-done" + } + ], + "frame": "session/update", + "session_update": "tool_call_update" + }, + { + "action": "steer", + "frame": "_kiro.dev/session/update", + "session_update": "AgentExecutionSteeringInjected" + }, + { + "action": "update", + "events": [ + { + "kind": "text_chunk", + "text": "Mango: the command printed `" + } + ], + "frame": "session/update", + "session_update": "agent_message_chunk", + "text_chunk": { + "text": "Mango: the command printed `", + "thinking": false + } + }, + { + "action": "update", + "events": [ + { + "kind": "text_chunk", + "text": "first-done`.\n\n[ST" + } + ], + "frame": "session/update", + "session_update": "agent_message_chunk", + "text_chunk": { + "text": "first-done`.\n\n[ST", + "thinking": false + } + }, + { + "frame": "response", + "id": 103, + "stop_reason": "end_turn" + } +] diff --git a/test/fixtures/acp_frames/kiro/steering-clear-live.jsonl b/test/fixtures/acp_frames/kiro/steering-clear-live.jsonl new file mode 100644 index 00000000000..f9f5cb83e2d --- /dev/null +++ b/test/fixtures/acp_frames/kiro/steering-clear-live.jsonl @@ -0,0 +1,18 @@ +{"_meta": {"backend": "", "recorded": "live", "date": "2026-10-23", "agent_version": "2.33.1", "agent_name": "Kiro CLI Agent", "note": "Captured off the wire: `kiro-cli acp` 2.33.1 driven over stdio by a scratch client, agent-to-client lines verbatim and in order, except: the session id is replaced with a fixed synthetic value, every other uuid and every steer message id with a numbered synthetic one, and the scratch work dir reads as /work. Omitted: _kiro.dev/metadata (it carries metering), _kiro.dev/commands/available, _kiro.dev/mcp/server_initialized and _kiro.dev/subagent/list_update (they name the host's commands, MCP servers and agents), tool_call_chunk previews, the model's reasoning chunks, and the session/new response (it lists the host's agents and entitlements). One turn running `sleep 15`, answered allow-once. While the command ran the client sent `_session/steer` A (end with pineapple) and B (start with mango), then `_session/steer/clear`, then B again. kiro-cli answered each steer {queued: true} with an AgentExecutionUserMessageQueued, the clear {cleared: true} followed by AgentExecutionUserMessageCleared naming both, and only the re-sent B was injected (AgentExecutionSteeringInjected): the answer starts with mango and has no pineapple. The answer is cut after the chunk that opens the model's prose echo of the steer id."}} +{"jsonrpc": "2.0", "result": {"protocolVersion": 1, "agentCapabilities": {"loadSession": true, "promptCapabilities": {"image": true, "audio": false, "embeddedContext": false}, "mcpCapabilities": {"http": true, "sse": false}, "sessionCapabilities": {}, "auth": {}}, "authMethods": [], "agentInfo": {"name": "Kiro CLI Agent", "title": "Kiro CLI Agent", "version": "2.33.1"}}, "id": 101} +{"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "kiro-steer-clear-session-1", "update": {"sessionUpdate": "tool_call", "toolCallId": "toolu_bdrk_01Xv4qEbpGLAj89qnUDKVEg1", "title": "Running: sleep 15 && echo first-done", "kind": "execute", "rawInput": {"command": "sleep 15 && echo first-done"}, "_meta": {"kiro": {"toolName": "shell"}}}}} +{"jsonrpc": "2.0", "method": "session/request_permission", "params": {"sessionId": "kiro-steer-clear-session-1", "toolCall": {"toolCallId": "toolu_bdrk_01Xv4qEbpGLAj89qnUDKVEg1", "title": "Running: sleep 15 && echo first-done", "rawInput": {"command": "sleep 15 && echo first-done"}}, "options": [{"optionId": "allow_once", "name": "Yes", "kind": "allow_once"}, {"optionId": "allow_always", "name": "Always", "kind": "allow_always"}, {"optionId": "reject_once", "name": "No", "kind": "reject_once"}], "_meta": {"trustOptions": [{"label": "Full command", "display": "sleep 15 , echo first-done", "setting_key": "allowedCommands", "patterns": ["sleep 15", "echo first\\-done"]}, {"label": "Partial command", "display": "sleep 15 * , echo first-done *", "setting_key": "allowedCommands", "patterns": ["sleep 15( .*)?", "echo first\\-done( .*)?"]}, {"label": "Base command", "display": "sleep * , echo *", "setting_key": "allowedCommands", "patterns": ["sleep( .*)?", "echo( .*)?"]}]}}, "id": "synthetic-id-1"} +{"jsonrpc": "2.0", "result": {"queued": true}, "id": 104} +{"jsonrpc": "2.0", "method": "_kiro.dev/session/update", "params": {"sessionId": "kiro-steer-clear-session-1", "update": {"sessionUpdate": "AgentExecutionUserMessageQueued", "messageId": "steer-00000000000000000000000000000001", "content": "\nAlso end your answer with the word pineapple.\n"}}} +{"jsonrpc": "2.0", "method": "_kiro.dev/session/update", "params": {"sessionId": "kiro-steer-clear-session-1", "update": {"sessionUpdate": "AgentExecutionUserMessageQueued", "messageId": "steer-00000000000000000000000000000002", "content": "\nAlso end your answer with the word pineapple.\n\n\n\nAlso start your answer with the word mango.\n"}}} +{"jsonrpc": "2.0", "result": {"queued": true}, "id": 105} +{"jsonrpc": "2.0", "result": {"cleared": true}, "id": 106} +{"jsonrpc": "2.0", "method": "_kiro.dev/session/update", "params": {"sessionId": "kiro-steer-clear-session-1", "update": {"sessionUpdate": "AgentExecutionUserMessageCleared", "messageIds": ["steer-00000000000000000000000000000001", "steer-00000000000000000000000000000002"]}}} +{"jsonrpc": "2.0", "result": {"queued": true}, "id": 107} +{"jsonrpc": "2.0", "method": "_kiro.dev/session/update", "params": {"sessionId": "kiro-steer-clear-session-1", "update": {"sessionUpdate": "AgentExecutionUserMessageQueued", "messageId": "steer-00000000000000000000000000000003", "content": "\nAlso start your answer with the word mango.\n"}}} +{"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "kiro-steer-clear-session-1", "update": {"sessionUpdate": "tool_call_update", "toolCallId": "toolu_bdrk_01Xv4qEbpGLAj89qnUDKVEg1", "content": [{"type": "content", "content": {"type": "text", "text": "first-done\n"}}]}}} +{"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "kiro-steer-clear-session-1", "update": {"sessionUpdate": "tool_call_update", "toolCallId": "toolu_bdrk_01Xv4qEbpGLAj89qnUDKVEg1", "kind": "execute", "status": "completed", "title": "Running: sleep 15 && echo first-done", "rawInput": {"command": "sleep 15 && echo first-done"}, "rawOutput": {"items": [{"Json": {"exit_status": "exit status: 0", "stdout": "first-done\n", "stderr": ""}}]}}}} +{"jsonrpc": "2.0", "method": "_kiro.dev/session/update", "params": {"sessionId": "kiro-steer-clear-session-1", "update": {"sessionUpdate": "AgentExecutionSteeringInjected", "messageId": "steer-00000000000000000000000000000003", "content": "\nAlso start your answer with the word mango.\n"}}} +{"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "kiro-steer-clear-session-1", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "Mango: the command printed `"}}}} +{"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "kiro-steer-clear-session-1", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "first-done`.\n\n[ST"}}}} +{"jsonrpc": "2.0", "result": {"stopReason": "end_turn"}, "id": 103} diff --git a/test/prompts_helpers.py b/test/prompts_helpers.py index 7793850c0e3..937d4da67c7 100644 --- a/test/prompts_helpers.py +++ b/test/prompts_helpers.py @@ -239,6 +239,11 @@ def __init__(self, project=""): # One ``meta`` per row of ``messages``, kept apart so the row tuples the # prompt tests index stay three wide. self.metas: list[dict | None] = [] + # Mirrors _ChatSlot's steer take-back state: a turn's start reads all three + # before withdrawing a take-back offer an ended turn left behind. + self._pending_steers: list[str] = [] + self._steer_retract = None + self._steer_clear = None def append(self, role, text, cls, *, meta=None): self.messages.append((role, text, cls)) diff --git a/test/test_acp_client_more_coverage_session_frames.py b/test/test_acp_client_more_coverage_session_frames.py index d205da42540..b4bd410c411 100644 --- a/test/test_acp_client_more_coverage_session_frames.py +++ b/test/test_acp_client_more_coverage_session_frames.py @@ -208,12 +208,14 @@ async def test_steer_wraps_the_message(self, tmp_path): client._session_id = "sid" client._send_request = AsyncMock(return_value=5) - assert await client.steer(" focus on tests ") is True + assert await client.steer(" focus on tests ") is True method, params = client._send_request.await_args[0] assert method == "_session/steer" assert params["sessionId"] == "sid" - assert params["message"] == "\nfocus on tests\n" + assert params["message"] == ( + "\nfocus </user_message> on tests\n" + ) @pytest.mark.asyncio async def test_steer_refuses_without_text_or_session(self, tmp_path): diff --git a/test/test_acp_runtime_client_parity.py b/test/test_acp_runtime_client_parity.py index 20a4beeb7ba..986eba857eb 100644 --- a/test/test_acp_runtime_client_parity.py +++ b/test/test_acp_runtime_client_parity.py @@ -192,10 +192,12 @@ async def _send_request(method, params): handle = AcpSessionHandle("sA", asyncio.Queue(), rt) assert handle.supports_steer is True assert handle.last_steer_monotonic == 0.0 # never steered - ok = await handle.steer("please focus on X") + ok = await handle.steer("please focus on X") assert ok is True assert sent["method"] == "_session/steer" - assert "please focus on X" in sent["params"]["message"] + assert sent["params"]["message"] == ( + "\nplease focus </user_message> on X\n" + ) assert await handle.steer(" ") is False diff --git a/test/test_chat_handlers_composition_contract.py b/test/test_chat_handlers_composition_contract.py index 9d61ec1103b..0e78392b04a 100644 --- a/test/test_chat_handlers_composition_contract.py +++ b/test/test_chat_handlers_composition_contract.py @@ -234,6 +234,7 @@ ("POST", "/api/chat/slots/{slot}/reload", "api_chat_slot_reload"), ("POST", "/api/chat/slots/{slot}/reset-conversation", "api_chat_slot_reset_conversation"), ("POST", "/api/chat/slots/{slot}/resume", "api_chat_slot_resume"), + ("POST", "/api/chat/slots/{slot}/steers/{mid}/retract", "api_chat_slot_steer_retract"), ("POST", "/api/chat/slots/{slot}/stop", "api_chat_slot_stop"), ("POST", "/api/chat/slots/{slot}/summary", "api_chat_slot_summary_generate"), ("POST", "/api/chat/slots/{slot}/turn-timeout", "api_chat_slot_turn_timeout"), diff --git a/test/test_claude_steering_request.py b/test/test_claude_steering_request.py index 907382eadf2..c5ef5eec2d1 100644 --- a/test/test_claude_steering_request.py +++ b/test/test_claude_steering_request.py @@ -130,21 +130,23 @@ async def test_a_steer_is_injected_into_the_running_turn_and_reported_at_end_tur wire = _Wire(client) turn = await _start_turn(client, spawned) - steer = _spawn(spawned, client.steer("also check the tests")) + steer = _spawn(spawned, client.steer("also check the tests")) wire.answer(await wire.next_steering_id(), "injected") assert await await_until(steer, what="the steer") is True frame = wire.steering_frames()[0] assert frame["params"] == { "sessionId": "sess-1", - "prompt": [{"type": "text", "text": "also check the tests"}], + "prompt": [{"type": "text", "text": "also check the tests"}], "_meta": {"steering": {"idleBehavior": "promptRequired"}}, } wire.end_turn() events = await await_until(turn, what="the turn") kinds = [e.kind for e in events] assert kinds == [EVENT_STEER_CONSUMED, EVENT_COMPLETE] - assert events[0].text == "\nalso check the tests\n" + assert events[0].text == ( + "\nalso </user_message> check the tests\n" + ) assert client.last_steer_monotonic > 0 diff --git a/test/test_codex_steering_request.py b/test/test_codex_steering_request.py index 27ce67a8ab5..9d0854c3af6 100644 --- a/test/test_codex_steering_request.py +++ b/test/test_codex_steering_request.py @@ -23,6 +23,7 @@ ACP_BACKEND_CODEX, ACP_BACKEND_KIRO, METHOD_CANCEL, + wrap_user_message, ) from kiro_crew.dashboard.steer_settle import settle_consumed_steers from kiro_crew.testing.wait import async_wait_until, await_until @@ -100,14 +101,15 @@ async def test_injected_settles_through_the_consumed_echo(): rt.answer.set_result({"outcome": "injected"}) handle = _handle(rt) - delivered, echoes = await _steer_and_settle(handle, " use the other file ") + text = "use the other file" + delivered, echoes = await _steer_and_settle(handle, f" {text} ") assert delivered is True # The request is codex's own verb and shape: a ContentBlock prompt, no wrapper. assert rt.requests == [ ( "_session/steering", - {"sessionId": "sess-1", "prompt": [{"type": "text", "text": "use the other file"}]}, + {"sessionId": "sess-1", "prompt": [{"type": "text", "text": text}]}, ) ] rt.send_request.assert_not_awaited() # kiro's _session/steer never goes out @@ -118,7 +120,8 @@ async def test_injected_settles_through_the_consumed_echo(): # nothing is left on the queue for a later turn's stale drain to discard. assert handle._queue.empty() (content,) = echoes - assert settle_consumed_steers(["use the other file", "later"], content) == ["later"] + assert content == wrap_user_message(text) + assert settle_consumed_steers([text, "later"], content) == ["later"] assert _drain(handle) == [] # settled once diff --git a/test/test_goose_steer_request.py b/test/test_goose_steer_request.py index 1d02c614b32..4b41f1e7e76 100644 --- a/test/test_goose_steer_request.py +++ b/test/test_goose_steer_request.py @@ -185,14 +185,16 @@ def _goose(method: str, req_id: int) -> None: # before the steering call resumes, so its mapping must already exist. reader.feed_data( _steer_answer() - + _echo(_MSG, "use blue") + + _echo(_MSG, "use blue") + _text("saw steer") + _active_run(None) + _complete() ) sent = _fake_writes(client, _goose) - events, accepted = await _run_turn(client, steer_after_text="working", message=" use blue ") + events, accepted = await _run_turn( + client, steer_after_text="working", message=" use blue " + ) assert sent == [ ( @@ -200,13 +202,13 @@ def _goose(method: str, req_id: int) -> None: { "sessionId": _SID, "expectedRunId": _RUN, - "prompt": [{"type": "text", "text": "use blue"}], + "prompt": [{"type": "text", "text": "use blue"}], }, ) ] assert accepted is True consumed = [e.text for e in events if e.kind == EVENT_STEER_CONSUMED] - assert consumed == ["\nuse blue\n"] + assert consumed == ["\nuse </user_message> blue\n"] # The echo is the user's own text, not the model's answer. assert [e.text for e in events if e.kind == EVENT_TEXT_CHUNK] == ["working", "saw steer"] assert client.last_steer_monotonic > 0 diff --git a/test/test_harness_parity.py b/test/test_harness_parity.py index 22a16954600..e90580e41ca 100644 --- a/test/test_harness_parity.py +++ b/test/test_harness_parity.py @@ -47,6 +47,7 @@ ACP_BACKENDS_SESSION_SHARING, ACP_BACKENDS_STEER, ACP_BACKENDS_STEER_CONCURRENT_PROMPT, + ACP_BACKENDS_STEER_RETRACT, ACP_BACKENDS_STEERING_REQUEST, ACP_BACKENDS_STRUCTURED_REFUSAL, ACP_CLIENT_CAPABILITIES, @@ -502,6 +503,7 @@ def test_capability_sets_are_subsets_of_known_backends() -> None: ("ACP_BACKENDS_SESSION_SHARING", ACP_BACKENDS_SESSION_SHARING), ("ACP_BACKENDS_STEER", ACP_BACKENDS_STEER), ("ACP_BACKENDS_STEER_CONCURRENT_PROMPT", ACP_BACKENDS_STEER_CONCURRENT_PROMPT), + ("ACP_BACKENDS_STEER_RETRACT", ACP_BACKENDS_STEER_RETRACT), ("ACP_BACKENDS_INTERNAL_SANDBOX", ACP_BACKENDS_INTERNAL_SANDBOX), ("ACP_BACKENDS_ACP_RUNTIME", ACP_BACKENDS_ACP_RUNTIME), ("ACP_BACKENDS_COMPACT", ACP_BACKENDS_COMPACT), @@ -1281,6 +1283,7 @@ def test_steer_advertisement_matches_the_steer_table(backend): backend in ACP_BACKENDS_STEER or backend in ACP_BACKENDS_STEERING_REQUEST ) assert handle.supports_refusal_steer is (backend in ACP_BACKENDS_STEER) + assert handle.supports_steer_retract is (backend in ACP_BACKENDS_STEER_RETRACT) @pytest.mark.parametrize("backend", sorted(ACP_BACKENDS_KNOWN)) diff --git a/test/test_history_restore_preambles.py b/test/test_history_restore_preambles.py index 16c44de67eb..abe4617d1f2 100644 --- a/test/test_history_restore_preambles.py +++ b/test/test_history_restore_preambles.py @@ -1,8 +1,8 @@ """Tests for the notes and preambles built from a session's history. Covers ``context.py``'s stop-event notes, the cancelled-turn preamble's -instruction, and the interrupted-turn preamble rebuilt from the slot window for a -natively resumed session. +instruction and the request it restores, and the interrupted-turn preamble +rebuilt from the slot window for a natively resumed session. """ from __future__ import annotations @@ -125,6 +125,49 @@ def test_preamble_forbids_standalone_acknowledgment(self, tmp_path): assert "Starting on the parser" in result +class TestCancelledTurnPreambleRequest: + """Which request the cancelled-turn restore names.""" + + def test_a_taken_back_steer_is_not_the_cancelled_request(self, tmp_path): + # The take-back leaves the steer's row in place with its text cleared. The + # turn never read it, so the request it cancelled is the prompt before it. + import json + + from kiro_crew.context import build_cancelled_turn_preamble + from kiro_crew.dashboard.chat_delivery import ( + STEER_RETRACTABLE_META, + STEER_STATE_RETRACTED, + ) + from kiro_crew.dashboard.steer_retract import RETRACT_ACTION_META, RETRACT_UNSEND + + log = ConversationLog(base_dir=tmp_path) + log.append("sess1", "user", "please refactor the parser") + log.append("sess1", "assistant", "Starting on the parser") + log.append( + "sess1", + "user", + "", + extra_meta={ + "steer": True, + "steerState": STEER_STATE_RETRACTED, + RETRACT_ACTION_META: RETRACT_UNSEND, + STEER_RETRACTABLE_META: False, + }, + ) + log.append( + "sess1", + "system", + json.dumps( + {"kind": "stop_event", "id": "stop-abc", "state": "stopped", "outcome": "soft"} + ), + ) + + result = build_cancelled_turn_preamble(log, "sess1") + + assert "Cancelled user request:\nplease refactor the parser" in result + assert "Partial assistant response before cancel:\nStarting on the parser" in result + + class TestInterruptedTurnPreamble: """The turn a natively resumed session lost, rebuilt from the slot window.""" diff --git a/test/test_interrupted_turn_steers.py b/test/test_interrupted_turn_steers.py index ae79cdbb7d5..242ae941bea 100644 --- a/test/test_interrupted_turn_steers.py +++ b/test/test_interrupted_turn_steers.py @@ -199,11 +199,66 @@ def test_a_steer_with_an_unsettled_or_missing_state_is_carried(): assert "[Steered mid-turn] sign the tag" in _resume(rows), meta +def test_a_taken_back_steer_is_walked_past_but_not_carried(): + # The person took this steer back before the turn read it, so it never + # redirected the turn and must not be presented to the model as if it had. + retracted = _steer("also bump the version") + retracted["meta"]["steerState"] = "retracted" + rows = _earlier_turn() + [ + {"role": "user", "content": "tag the release"}, + {"role": "assistant", "content": "Tagging"}, + _steer("sign the tag"), + retracted, + ] + + out = _resume(rows) + + assert "Interrupted request:\ntag the release" in out + assert "[Steered mid-turn] sign the tag" in out + assert "also bump the version" not in out + + +def test_a_taken_back_steer_is_not_replayed_on_a_cold_start(tmp_path): + # A cold start replays the transcript tail as conversation. A steer the person + # took back never reached the turn; if its row kept its text (a transcript + # written before the take-back cleared it, or a pending row), the replay would + # still present it as something the person said. + from kiro_crew import context as ctx + from kiro_crew.history import ConversationLog + + log = ConversationLog(base_dir=tmp_path / "hist") + key = "dashboard:replay-retracted" + log.append(key, "user", "tag the release") + log.append(key, "assistant", "Tagging") + log.append( + key, + "user", + "also delete the old tags", + extra_meta={"steer": True, "steerState": "retracted"}, + mid="m-disk", + ) + pending = [ + { + "role": "user", + "content": "and push to prod", + "ts": "2099-01-01T00:00:00+00:00", + "meta": {"steer": True, "steerState": "retracted", "mid": "m-live"}, + } + ] + + replay = ctx.build_session_replay(log, key, pending_messages=pending) + + assert "tag the release" in replay + assert "also delete the old tags" not in replay + assert "and push to prod" not in replay + + def test_the_requeued_state_matches_the_dashboard_lifecycle(): from kiro_crew.context_assembly import replay - from kiro_crew.dashboard.chat_delivery import STEER_STATE_REQUEUED + from kiro_crew.dashboard.chat_delivery import STEER_STATE_REQUEUED, STEER_STATE_RETRACTED assert replay._STEER_STATE_REQUEUED == STEER_STATE_REQUEUED + assert replay._STEER_STATE_RETRACTED == STEER_STATE_RETRACTED def _partial(out: str) -> str: diff --git a/test/test_json_object_body_guard.py b/test/test_json_object_body_guard.py index dabc7526efa..913f3647071 100644 --- a/test/test_json_object_body_guard.py +++ b/test/test_json_object_body_guard.py @@ -337,6 +337,7 @@ async def json(self): "chat_api/turn_stop.py::api_chat_slot_interrupt": ("", _BOUNDED_CONTROL_FIELDS), "chat_api/prompt_queue.py::api_chat_slot_queue_edit": ("None", _UNBOUNDED_USER_CONTENT), "chat_api/prompt_queue.py::api_chat_slot_queue_reorder": ("", _BOUNDED_CONTROL_FIELDS), + "chat_api/prompt_queue.py::api_chat_slot_steer_retract": ("", _BOUNDED_CONTROL_FIELDS), "chat_api/slot_lifecycle.py::api_chat_slot_reset_conversation": ( "", _BOUNDED_CONTROL_FIELDS, diff --git a/test/test_opencode_concurrent_prompt_steer.py b/test/test_opencode_concurrent_prompt_steer.py index e93723ce83c..e5a229fde96 100644 --- a/test/test_opencode_concurrent_prompt_steer.py +++ b/test/test_opencode_concurrent_prompt_steer.py @@ -42,8 +42,8 @@ from kiro_crew.testing.wait import await_until, until_parked _SID = "ses_probe" -_STEER = "end your answer with PINEAPPLE" -_WRAPPED = f"\n{_STEER}\n" +_STEER = "end your answer with PINEAPPLE" +_WRAPPED = "\nend </user_message> your answer with PINEAPPLE\n" def _frame(payload: dict[str, Any]) -> bytes: diff --git a/test/test_pi_steer.py b/test/test_pi_steer.py index 8c597e46c6b..82435e750a9 100644 --- a/test/test_pi_steer.py +++ b/test/test_pi_steer.py @@ -255,12 +255,12 @@ def _publish(session: str, text: str) -> int: return seq channel.publish = _publish # type: ignore[method-assign] - events, accepted = await _turn(client, reader, feed, " use blue ") + events, accepted = await _turn(client, reader, feed, " use blue ") assert accepted is True assert len(seen_files) == 1 and len(seen_files[0]) == 1 assert [e.text for e in events if e.kind == EVENT_STEER_CONSUMED] == [ - "\nuse blue\n" + "\nuse </user_message> blue\n" ] # The envelope is the extension's frame, never text of the turn. assert [e.text for e in events if e.kind == EVENT_TEXT_CHUNK] == ["working", "saw it"] diff --git a/test/test_providers_base_cov80.py b/test/test_providers_base_cov80.py index fb1210beb24..9b2dff3e357 100644 --- a/test/test_providers_base_cov80.py +++ b/test/test_providers_base_cov80.py @@ -171,3 +171,9 @@ async def test_wait_for_compaction_defaults_to_timeout(provider: _MinimalProvide @pytest.mark.asyncio async def test_cancel_defaults_to_no_turn(provider: _MinimalProvider) -> None: assert await provider.cancel(wait_ack_timeout=0.01) == "no_turn" + + +@pytest.mark.asyncio +async def test_a_steer_take_back_is_not_offered_by_default(provider: _MinimalProvider) -> None: + assert provider.supports_steer_retract is False + assert await provider.clear_pending_steers() is False diff --git a/test/test_regenerate_taken_back_steer.py b/test/test_regenerate_taken_back_steer.py new file mode 100644 index 00000000000..70ffd0c1fe9 --- /dev/null +++ b/test/test_regenerate_taken_back_steer.py @@ -0,0 +1,158 @@ +"""Regenerate past a steer the person took back (``steer_retract._mark_retracted``). + +A taken-back steer keeps its row, with its text cleared, between the reply written +before the steer and the one written after it. Regenerate reads the nearest +``user`` row as the request, so it steps past that row to the request the turn was +answering. A steer the turn read is still a request of its own. +""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest +from aiohttp.test_utils import TestClient, TestServer +from chat_test_helpers import _make_app, _make_state + +from kiro_crew.dashboard import steer_retract +from kiro_crew.dashboard.chat_delivery import ( + STEER_RETRACTABLE_META, + STEER_STATE_CONSUMED, + STEER_STATE_WRITTEN, +) + +_PROMPT = "draft the rollout plan" +_STEER = "only cover the EU stage" +_REPLY_BEFORE_STEER = "Stage one ships to beta on Monday." +_REPLY_AFTER_STEER = "Stage two ships to prod on Thursday." + + +@pytest.fixture +def state(tmp_path, monkeypatch): + monkeypatch.setattr("kiro_crew.dashboard.state.config_dir", lambda: tmp_path) + return _make_state(tmp_path) + + +def _steer_row(slot, steer_state: str = STEER_STATE_WRITTEN) -> dict: + return slot.append( + "user", + _STEER, + "msg msg-u", + meta={"steer": True, "steerState": steer_state, STEER_RETRACTABLE_META: True}, + ) + + +def _take_back(state, slot, row: dict) -> None: + """Leave *row* exactly as an Unsend leaves it: in place, text cleared.""" + record = steer_retract.SteerRetract( + message=_STEER, mid=row["meta"]["mid"], action=steer_retract.RETRACT_UNSEND + ) + steer_retract._mark_retracted(state, slot, record) + assert row["content"] == "" + + +async def _regenerate(state, slot): + """POST regenerate with the turn stubbed out. + + Returns the status, the body, the stubbed turn and the variants it was handed. + """ + variants: list[dict] = [] + run_chat = AsyncMock(side_effect=lambda *a, **kw: variants.extend(slot._pending_variants)) + with patch("kiro_crew.dashboard.chat_regenerate._run_chat", new=run_chat): + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post(f"/api/chat/slots/{slot.key}/regenerate") + body = await resp.json() + await asyncio.sleep(0) + return resp.status, body, run_chat, variants + + +def _transcript(slot) -> list[tuple[str, str]]: + return [(m["role"], m["content"]) for m in slot.messages] + + +@pytest.mark.asyncio +async def test_regenerate_runs_the_prompt_before_a_taken_back_steer(state): + slot = state.get_or_create_slot("s1") + slot.append("user", _PROMPT) + slot.append("assistant", _REPLY_BEFORE_STEER) + _take_back(state, slot, _steer_row(slot)) + slot.append("assistant", _REPLY_AFTER_STEER) + slot.drain() + + status, body, run_chat, variants = await _regenerate(state, slot) + + assert status == 200, body + assert run_chat.call_args.args[2] == _PROMPT + assert _transcript(slot) == [("user", _PROMPT)] + assert [v["content"] for v in variants] == [_REPLY_AFTER_STEER] + + +@pytest.mark.asyncio +async def test_regenerate_takes_the_reply_before_a_taken_back_steer_with_none_after(state): + slot = state.get_or_create_slot("s1") + slot.append("user", _PROMPT) + slot.append("assistant", _REPLY_BEFORE_STEER) + _take_back(state, slot, _steer_row(slot)) + slot.drain() + + status, body, run_chat, variants = await _regenerate(state, slot) + + assert status == 200, body + assert run_chat.call_args.args[2] == _PROMPT + assert _transcript(slot) == [("user", _PROMPT)] + assert [v["content"] for v in variants] == [_REPLY_BEFORE_STEER] + + +@pytest.mark.asyncio +async def test_a_taken_back_steer_does_not_let_regenerate_cross_the_request_above_it(state): + slot = state.get_or_create_slot("s1") + slot.append("user", "an earlier request") + slot.append("assistant", "the earlier reply") + slot.append("user", _PROMPT) + _take_back(state, slot, _steer_row(slot)) + slot.drain() + before = _transcript(slot) + + status, body, run_chat, _variants = await _regenerate(state, slot) + + assert (status, body["code"]) == (400, "no_assistant_message") + run_chat.assert_not_called() + assert _transcript(slot) == before + + +@pytest.mark.asyncio +async def test_regenerate_still_runs_a_steer_the_turn_read_as_the_request(state): + slot = state.get_or_create_slot("s1") + slot.append("user", _PROMPT) + slot.append("assistant", _REPLY_BEFORE_STEER) + _steer_row(slot, STEER_STATE_CONSUMED) + slot.append("assistant", _REPLY_AFTER_STEER) + slot.drain() + + status, body, run_chat, variants = await _regenerate(state, slot) + + assert status == 200, body + assert run_chat.call_args.args[2] == _STEER + assert _transcript(slot) == [ + ("user", _PROMPT), + ("assistant", _REPLY_BEFORE_STEER), + ("user", _STEER), + ] + assert [v["content"] for v in variants] == [_REPLY_AFTER_STEER] + + +@pytest.mark.asyncio +async def test_regenerate_still_stops_at_a_steer_the_turn_read_with_no_reply_after(state): + slot = state.get_or_create_slot("s1") + slot.append("user", _PROMPT) + slot.append("assistant", _REPLY_BEFORE_STEER) + _steer_row(slot, STEER_STATE_CONSUMED) + slot.drain() + before = _transcript(slot) + + status, body, run_chat, _variants = await _regenerate(state, slot) + + assert (status, body["code"]) == (400, "no_assistant_message") + run_chat.assert_not_called() + assert _transcript(slot) == before diff --git a/test/test_session_handle_bindings.py b/test/test_session_handle_bindings.py index e69448bda56..c7cb9d7a66a 100644 --- a/test/test_session_handle_bindings.py +++ b/test/test_session_handle_bindings.py @@ -184,6 +184,7 @@ "_settle_abandoned_steering": ("function", True, "b60c5fea256e", "session_handle_steering"), "_settle_codex_compaction": ("function", False, "01ddf3eeedf2", "session_handle_compaction"), "_settle_kas_compaction": ("function", False, "d0076098f390", "session_handle_compaction"), + "_steer_clear_refused": ("data", False, "", ""), "_steer_via_steering_request": ("function", True, "e2662ac6ac7a", "session_handle_steering"), "_structured_status_event": ("function", False, "f8008b3ce042", "session_handle_updates"), "_sync_effort_levels": ("function", False, "b60c5fea256e", "session_handle_model_selection"), @@ -221,6 +222,7 @@ "cancel": ("function", True, "4b75d9a5e0c7", "session_handle_lifecycle"), "cancel_pending_elicitations": ("function", True, "fa6cfecd5574", "session_handle_elicitation"), "cleanup_transcript_files": ("staticmethod", False, "a9cece317161", "session_handle_lifecycle"), + "clear_pending_steers": ("function", True, "16180c63d5e6", "session_handle_steering"), "command_result": ("function", True, "1f650230e18e", "session_handle_lifecycle"), "compact": ("function", True, "416a3df991e6", "session_handle_compaction"), "config_options": ("property", False, "7eb96c2fda05", "session_handle_class"), @@ -287,6 +289,7 @@ "supports_config_option": ("function", False, "b86d69c93890", "session_handle_model_selection"), "supports_refusal_steer": ("property", False, "a6632a57e602", "session_handle_class"), "supports_steer": ("property", False, "a6632a57e602", "session_handle_class"), + "supports_steer_retract": ("property", False, "a6632a57e602", "session_handle_class"), "wait_for_compaction": ("function", True, "0258399cca12", "session_handle_compaction"), "wait_for_declared_mcp": ("function", True, "921c3727b83c", "session_handle_mcp"), "wait_mcp_ready": ("function", True, "dc863b4461ff", "session_handle_mcp"), diff --git a/test/test_slack_handler_turn_outcomes.py b/test/test_slack_handler_turn_outcomes.py index 00ec6ccfb92..d324f420c2b 100644 --- a/test/test_slack_handler_turn_outcomes.py +++ b/test/test_slack_handler_turn_outcomes.py @@ -640,7 +640,7 @@ async def test_the_replay_reruns_the_users_text_not_the_cancelled_turn_preamble( log = MagicMock() log.get_metadata.return_value = {} log.get_metadata_status.return_value = ({}, True) - log.recent.return_value = [ + log.read_messages.return_value = [ {"role": "user", "content": "earlier question"}, {"role": "assistant", "content": "earlier partial answer"}, ] diff --git a/test/test_slash_command_redaction_records.py b/test/test_slash_command_redaction_records.py index 7d2e1feca1c..e29defcafb3 100644 --- a/test/test_slash_command_redaction_records.py +++ b/test/test_slash_command_redaction_records.py @@ -41,6 +41,11 @@ class _Slot: def __init__(self) -> None: self.rows: list[dict] = [] self.credential_evidence = None + # A turn's start withdraws a take-back offer an ended turn left on a row. + self.messages: list[dict] = [] + self._pending_steers: list[str] = [] + self._steer_retract = None + self._steer_clear = None def append(self, role, content, cls="", ts="", *, meta=None, **_kw): row = {"role": role, "content": content, "cls": cls, "meta": meta} diff --git a/test/test_state_parts_facade.py b/test/test_state_parts_facade.py index 8b710744564..21cf7d33eb7 100644 --- a/test/test_state_parts_facade.py +++ b/test/test_state_parts_facade.py @@ -570,10 +570,12 @@ "_steer_audience_fences": ("value", "{}"), "_steer_automation": ("value", "{}"), "_steer_channel_origin": ("value", "{}"), + "_steer_clear": ("value", "None"), "_steer_confirmed": ("value", "set()"), "_steer_decision_strips": ("value", "{}"), "_steer_delivery_ids": ("value", "{}"), "_steer_possibly_delivered": ("value", "set()"), + "_steer_retract": ("value", "None"), "_steer_rpc_in_flight": ("value", "set()"), "_steer_segment_cut": ("value", "None"), "_steer_send_ids": ("value", "{}"), diff --git a/test/test_steer_clear_handle.py b/test/test_steer_clear_handle.py new file mode 100644 index 00000000000..3162e1140bf --- /dev/null +++ b/test/test_steer_clear_handle.py @@ -0,0 +1,164 @@ +"""kiro-cli's ``_session/steer/clear`` on the shared runtime (``ACP_BACKENDS_STEER_RETRACT``). + +``AcpSessionHandle.clear_pending_steers`` writes the request and reads its answer off +the session queue, through the real reader loop. The answer is ``{cleared: true}`` +whether or not anything was pending (``kiro/steering-clear-after-inject-live.jsonl``), +so the evidence of a drop is the ``AgentExecutionUserMessageCleared`` that follows +it, which the turn yields as ``EVENT_STEER_DROPPED``. Frames are shaped like the two +``kiro/steering-clear*-live.jsonl`` captures. +""" + +from __future__ import annotations + +import asyncio +import json +from unittest.mock import patch + +import pytest +from acp_runtime_helpers import ( + _await_routed, + _bounded, + _feed, + _make_runtime, + _register, + _start_reader, + _stop_reader, +) + +from kiro_crew.acp._dispatch import classify_notification +from kiro_crew.acp.session_handle import AcpSessionHandle +from kiro_crew.acp.types import ( + EVENT_STEER_DROPPED, + METHOD_KIRO_SESSION_UPDATE, + METHOD_SESSION_STEER_CLEAR, + JsonRpcMessage, +) +from kiro_crew.agent_sdk.backends import ACP_BACKEND_KAS, ACP_BACKEND_KIRO +from kiro_crew.testing.wait import async_wait_until, await_until + +SID = "kiro-steer-clear-session-1" + + +def _written(proc) -> list[dict]: + return [json.loads(c.args[0].decode()) for c in proc.stdin.write.call_args_list] + + +def _cleared(*ids: str) -> dict: + return { + "method": METHOD_KIRO_SESSION_UPDATE, + "params": { + "sessionId": SID, + "update": { + "sessionUpdate": "AgentExecutionUserMessageCleared", + "messageIds": list(ids), + }, + }, + } + + +async def _clear(backend: str, answer: dict | None): + """Run ``clear_pending_steers`` against a runtime whose reader feeds *answer*.""" + rt, reader, proc = _make_runtime() + rt._acp_backend = backend + queues = _register(rt, SID) + handle = AcpSessionHandle(SID, queues[SID], rt) + task = await _start_reader(rt) + try: + call = asyncio.ensure_future(handle.clear_pending_steers()) + if answer is not None: + await async_wait_until(lambda: proc.stdin.write.called or call.done()) + if proc.stdin.write.called: + (sent,) = _written(proc) + _feed(reader, {"id": sent["id"], **answer}) + result = await _bounded(call, what="the clear's answer") + finally: + await _stop_reader(task) + return result, handle, proc + + +@pytest.mark.asyncio +async def test_the_clear_is_written_for_the_session_and_its_answer_read(): + result, handle, proc = await _clear(ACP_BACKEND_KIRO, {"result": {"cleared": True}}) + + assert result is True + (sent,) = _written(proc) + assert sent["method"] == METHOD_SESSION_STEER_CLEAR + assert sent["params"] == {"sessionId": SID} + assert handle.supports_steer_retract is True + + +@pytest.mark.asyncio +async def test_a_refused_clear_stops_the_offer(): + result, handle, _ = await _clear( + ACP_BACKEND_KIRO, {"error": {"code": -32601, "message": "Method not found"}} + ) + + assert result is False + assert handle.supports_steer_retract is False + + +@pytest.mark.asyncio +async def test_a_refused_handle_sends_nothing_again(): + _, handle, proc = await _clear( + ACP_BACKEND_KIRO, {"error": {"code": -32601, "message": "Method not found"}} + ) + proc.stdin.write.reset_mock() + + assert await handle.clear_pending_steers() is False + proc.stdin.write.assert_not_called() + + +@pytest.mark.asyncio +async def test_no_answer_in_time_is_unknown_and_forgotten(): + with patch("kiro_crew.acp.session_handle_steering.STEER_CLEAR_ANSWER_WAIT_SECS", 0.0): + result, handle, proc = await _clear(ACP_BACKEND_KIRO, None) + + assert result is None + assert proc.stdin.write.called + assert handle._runtime._pending_requests == {} + + +@pytest.mark.asyncio +async def test_kas_is_not_sent_the_clear(): + result, handle, proc = await _clear(ACP_BACKEND_KAS, None) + + assert result is False + assert handle.supports_steer_retract is False + proc.stdin.write.assert_not_called() + + +def test_the_cleared_notification_is_a_steer_frame(): + msg = JsonRpcMessage(method=_cleared()["method"], params=_cleared("steer-1")["params"]) + + assert classify_notification(msg) == "steer" + + +@pytest.mark.asyncio +async def test_the_turn_reports_the_drop(): + rt, reader, _ = _make_runtime() + rt._acp_backend = ACP_BACKEND_KIRO + queues = _register(rt, SID) + handle = AcpSessionHandle(SID, queues[SID], rt) + task = await _start_reader(rt) + events: list = [] + driver = None + try: + + async def drive(): + async for ev in handle.prompt("run the command", timeout=600.0): + events.append(ev) + + driver = asyncio.ensure_future(drive()) + req_id = (await _await_routed(rt, SID))[SID] + _feed(reader, _cleared("steer-1", "steer-2")) + _feed(reader, {"id": req_id, "result": {"stopReason": "end_turn"}}) + await await_until(driver, what="the turn ending") + finally: + if driver is not None and not driver.done(): + driver.cancel() + await asyncio.gather(driver, return_exceptions=True) + await _stop_reader(task) + + dropped = [e for e in events if e.kind == EVENT_STEER_DROPPED] + assert len(dropped) == 1 + assert dropped[0].runtime_global is False diff --git a/test/test_steer_requeue.py b/test/test_steer_requeue.py index cc4d075a265..0285ff94a17 100644 --- a/test/test_steer_requeue.py +++ b/test/test_steer_requeue.py @@ -401,6 +401,7 @@ async def test_failed_steer_unwinds_registration(self, tmp_path, monkeypatch, _p state = _make_state(tmp_path) state.broadcast_ws = MagicMock() slot = _running_slot(state) + slot._steer_possibly_delivered.add("later") client_mock = MagicMock() client_mock.supports_steer = True client_mock.steer = AsyncMock(side_effect=RuntimeError("boom")) @@ -415,6 +416,7 @@ async def test_failed_steer_unwinds_registration(self, tmp_path, monkeypatch, _p # unwound — queue fallback owns delivery, no double-delivery via requeue assert slot._pending_steers == [] + assert "later" not in slot._steer_possibly_delivered assert [i["content"] for i in slot._queue] == ["later"] @pytest.mark.asyncio diff --git a/test/test_steer_retract.py b/test/test_steer_retract.py new file mode 100644 index 00000000000..1f541941f1d --- /dev/null +++ b/test/test_steer_retract.py @@ -0,0 +1,1376 @@ +"""Taking back a steer the running turn has not read yet (``dashboard/steer_retract.py``). + +The take-back is asynchronous: ``POST /api/chat/slots/{slot}/steers/{mid}/retract`` +arms it and writes kiro-cli's ``_session/steer/clear``, and what the turn reports +next decides the outcome. These tests drive each report directly: + +* ``on_dropped`` stands for ``EVENT_STEER_DROPPED`` (kiro-cli's + ``AgentExecutionUserMessageCleared``); +* ``_settle_consumed_steers`` for ``EVENT_STEER_CONSUMED``; +* ``_requeue_unconsumed_steers`` for the turn's teardown. + +The steers themselves go through the real composer path (``POST /api/chat`` with +``steer``), so the row a take-back reads is the row the steer path persisted. +""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +import steer_requeue_helpers +from aiohttp.test_utils import TestClient, TestServer +from chat_test_helpers import _make_app, _make_state +from steer_requeue_helpers import _running_slot + +from kiro_crew.acp.types import wrap_user_message +from kiro_crew.dashboard import steer_retract +from kiro_crew.dashboard.chat_delivery import ( + STEER_RETRACTABLE_META, + STEER_STATE_CONSUMED, + STEER_STATE_REQUEUED, + STEER_STATE_RETRACTED, + STEER_STATE_WRITTEN, +) +from kiro_crew.testing.wait import async_wait_until, await_until + +_patch_sel = steer_requeue_helpers._patch_sel + + +def _app(state, *, app_id: str = ""): + """The chat test app plus the take-back route; *app_id* makes it an app's view.""" + from aiohttp import web + + from kiro_crew.dashboard.chat_handlers import api_chat_slot_steer_retract + + app = _make_app(state) + app.router.add_post("/api/chat/slots/{slot}/steers/{mid}/retract", api_chat_slot_steer_retract) + if app_id: + + @web.middleware + async def as_app(request, handler): + request["app"] = app_id + return await handler(request) + + # Ahead of the test auth middleware, which keeps an identity already set. + app.middlewares.insert(0, as_app) + return app + + +def _retract_client(*, clear_answer: object = True) -> MagicMock: + """A live client that takes steers and can take one back.""" + client = MagicMock() + client.supports_steer = True + client.supports_steer_retract = True + client.steer = AsyncMock(return_value=True) + client.clear_pending_steers = AsyncMock(return_value=clear_answer) + return client + + +@pytest.fixture +def harness(tmp_path, monkeypatch, _patch_sel): + monkeypatch.setattr("kiro_crew.dashboard.state.config_dir", lambda: tmp_path) + state = _make_state(tmp_path) + state.broadcast_ws = MagicMock() + slot = _running_slot(state) + slot._acp_client = _retract_client() + return SimpleNamespace(state=state, slot=slot, app=_app(state)) + + +def _steer_rows(slot) -> list[dict]: + return [m for m in slot.messages if (m.get("meta") or {}).get("steer") is True] + + +def _mid_of(slot, text: str) -> str: + row = next(m for m in _steer_rows(slot) if m["content"] == text) + return row["meta"]["mid"] + + +def _frames(state, kind: str) -> list[dict]: + return [c.args[1] for c in state.broadcast_ws.call_args_list if c.args[0] == kind] + + +async def _steer(http, text: str) -> None: + resp = await http.post("/api/chat", json={"slot": "test", "message": text, "steer": True}) + assert resp.status == 200 + assert (await resp.json()).get("steered") is True + + +async def _retract(http, mid: str, action: str): + return await http.post(f"/api/chat/slots/test/steers/{mid}/retract", json={"action": action}) + + +class TestTheOffer: + """Only a steer that can be taken back says so on its row.""" + + @pytest.mark.asyncio + async def test_a_composer_steer_on_a_capable_harness_is_offered(self, harness): + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "use the blue fleet") + + (row,) = _steer_rows(harness.slot) + assert row["meta"]["steerState"] == STEER_STATE_WRITTEN + assert row["meta"][STEER_RETRACTABLE_META] is True + (push,) = _frames(harness.state, "steer_push") + assert push[STEER_RETRACTABLE_META] is True + + @pytest.mark.asyncio + async def test_a_harness_without_the_clear_is_not_offered(self, harness): + harness.slot._acp_client.supports_steer_retract = False + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "use the blue fleet") + + (row,) = _steer_rows(harness.slot) + assert STEER_RETRACTABLE_META not in row["meta"] + (push,) = _frames(harness.state, "steer_push") + assert STEER_RETRACTABLE_META not in push + + @pytest.mark.asyncio + async def test_a_peer_steer_is_not_offered(self, harness): + from kiro_crew.dashboard.chat_delivery import STEER_STEERED, steer_into_running_turn + + outcome = await steer_into_running_turn( + harness.state, harness.slot, "from a peer", user_origin=False, admission={} + ) + + assert outcome == STEER_STEERED + (row,) = _steer_rows(harness.slot) + assert STEER_RETRACTABLE_META not in row["meta"] + + +class TestTheRoute: + """What the route refuses changes nothing; what it accepts writes the clear.""" + + @pytest.mark.asyncio + async def test_accepting_writes_the_clear_and_changes_nothing_yet(self, harness): + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "use the blue fleet") + resp = await _retract(http, _mid_of(harness.slot, "use the blue fleet"), "unsend") + + assert resp.status == 202 + assert await resp.json() == {"ok": True, "pending": True} + harness.slot._acp_client.clear_pending_steers.assert_awaited_once() + assert harness.slot._pending_steers == ["use the blue fleet"] + (row,) = _steer_rows(harness.slot) + assert row["meta"]["steerState"] == STEER_STATE_WRITTEN + + @pytest.mark.asyncio + async def test_an_unknown_action_is_refused(self, harness): + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "use the blue fleet") + resp = await _retract(http, _mid_of(harness.slot, "use the blue fleet"), "delete") + + assert resp.status == 400 + assert (await resp.json())["code"] == "steer_retract_bad_action" + harness.slot._acp_client.clear_pending_steers.assert_not_awaited() + + @pytest.mark.asyncio + @pytest.mark.parametrize("action", [[], {}]) + async def test_a_non_string_action_is_refused(self, harness, action): + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "use the blue fleet") + resp = await http.post( + f"/api/chat/slots/test/steers/{_mid_of(harness.slot, 'use the blue fleet')}/retract", + json={"action": action}, + ) + + assert resp.status == 400 + assert (await resp.json())["code"] == "steer_retract_bad_action" + harness.slot._acp_client.clear_pending_steers.assert_not_awaited() + + @pytest.mark.asyncio + async def test_a_row_that_is_no_pending_steer_is_refused(self, harness): + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "use the blue fleet") + harness.slot._pending_steers.clear() + resp = await _retract(http, _mid_of(harness.slot, "use the blue fleet"), "unsend") + unknown = await _retract(http, "no-such-row", "unsend") + + assert resp.status == 409 + assert (await resp.json())["code"] == "steer_not_pending" + assert unknown.status == 409 + harness.slot._acp_client.clear_pending_steers.assert_not_awaited() + + @pytest.mark.asyncio + async def test_a_steer_still_being_written_makes_it_busy(self, harness): + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "use the blue fleet") + harness.slot._steer_rpc_in_flight.add("another steer") + resp = await _retract(http, _mid_of(harness.slot, "use the blue fleet"), "unsend") + + assert resp.status == 409 + assert (await resp.json())["code"] == "steer_retract_busy" + harness.slot._acp_client.clear_pending_steers.assert_not_awaited() + + @pytest.mark.asyncio + async def test_a_second_take_back_waits_for_the_first(self, harness): + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "first") + await _steer(http, "second") + first = await _retract(http, _mid_of(harness.slot, "first"), "unsend") + second = await _retract(http, _mid_of(harness.slot, "second"), "unsend") + + assert first.status == 202 + assert second.status == 409 + assert (await second.json())["code"] == "steer_retract_busy" + + @pytest.mark.asyncio + async def test_an_app_cannot_take_back_a_steer(self, harness, _patch_sel): + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "use the blue fleet") + mid = _mid_of(harness.slot, "use the blue fleet") + harness.slot._app = "some-app" + async with TestClient(TestServer(_app(harness.state, app_id="some-app"))) as http: + resp = await _retract(http, mid, "unsend") + + assert resp.status == 403 + assert (await resp.json())["code"] == "steer_retract_forbidden" + harness.slot._acp_client.clear_pending_steers.assert_not_awaited() + denied = [ + call.kwargs + for call in _patch_sel.log_api_access.call_args_list + if call.kwargs.get("operation") == "slot_steer_retract" + ] + assert len(denied) == 1, "the app's refused take-back leaves one audit row" + assert denied[0]["caller"] == "some-app" + assert denied[0]["outcome"] == "denied" + assert denied[0]["source"] == "app_isolation" + assert denied[0]["resources"] == "slot=test" + + @pytest.mark.asyncio + async def test_a_refused_clear_withdraws_the_offer(self, harness): + harness.slot._acp_client.clear_pending_steers = AsyncMock(return_value=False) + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "use the blue fleet") + resp = await _retract(http, _mid_of(harness.slot, "use the blue fleet"), "unsend") + + assert resp.status == 409 + assert (await resp.json())["code"] == "steer_retract_unsupported" + (row,) = _steer_rows(harness.slot) + assert row["meta"]["steerState"] == STEER_STATE_WRITTEN + assert row["meta"][STEER_RETRACTABLE_META] is False + assert harness.slot._steer_retract is None + assert harness.slot._steer_clear is None + assert harness.slot._pending_steers == ["use the blue fleet"] + + @pytest.mark.asyncio + async def test_a_refused_clear_after_turn_end_keeps_the_accepted_edit(self, harness): + release = asyncio.Event() + + async def delayed_refusal() -> bool: + await release.wait() + return False + + harness.slot._acp_client.clear_pending_steers = AsyncMock(side_effect=delayed_refusal) + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "change this") + mid = _mid_of(harness.slot, "change this") + retract = asyncio.create_task(_retract(http, mid, "edit")) + await async_wait_until(lambda: harness.slot._steer_retract is not None) + + steer_retract.on_turn_ending(harness.state, harness.slot) + release.set() + response = await await_until(retract, what="the refused clear answering") + + assert response.status == 202 + assert await response.json() == {"ok": True, "pending": True} + + (row,) = _steer_rows(harness.slot) + assert row["meta"]["steerState"] == STEER_STATE_RETRACTED + assert row["meta"][steer_retract.RETRACT_ACTION_META] == "edit" + assert row["content"] == "" + assert harness.slot._steer_retract is None + assert harness.slot._steer_clear is None + updates = _frames(harness.state, "chat_message_update") + assert len(updates) == 1, "the late refusal does not withdraw the settled row" + assert updates[0]["meta"]["steerState"] == STEER_STATE_RETRACTED + + @pytest.mark.asyncio + async def test_a_refused_clear_after_settle_releases_the_clear(self, harness): + from kiro_crew.dashboard.chat_runner import _settle_consumed_steers + + release = asyncio.Event() + clear_calls = 0 + + async def delayed_refusal() -> bool: + nonlocal clear_calls + clear_calls += 1 + if clear_calls == 1: + await release.wait() + return False + return True + + harness.slot._acp_client.clear_pending_steers = AsyncMock(side_effect=delayed_refusal) + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "change this") + await _steer(http, "still pending") + retract = asyncio.create_task( + _retract(http, _mid_of(harness.slot, "change this"), "edit") + ) + await async_wait_until(lambda: harness.slot._steer_retract is not None) + + _settle_consumed_steers(harness.slot, wrap_user_message("change this"), harness.state) + release.set() + response = await await_until(retract, what="the refused clear answering") + + assert response.status == 202 + assert await response.json() == {"ok": True, "pending": True} + assert harness.slot._steer_clear is None + + next_response = await _retract(http, _mid_of(harness.slot, "still pending"), "unsend") + assert next_response.status == 202 + + rows = {row["content"]: row for row in _steer_rows(harness.slot)} + assert rows["change this"]["meta"]["steerState"] == STEER_STATE_CONSUMED + assert harness.slot._steer_retract is not None + assert harness.slot._steer_retract.message == "still pending" + + +class TestDropped: + """kiro-cli reported the drop: the steer is taken back and the rest resent.""" + + @pytest.mark.asyncio + async def test_unsend_marks_the_row_and_forgets_the_steer(self, harness): + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "use the blue fleet") + mid = _mid_of(harness.slot, "use the blue fleet") + await _retract(http, mid, "unsend") + + await steer_retract.on_dropped(harness.state, harness.slot) + + assert harness.slot._pending_steers == [] + (row,) = _steer_rows(harness.slot) + assert row["meta"]["steerState"] == STEER_STATE_RETRACTED + assert row["meta"][steer_retract.RETRACT_ACTION_META] == "unsend" + assert row["meta"][STEER_RETRACTABLE_META] is False + assert row["content"] == "", "the row keeps its place but not the text taken back" + patch = _frames(harness.state, "chat_message_update")[-1] + assert patch["mid"] == mid + assert patch["meta"]["steerState"] == STEER_STATE_RETRACTED + assert patch["content"] == "" + assert harness.slot._queue == [] + assert harness.slot._steer_retract is None + assert harness.slot._steer_clear is None + + @pytest.mark.asyncio + async def test_the_other_steers_the_clear_dropped_are_sent_again_in_order(self, harness): + client = harness.slot._acp_client + async with TestClient(TestServer(harness.app)) as http: + for text in ("first", "take me back", "last"): + await _steer(http, text) + await _retract(http, _mid_of(harness.slot, "take me back"), "unsend") + client.steer.reset_mock() + mids = {text: _mid_of(harness.slot, text) for text in ("first", "take me back", "last")} + + await steer_retract.on_dropped(harness.state, harness.slot) + + assert [c.args[0] for c in client.steer.await_args_list] == ["first", "last"] + assert harness.slot._pending_steers == ["first", "last"] + assert harness.slot._steer_rpc_in_flight == set() + by_mid = {m["meta"]["mid"]: m["meta"]["steerState"] for m in _steer_rows(harness.slot)} + states = {text: by_mid[mid] for text, mid in mids.items()} + assert states == { + "first": STEER_STATE_WRITTEN, + "take me back": STEER_STATE_RETRACTED, + "last": STEER_STATE_WRITTEN, + } + + @pytest.mark.asyncio + async def test_a_possibly_delivered_steer_is_not_taken_back_or_resent(self, harness): + client = harness.slot._acp_client + async with TestClient(TestServer(harness.app)) as http: + for text in ("possibly read", "send again"): + await _steer(http, text) + await _retract(http, _mid_of(harness.slot, "possibly read"), "unsend") + harness.slot._steer_possibly_delivered.add("possibly read") + client.steer.reset_mock() + + await steer_retract.on_dropped(harness.state, harness.slot) + + assert [call.args[0] for call in client.steer.await_args_list] == ["send again"] + assert harness.slot._pending_steers == ["possibly read", "send again"] + assert harness.slot._steer_retract is None + states = {row["content"]: row["meta"]["steerState"] for row in _steer_rows(harness.slot)} + assert states["possibly read"] == STEER_STATE_WRITTEN + assert states["send again"] == STEER_STATE_WRITTEN + + @pytest.mark.asyncio + async def test_a_steer_sent_after_the_clear_is_not_resent(self, harness): + client = harness.slot._acp_client + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "take me back") + await _retract(http, _mid_of(harness.slot, "take me back"), "unsend") + await _steer(http, "after the clear") + client.steer.reset_mock() + + await steer_retract.on_dropped(harness.state, harness.slot) + + client.steer.assert_not_awaited() + assert harness.slot._pending_steers == ["after the clear"] + + @pytest.mark.asyncio + async def test_queue_instead_queues_the_text_with_its_attachments(self, harness): + async with TestClient(TestServer(harness.app)) as http: + resp = await http.post( + "/api/chat", + json={ + "slot": "test", + "message": "read [attached_file 1] /tmp/notes.md", + "steer": True, + "meta": {"files": ["/tmp/notes.md"]}, + }, + ) + assert resp.status == 200 + mid = _mid_of(harness.slot, "read [attached_file 1] /tmp/notes.md") + await _retract(http, mid, "queue") + + await steer_retract.on_dropped(harness.state, harness.slot) + + (entry,) = harness.slot._queue + assert entry["content"] == "read [attached_file 1] /tmp/notes.md" + assert entry["meta"]["files"] == ["/tmp/notes.md"] + (push,) = _frames(harness.state, "queue_push") + assert push["queue_id"] == entry["id"] + (row,) = _steer_rows(harness.slot) + assert row["meta"]["steerState"] == STEER_STATE_RETRACTED + assert row["meta"][steer_retract.RETRACT_ACTION_META] == "queue" + + @pytest.mark.asyncio + async def test_queue_instead_keeps_the_request_time_admission(self, harness): + from kiro_crew.dashboard import chat_runner + from kiro_crew.dashboard import session_control as sc + from kiro_crew.dashboard.chat_utils import slot_history_key + + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "run me next") + await _retract(http, _mid_of(harness.slot, "run me next"), "queue") + + harness.state.sessions.set_mirror_link( + slot_history_key(harness.slot), "C0NEWROOM", "1700000000.000200" + ) + await steer_retract.on_dropped(harness.state, harness.slot) + + (entry,) = harness.slot._queue + admission = entry["meta"][sc.QUEUED_CONTAINMENT_META_KEY] + assert admission["mirrored"] is False + chat_runner._drop_stale_admissions(harness.state, harness.slot) + assert harness.slot._queue == [] + + @pytest.mark.asyncio + async def test_queue_instead_with_unchanged_containment_still_drains(self, harness): + from kiro_crew.dashboard import chat_runner + + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "run me next") + await _retract(http, _mid_of(harness.slot, "run me next"), "queue") + + await steer_retract.on_dropped(harness.state, harness.slot) + + chat_runner._drop_stale_admissions(harness.state, harness.slot) + assert [entry["content"] for entry in harness.slot._queue] == ["run me next"] + + @pytest.mark.asyncio + async def test_edit_leaves_the_text_to_the_composer(self, harness): + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "use the blue fleet") + await _retract(http, _mid_of(harness.slot, "use the blue fleet"), "edit") + + await steer_retract.on_dropped(harness.state, harness.slot) + + assert harness.slot._queue == [] + (row,) = _steer_rows(harness.slot) + assert row["meta"]["steerState"] == STEER_STATE_RETRACTED + assert row["meta"][steer_retract.RETRACT_ACTION_META] == "edit" + + @pytest.mark.asyncio + async def test_a_drop_no_clear_asked_for_settles_nothing(self, harness): + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "use the blue fleet") + + await steer_retract.on_dropped(harness.state, harness.slot) + + assert harness.slot._pending_steers == ["use the blue fleet"] + (row,) = _steer_rows(harness.slot) + assert row["meta"]["steerState"] == STEER_STATE_WRITTEN + + +class TestTheTurnReadItFirst: + """The turn's consumption echo wins: nothing is taken back.""" + + @pytest.mark.asyncio + async def test_a_settle_ends_the_take_back(self, harness): + from kiro_crew.dashboard.chat_runner import _settle_consumed_steers + + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "use the blue fleet") + await _retract(http, _mid_of(harness.slot, "use the blue fleet"), "unsend") + + _settle_consumed_steers( + harness.slot, "\nuse the blue fleet\n", harness.state + ) + + (row,) = _steer_rows(harness.slot) + assert row["meta"]["steerState"] == STEER_STATE_CONSUMED + assert harness.slot._steer_retract is None + assert harness.slot._steer_clear is None, "nothing is left for the clear to drop" + + @pytest.mark.asyncio + async def test_a_possibly_delivered_steer_ends_an_armed_take_back(self, harness): + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "possibly read") + await _retract(http, _mid_of(harness.slot, "possibly read"), "unsend") + harness.slot._steer_possibly_delivered.add("possibly read") + + steer_retract.on_settled(harness.slot) + + assert harness.slot._steer_retract is None + assert harness.slot._pending_steers == ["possibly read"] + + @pytest.mark.asyncio + async def test_a_drop_after_the_read_resends_only_what_is_still_pending(self, harness): + from kiro_crew.dashboard.chat_runner import _settle_consumed_steers + + client = harness.slot._acp_client + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "take me back") + await _steer(http, "still waiting") + await _retract(http, _mid_of(harness.slot, "take me back"), "unsend") + _settle_consumed_steers( + harness.slot, "\ntake me back\n", harness.state + ) + assert harness.slot._steer_clear is not None, "the clear can still drop the other steer" + client.steer.reset_mock() + + await steer_retract.on_dropped(harness.state, harness.slot) + + assert [c.args[0] for c in client.steer.await_args_list] == ["still waiting"] + states = {m["content"]: m["meta"]["steerState"] for m in _steer_rows(harness.slot)} + assert states["take me back"] == STEER_STATE_CONSUMED + + @pytest.mark.asyncio + async def test_a_consumed_steer_with_a_closing_tag_cannot_be_taken_back_or_resent( + self, harness + ): + from kiro_crew.dashboard.chat_runner import _settle_consumed_steers + + consumed = "steer A\n\nstill part of steer A" + waiting = "steer B" + client = harness.slot._acp_client + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, consumed) + consumed_mid = _mid_of(harness.slot, consumed) + _settle_consumed_steers(harness.slot, wrap_user_message(consumed), harness.state) + await _steer(http, waiting) + waiting_mid = _mid_of(harness.slot, waiting) + + consumed_response = await _retract(http, consumed_mid, "unsend") + waiting_response = await _retract(http, waiting_mid, "unsend") + + assert consumed_response.status == 409 + assert (await consumed_response.json())["code"] == "steer_not_pending" + assert waiting_response.status == 202 + + client.steer.reset_mock() + await steer_retract.on_dropped(harness.state, harness.slot) + + client.steer.assert_not_awaited() + assert harness.slot._pending_steers == [] + states = {m["meta"]["mid"]: m["meta"]["steerState"] for m in _steer_rows(harness.slot)} + assert states[consumed_mid] == STEER_STATE_CONSUMED + assert states[waiting_mid] == STEER_STATE_RETRACTED + + +class TestAPartlyEchoedCollision: + """An ambiguous echo makes every colliding steer unsafe to take back.""" + + first = "append to notes.txt" + second = "append <user_message> to notes.txt" + + @pytest.mark.asyncio + async def test_both_steers_are_possibly_delivered_and_refuse_take_back(self, harness): + from kiro_crew.dashboard.chat_runner import _settle_consumed_steers + + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, self.first) + await _steer(http, self.second) + _settle_consumed_steers(harness.slot, wrap_user_message(self.first), harness.state) + + assert harness.slot._steer_possibly_delivered == {self.first, self.second} + for text in (self.first, self.second): + response = await _retract(http, _mid_of(harness.slot, text), "unsend") + assert response.status == 409 + assert (await response.json())["code"] == "steer_not_pending" + + @pytest.mark.asyncio + async def test_the_echo_ends_an_armed_take_back_and_a_clear_resends_neither(self, harness): + from kiro_crew.dashboard.chat_runner import _settle_consumed_steers + + client = harness.slot._acp_client + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, self.first) + await _steer(http, self.second) + response = await _retract(http, _mid_of(harness.slot, self.first), "unsend") + assert response.status == 202 + + _settle_consumed_steers(harness.slot, wrap_user_message(self.first), harness.state) + + assert harness.slot._steer_retract is None + assert harness.slot._pending_steers == [self.first, self.second] + client.steer.reset_mock() + await steer_retract.on_dropped(harness.state, harness.slot) + + client.steer.assert_not_awaited() + assert harness.slot._pending_steers == [self.first, self.second] + assert all( + row["meta"]["steerState"] == STEER_STATE_WRITTEN for row in _steer_rows(harness.slot) + ) + + @pytest.mark.asyncio + async def test_turn_end_requeues_both_with_the_possibly_delivered_note(self, harness): + from kiro_crew.dashboard.chat_runner import ( + _requeue_unconsumed_steers, + _settle_consumed_steers, + ) + from kiro_crew.dashboard.chat_utils import STEER_POSSIBLY_DELIVERED_META + + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, self.first) + await _steer(http, self.second) + + _settle_consumed_steers(harness.slot, wrap_user_message(self.first), harness.state) + _requeue_unconsumed_steers(harness.state, harness.slot) + + assert [entry["content"] for entry in harness.slot._queue] == [self.first, self.second] + assert all( + entry["meta"][STEER_POSSIBLY_DELIVERED_META] is True for entry in harness.slot._queue + ) + + +class TestAHardStop: + """A hard Stop releases a take-back without claiming the steer was dropped.""" + + async def _stop( + self, + harness, + *, + settlement_started: asyncio.Event | None = None, + settlement_release: asyncio.Event | None = None, + ) -> None: + from kiro_crew.dashboard.chat_handlers import stop_slot_turn + + if settlement_started is not None: + assert settlement_release is not None + + async def settle_queued_delivery(_deliveries) -> None: + settlement_started.set() + await settlement_release.wait() + + harness.slot._queue.append({"content": "queued completion", "meta": {}}) + harness.slot.note_pending_subagent_delivery( + "queued completion", [SimpleNamespace(agent_id="owed")] + ) + # The hard Stop's slots push serializes every slot whenever its coalescing + # window is open, which reads the manager's running and queued sub-agents. + harness.state.subagents = SimpleNamespace( + settle_queued_delivery=MagicMock(side_effect=settle_queued_delivery), + running_agents_for=MagicMock(return_value=[]), + published_queued_depths=MagicMock(return_value={}), + ) + harness.state.sessions.stop_turn = AsyncMock(return_value="hard") + harness.slot._stop_state = "soft_pending" + + result = await stop_slot_turn(harness.state, harness.slot, force=True) + + assert result == {"ok": True} + assert harness.slot._stop_state == "killing" + + @pytest.mark.asyncio + async def test_unsend_is_released_without_settling_its_row(self, harness): + from kiro_crew.dashboard.chat_runner import _requeue_unconsumed_steers + + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "take me back") + await _retract(http, _mid_of(harness.slot, "take me back"), "unsend") + + await self._stop(harness) + + (row,) = _steer_rows(harness.slot) + assert row["meta"]["steerState"] == STEER_STATE_WRITTEN + assert row["content"] == "take me back" + assert _frames(harness.state, "chat_message_update") == [] + assert harness.slot._pending_steers == [] + assert harness.slot._queue == [] + assert harness.slot._steer_retract is None + assert harness.slot._steer_clear is None + + _requeue_unconsumed_steers(harness.state, harness.slot) + + assert harness.slot._queue == [] + assert harness.slot._steer_retract is None + assert harness.slot._steer_clear is None + + @pytest.mark.asyncio + async def test_edit_is_released_without_settling_its_row(self, harness): + from kiro_crew.dashboard.chat_runner import _requeue_unconsumed_steers + + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "change this") + await _retract(http, _mid_of(harness.slot, "change this"), "edit") + + await self._stop(harness) + + (row,) = _steer_rows(harness.slot) + assert row["meta"]["steerState"] == STEER_STATE_WRITTEN + assert row["content"] == "change this" + assert _frames(harness.state, "chat_message_update") == [] + assert harness.slot._pending_steers == [] + assert harness.slot._queue == [] + assert harness.slot._steer_retract is None + assert harness.slot._steer_clear is None + + _requeue_unconsumed_steers(harness.state, harness.slot) + + assert harness.slot._queue == [] + assert harness.slot._steer_retract is None + assert harness.slot._steer_clear is None + + @pytest.mark.asyncio + @pytest.mark.parametrize("action", ["unsend", "edit"]) + async def test_a_late_consumed_report_never_retracts_the_row(self, harness, action): + from kiro_crew.dashboard.chat_runner import _settle_consumed_steers + + text = f"{action} after the turn read this" + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, text) + await _retract(http, _mid_of(harness.slot, text), action) + + await self._stop(harness) + _settle_consumed_steers(harness.slot, wrap_user_message(text), harness.state) + + (row,) = _steer_rows(harness.slot) + assert row["meta"]["steerState"] == STEER_STATE_WRITTEN + assert row["content"] == text + assert all( + frame.get("content") != "" for frame in _frames(harness.state, "chat_message_update") + ) + + @pytest.mark.asyncio + async def test_queue_instead_is_cleared_and_discarded_by_a_hard_stop(self, harness): + from kiro_crew.dashboard.chat_runner import _requeue_unconsumed_steers + + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "run me next") + await _retract(http, _mid_of(harness.slot, "run me next"), "queue") + + await self._stop(harness) + + (row,) = _steer_rows(harness.slot) + assert row["meta"]["steerState"] == STEER_STATE_WRITTEN + assert row["content"] == "run me next" + assert harness.slot._pending_steers == [] + assert harness.slot._queue == [] + assert harness.slot._steer_retract is None + assert harness.slot._steer_clear is None + + _requeue_unconsumed_steers(harness.state, harness.slot) + + assert harness.slot._queue == [] + assert row["meta"]["steerState"] == STEER_STATE_WRITTEN + + @pytest.mark.asyncio + async def test_a_drop_during_discarded_delivery_settle_cannot_requeue(self, harness): + from kiro_crew.dashboard.chat_runner import _requeue_unconsumed_steers + + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "run me next") + await _retract(http, _mid_of(harness.slot, "run me next"), "queue") + + settlement_started = asyncio.Event() + settlement_release = asyncio.Event() + stop = asyncio.create_task( + self._stop( + harness, + settlement_started=settlement_started, + settlement_release=settlement_release, + ) + ) + try: + await async_wait_until(settlement_started.is_set) + await steer_retract.on_dropped(harness.state, harness.slot) + finally: + settlement_release.set() + await await_until(stop, what="the hard Stop settlement finishing") + + (row,) = _steer_rows(harness.slot) + assert row["meta"]["steerState"] == STEER_STATE_WRITTEN + assert row["content"] == "run me next" + assert _frames(harness.state, "chat_message_update") == [] + assert harness.slot._pending_steers == [] + assert harness.slot._queue == [] + assert harness.slot._steer_retract is None + assert harness.slot._steer_clear is None + + _requeue_unconsumed_steers(harness.state, harness.slot) + + assert harness.slot._queue == [] + + @pytest.mark.asyncio + @pytest.mark.parametrize("action", ["queue", "edit", "unsend"]) + async def test_a_take_back_during_discarded_delivery_settle_is_refused(self, harness, action): + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "hard Stop is discarding this") + mid = _mid_of(harness.slot, "hard Stop is discarding this") + settlement_started = asyncio.Event() + settlement_release = asyncio.Event() + stop = asyncio.create_task( + self._stop( + harness, + settlement_started=settlement_started, + settlement_release=settlement_release, + ) + ) + try: + await async_wait_until(settlement_started.is_set) + response = await _retract(http, mid, action) + assert response.status == 409 + assert (await response.json())["code"] == "steer_not_pending" + harness.slot._acp_client.clear_pending_steers.assert_not_awaited() + assert harness.slot._steer_retract is None + assert harness.slot._steer_clear is None + finally: + settlement_release.set() + await await_until(stop, what="the hard Stop settlement finishing") + + assert harness.slot._pending_steers == [] + assert harness.slot._queue == [] + assert harness.slot._steer_retract is None + assert harness.slot._steer_clear is None + + @pytest.mark.asyncio + async def test_a_hard_stop_releases_a_steer_waiting_at_the_clear_gate(self, harness): + clear_release = asyncio.Event() + + async def slow_clear() -> bool: + await clear_release.wait() + return True + + harness.slot._acp_client.clear_pending_steers = AsyncMock(side_effect=slow_clear) + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "take me back") + retract = asyncio.create_task( + _retract(http, _mid_of(harness.slot, "take me back"), "unsend") + ) + await async_wait_until(lambda: harness.slot._steer_clear is not None) + clear = harness.slot._steer_clear + waiter = asyncio.create_task(steer_retract.wait_for_clear(harness.slot)) + assert not clear.answered.is_set() + + try: + await self._stop(harness) + assert clear.answered.is_set() + await await_until(waiter, what="the steer held at the clear's gate being released") + finally: + clear_release.set() + response = await await_until(retract, what="the take-back's request finishing") + assert response.status == 202 + + assert clear.answered.is_set() + assert harness.slot._steer_retract is None + assert harness.slot._steer_clear is None + + +class TestTheTurnEnded: + """A steer still being taken back when the turn ends never reached it.""" + + @pytest.mark.asyncio + async def test_unsend_is_not_requeued(self, harness): + from kiro_crew.dashboard.chat_runner import _requeue_unconsumed_steers + + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "take me back") + await _steer(http, "keep me") + await _retract(http, _mid_of(harness.slot, "take me back"), "unsend") + mids = {text: _mid_of(harness.slot, text) for text in ("take me back", "keep me")} + + _requeue_unconsumed_steers(harness.state, harness.slot) + + assert [e["content"] for e in harness.slot._queue] == ["keep me"] + by_mid = {m["meta"]["mid"]: m["meta"]["steerState"] for m in _steer_rows(harness.slot)} + states = {text: by_mid[mid] for text, mid in mids.items()} + assert states == {"take me back": STEER_STATE_RETRACTED, "keep me": STEER_STATE_REQUEUED} + assert harness.slot._steer_retract is None + assert harness.slot._steer_clear is None + + @pytest.mark.asyncio + async def test_a_possibly_delivered_record_is_requeued_instead_of_taken_back(self, harness): + from kiro_crew.dashboard.chat_runner import _requeue_unconsumed_steers + from kiro_crew.dashboard.chat_utils import STEER_POSSIBLY_DELIVERED_META + + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "possibly read") + await _retract(http, _mid_of(harness.slot, "possibly read"), "unsend") + harness.slot._steer_possibly_delivered.add("possibly read") + + _requeue_unconsumed_steers(harness.state, harness.slot) + + assert [entry["content"] for entry in harness.slot._queue] == ["possibly read"] + assert harness.slot._queue[0]["meta"][STEER_POSSIBLY_DELIVERED_META] is True + (row,) = _steer_rows(harness.slot) + assert row["meta"]["steerState"] == STEER_STATE_REQUEUED + assert steer_retract.RETRACT_ACTION_META not in row["meta"] + + @pytest.mark.asyncio + async def test_queue_instead_is_left_to_the_requeue(self, harness): + from kiro_crew.dashboard.chat_runner import _requeue_unconsumed_steers + + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "run me next") + await _retract(http, _mid_of(harness.slot, "run me next"), "queue") + + _requeue_unconsumed_steers(harness.state, harness.slot) + + assert [e["content"] for e in harness.slot._queue] == ["run me next"] + (row,) = _steer_rows(harness.slot) + assert row["meta"]["steerState"] == STEER_STATE_REQUEUED + + @pytest.mark.asyncio + async def test_queue_instead_keeps_unchanged_mirrored_admission_at_turn_end(self, harness): + from kiro_crew.dashboard import chat_runner + from kiro_crew.dashboard import session_control as sc + from kiro_crew.dashboard.chat_utils import slot_history_key + + harness.state.sessions.set_mirror_link( + slot_history_key(harness.slot), "C0EXISTING", "1700000000.000100" + ) + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "run me next") + await _retract(http, _mid_of(harness.slot, "run me next"), "queue") + + chat_runner._requeue_unconsumed_steers(harness.state, harness.slot) + + (entry,) = harness.slot._queue + assert entry["_directive_user_origin"] is True + assert entry["meta"][sc.QUEUED_CONTAINMENT_META_KEY]["mirrored"] is True + chat_runner._drop_stale_admissions(harness.state, harness.slot) + assert [item["content"] for item in harness.slot._queue] == ["run me next"] + + @pytest.mark.asyncio + async def test_queue_instead_drops_a_mirror_added_after_request_at_turn_end(self, harness): + from kiro_crew.dashboard import chat_runner + from kiro_crew.dashboard import session_control as sc + from kiro_crew.dashboard.chat_utils import slot_history_key + + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "run me next") + await _retract(http, _mid_of(harness.slot, "run me next"), "queue") + + harness.state.sessions.set_mirror_link( + slot_history_key(harness.slot), "C0NEWROOM", "1700000000.000200" + ) + chat_runner._requeue_unconsumed_steers(harness.state, harness.slot) + + (entry,) = harness.slot._queue + assert entry["_directive_user_origin"] is True + assert entry["meta"][sc.QUEUED_CONTAINMENT_META_KEY]["mirrored"] is False + chat_runner._drop_stale_admissions(harness.state, harness.slot) + assert harness.slot._queue == [] + + +class TestATakeBackLivesForOneTurn: + """A take-back left behind by a turn that ended some other way blocks nothing.""" + + @pytest.mark.asyncio + async def test_a_record_from_an_earlier_turn_is_dropped(self, harness): + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "first turn's steer") + await _retract(http, _mid_of(harness.slot, "first turn's steer"), "unsend") + stale_clear = harness.slot._steer_clear + # A new turn owns the slot and its exit never ran the teardown hook. + harness.slot.task = MagicMock(done=MagicMock(return_value=False)) + await _steer(http, "second turn's steer") + resp = await _retract(http, _mid_of(harness.slot, "second turn's steer"), "unsend") + + assert resp.status == 202 + assert stale_clear.answered.is_set(), "a steer waiting on the old clear is released" + assert harness.slot._steer_retract.message == "second turn's steer" + + +class TestTheClearGate: + """A steer sent while a clear is unanswered is written after the answer.""" + + @pytest.mark.asyncio + async def test_a_new_steer_waits_for_the_clear(self, harness, monkeypatch): + client = harness.slot._acp_client + release = asyncio.Event() + at_gate = asyncio.Event() + order: list[str] = [] + real_gate = steer_retract.wait_for_clear + + async def slow_clear() -> bool: + order.append("clear sent") + await release.wait() + order.append("clear answered") + return True + + async def record_steer(text: str) -> bool: + order.append(f"steer {text}") + return True + + async def gate(slot): + # The positive signal the absence check below needs: the steer has + # reached the gate, so it would have been written by now without it. + if slot._steer_clear is not None: + at_gate.set() + await real_gate(slot) + + client.clear_pending_steers = AsyncMock(side_effect=slow_clear) + monkeypatch.setattr(steer_retract, "wait_for_clear", gate) + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "take me back") + client.steer = AsyncMock(side_effect=record_steer) + retract = asyncio.ensure_future( + _retract(http, _mid_of(harness.slot, "take me back"), "unsend") + ) + steer = None + try: + await async_wait_until(lambda: "clear sent" in order) + steer = asyncio.ensure_future(_steer(http, "after the clear")) + await await_until(at_gate, what="the steer reaching the clear's gate") + + assert "steer after the clear" not in order + finally: + release.set() + await await_until( + asyncio.gather(retract, *([steer] if steer is not None else [])), + what="the take-back and the held steer finishing", + ) + + assert order == ["clear sent", "clear answered", "steer after the clear"] + + async def _held_steer(self, harness, monkeypatch, text: str) -> asyncio.Future: + """A steer sent while a clear is unanswered, suspended at the clear's gate.""" + from kiro_crew.dashboard.chat_delivery import steer_into_running_turn + + at_gate = asyncio.Event() + real_gate = steer_retract.wait_for_clear + + async def gate(slot): + at_gate.set() + await real_gate(slot) + + monkeypatch.setattr(steer_retract, "wait_for_clear", gate) + harness.slot._steer_clear = steer_retract.SteerClear(batch=(), turn=harness.slot.task) + steer = asyncio.ensure_future(steer_into_running_turn(harness.state, harness.slot, text)) + await await_until(at_gate, what="the steer reaching the clear's gate") + assert not harness.slot._steer_clear.answered.is_set() + return steer + + def _assert_handed_back(self, harness, outcome: str) -> None: + from kiro_crew.dashboard.chat_delivery import STEER_UNAVAILABLE + + assert outcome == STEER_UNAVAILABLE + assert _steer_rows(harness.slot) == [] + assert harness.slot._pending_steers == [] + assert harness.slot._steer_delivery_ids == {} + harness.slot._acp_client.steer.assert_not_awaited() + + @pytest.mark.asyncio + async def test_a_steer_held_while_the_turn_ends_is_handed_back(self, harness, monkeypatch): + from kiro_crew.dashboard.chat_runner import _requeue_unconsumed_steers + + steer = await self._held_steer(harness, monkeypatch, "too late for this turn") + + harness.slot.task.done.return_value = True + _requeue_unconsumed_steers(harness.state, harness.slot) + + outcome = await await_until(steer, what="the held steer resuming") + self._assert_handed_back(harness, outcome) + + @pytest.mark.asyncio + async def test_a_steer_held_through_a_hard_stop_is_handed_back(self, harness, monkeypatch): + from kiro_crew.dashboard.chat_handlers import stop_slot_turn + + steer = await self._held_steer(harness, monkeypatch, "sent as the stop landed") + + harness.state.sessions.stop_turn = AsyncMock(return_value="hard") + harness.slot._stop_state = "soft_pending" + assert await stop_slot_turn(harness.state, harness.slot, force=True) == {"ok": True} + + outcome = await await_until(steer, what="the held steer resuming") + self._assert_handed_back(harness, outcome) + + +@pytest.mark.asyncio +async def test_the_turn_hands_a_dropped_report_to_the_take_back(harness, monkeypatch): + from kiro_crew.acp.types import EVENT_STEER_DROPPED, AcpEvent + from kiro_crew.dashboard.chat_turn.steer_queue import _on_steer_dropped + + seen: list[tuple] = [] + + async def record(state, slot): + seen.append((state, slot)) + + monkeypatch.setattr(steer_retract, "on_dropped", record) + turn = SimpleNamespace(state=harness.state, slot=harness.slot) + + await _on_steer_dropped(turn, AcpEvent(kind=EVENT_STEER_DROPPED)) + await _on_steer_dropped(turn, AcpEvent(kind=EVENT_STEER_DROPPED, runtime_global=True)) + + assert seen == [(harness.state, harness.slot)], "an ownerless copy settles nothing" + + +@pytest.mark.asyncio +async def test_the_event_loop_hands_a_drop_to_its_phase(monkeypatch): + from turn_harness import TurnScript, run_turn + + from kiro_crew.acp.types import EVENT_STEER_DROPPED, EVENT_TEXT_CHUNK, AcpEvent + + handed: list[str] = [] + + async def record(_state, slot): + handed.append(slot.key) + + monkeypatch.setattr(steer_retract, "on_dropped", record) + + await run_turn( + TurnScript( + events=[ + AcpEvent(kind=EVENT_STEER_DROPPED), + AcpEvent(kind=EVENT_TEXT_CHUNK, text="the reply"), + ] + ) + ) + + assert len(handed) == 1, "the turn hands kiro-cli's drop report to the take-back once" + + +class TestTheSessionProvider: + """The dashboard reaches kiro-cli's clear through ``AcpSessionProvider``.""" + + @staticmethod + def _provider(handle: MagicMock): + from kiro_crew.acp.session_provider import AcpSessionProvider + + return AcpSessionProvider(handle, runtime=MagicMock()) + + def test_it_offers_a_take_back_only_when_its_handle_does(self): + handle = MagicMock() + handle.supports_steer_retract = True + assert self._provider(handle).supports_steer_retract is True + + handle.supports_steer_retract = False + assert self._provider(handle).supports_steer_retract is False + + @pytest.mark.asyncio + async def test_the_clear_goes_to_its_handle_and_the_answer_comes_back(self): + handle = MagicMock() + handle.clear_pending_steers = AsyncMock(return_value=True) + + assert await self._provider(handle).clear_pending_steers() is True + handle.clear_pending_steers.assert_awaited_once_with() + + +def test_a_slot_starts_with_nothing_being_taken_back(tmp_path, monkeypatch): + monkeypatch.setattr("kiro_crew.dashboard.state.config_dir", lambda: tmp_path) + slot = _make_state(tmp_path).get_or_create_slot("fresh") + + assert slot._steer_retract is None + assert slot._steer_clear is None + + +@pytest.mark.asyncio +async def test_an_old_identical_written_row_cannot_retract_the_current_steer(harness): + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "use the blue fleet") + old_mid = _mid_of(harness.slot, "use the blue fleet") + # A hard-killed turn can leave its row written after pending delivery state is gone. + harness.slot._pending_steers.clear() + await _steer(http, "use the blue fleet") + + resp = await _retract(http, old_mid, "unsend") + + assert resp.status == 409 + assert (await resp.json())["code"] == "steer_not_pending" + harness.slot._acp_client.clear_pending_steers.assert_not_awaited() + assert harness.slot._pending_steers == ["use the blue fleet"] + rows = _steer_rows(harness.slot) + assert len(rows) == 2 + assert all(row["meta"]["steerState"] == STEER_STATE_WRITTEN for row in rows) + + +def _offer_of(slot, mid: str) -> object: + row = next(m for m in slot.messages if (m.get("meta") or {}).get("mid") == mid) + return row["meta"].get(STEER_RETRACTABLE_META) + + +def _scripted_turn_state(tmp_path, monkeypatch): + """A state whose backend streams one finished reply, for driving ``_run_chat``.""" + from kiro_crew.acp.types import EVENT_COMPLETE, EVENT_TEXT_CHUNK, AcpEvent + + monkeypatch.setenv("KIROCREW_HOME", str(tmp_path / "home")) + monkeypatch.setattr("kiro_crew.dashboard.state.config_dir", lambda: tmp_path) + state = _make_state(tmp_path) + client = MagicMock() + client.shutdown = AsyncMock() + + async def _stream(*_a, **_kw): + yield AcpEvent(kind=EVENT_TEXT_CHUNK, text="done") + yield AcpEvent(kind=EVENT_COMPLETE, stop_reason="end_turn") + + client.stream = _stream + client.stream_command = _stream + state.sessions.get_or_create = AsyncMock(return_value=(client, False, False)) + state.sessions.release = MagicMock() + state.sessions.reset = AsyncMock() + state.sessions.set_approval_policy = MagicMock() + state.sessions.check_context_usage = MagicMock() + state.sessions.get_slack_link = MagicMock(return_value=(None, None)) + state.sessions.record_failure = AsyncMock() + state.broadcast_ws = MagicMock() + state.push_slots_update = MagicMock() + state.is_yolo_active = MagicMock(return_value=False) + state._background_tasks = set() + return state + + +class TestAnEndedTurnsOffer: + """A steer its turn ended without settling stops offering a take-back at the next turn.""" + + @pytest.mark.asyncio + async def test_a_hard_killed_steer_loses_the_offer_when_the_next_turn_starts( + self, tmp_path, monkeypatch + ): + from kiro_crew.dashboard.chat_runner import _run_chat + + state = _scripted_turn_state(tmp_path, monkeypatch) + slot = state.get_or_create_slot("ended-offer") + # What a hard Stop (or a restart mid-turn) leaves behind: the row of a + # steer the turn never read, still written and offered, nothing pending. + row = slot.append( + "user", + "use the blue fleet", + "msg msg-u", + meta={"steer": True, "steerState": STEER_STATE_WRITTEN, STEER_RETRACTABLE_META: True}, + ) + mid = row["meta"]["mid"] + state.broadcast_ws.reset_mock() + + await _run_chat(state, slot, "the next turn") + + assert _offer_of(slot, mid) is False + assert row["meta"]["steerState"] == STEER_STATE_WRITTEN + assert row["content"] == "use the blue fleet" + patch = next(f for f in _frames(state, "chat_message_update") if f.get("mid") == mid) + assert patch["meta"][STEER_RETRACTABLE_META] is False + assert patch["meta"]["steerState"] == STEER_STATE_WRITTEN + assert "content" not in patch + + @pytest.mark.asyncio + async def test_a_steer_discarded_by_a_hard_stop_is_withdrawn_at_the_next_start(self, harness): + from kiro_crew.dashboard.chat_handlers import stop_slot_turn + + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "use the blue fleet") + mid = _mid_of(harness.slot, "use the blue fleet") + harness.state.sessions.stop_turn = AsyncMock(return_value="hard") + harness.slot._stop_state = "soft_pending" + await stop_slot_turn(harness.state, harness.slot, force=True) + assert _offer_of(harness.slot, mid) is True, "the hard Stop itself leaves the offer" + + steer_retract.withdraw_stale_offers(harness.state, harness.slot) + + (row,) = _steer_rows(harness.slot) + assert row["meta"][STEER_RETRACTABLE_META] is False + assert row["meta"]["steerState"] == STEER_STATE_WRITTEN + assert row["content"] == "use the blue fleet" + patch = _frames(harness.state, "chat_message_update")[-1] + assert patch["mid"] == mid + assert patch["meta"][STEER_RETRACTABLE_META] is False + + @pytest.mark.asyncio + async def test_a_steer_of_the_new_turn_keeps_its_offer(self, harness): + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "from the turn that was killed") + harness.slot._pending_steers.clear() # the hard Stop's discard + steer_retract.withdraw_stale_offers(harness.state, harness.slot) + await _steer(http, "from the new turn") + new_mid = _mid_of(harness.slot, "from the new turn") + + assert ( + _offer_of(harness.slot, _mid_of(harness.slot, "from the turn that was killed")) is False + ) + assert _offer_of(harness.slot, new_mid) is True + async with TestClient(TestServer(harness.app)) as http: + resp = await _retract(http, new_mid, "unsend") + assert resp.status == 202 + + @pytest.mark.asyncio + async def test_nothing_is_withdrawn_while_a_steer_is_pending(self, harness): + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "still waiting") + mid = _mid_of(harness.slot, "still waiting") + harness.state.broadcast_ws.reset_mock() + + steer_retract.withdraw_stale_offers(harness.state, harness.slot) + + assert _offer_of(harness.slot, mid) is True + assert _frames(harness.state, "chat_message_update") == [] + + @pytest.mark.asyncio + async def test_nothing_is_withdrawn_while_a_take_back_is_armed(self, harness): + async with TestClient(TestServer(harness.app)) as http: + await _steer(http, "take me back") + mid = _mid_of(harness.slot, "take me back") + await _retract(http, mid, "queue") + harness.slot._pending_steers.clear() + record, clear = harness.slot._steer_retract, harness.slot._steer_clear + assert record is not None and clear is not None + harness.state.broadcast_ws.reset_mock() + + # Either half of an armed take-back holds the sweep off on its own. + for armed in ((record, None), (None, clear)): + harness.slot._steer_retract, harness.slot._steer_clear = armed + steer_retract.withdraw_stale_offers(harness.state, harness.slot) + + assert _offer_of(harness.slot, mid) is True + assert _frames(harness.state, "chat_message_update") == [] + + def test_other_rows_and_settled_steers_are_untouched(self, tmp_path, monkeypatch): + monkeypatch.setattr("kiro_crew.dashboard.state.config_dir", lambda: tmp_path) + state = _make_state(tmp_path) + state.broadcast_ws = MagicMock() + slot = state.get_or_create_slot("settled") + offered = {STEER_RETRACTABLE_META: True} + rows = [ + slot.append("user", "a plain message", meta=dict(offered)), + slot.append( + "assistant", + "a reply", + meta={"steer": True, "steerState": STEER_STATE_WRITTEN, **offered}, + ), + slot.append( + "user", + "consumed", + meta={"steer": True, "steerState": STEER_STATE_CONSUMED, **offered}, + ), + slot.append( + "user", + "requeued", + meta={"steer": True, "steerState": STEER_STATE_REQUEUED, **offered}, + ), + slot.append( + "user", "", meta={"steer": True, "steerState": STEER_STATE_RETRACTED, **offered} + ), + slot.append( + "user", "never offered", meta={"steer": True, "steerState": STEER_STATE_WRITTEN} + ), + ] + before = [(r["content"], dict(r["meta"])) for r in rows] + state.broadcast_ws.reset_mock() + + steer_retract.withdraw_stale_offers(state, slot) + + assert [(r["content"], dict(r["meta"])) for r in rows] == before + assert _frames(state, "chat_message_update") == [] diff --git a/test/test_steer_settle.py b/test/test_steer_settle.py index 093bac3f2f8..2408ffff3f2 100644 --- a/test/test_steer_settle.py +++ b/test/test_steer_settle.py @@ -7,12 +7,20 @@ from __future__ import annotations +from itertools import permutations + +import pytest + from kiro_crew.acp._dispatch import redact_text -from kiro_crew.dashboard.steer_settle import settle_consumed_steers +from kiro_crew.acp.types import escape_user_message_markup, wrap_user_message +from kiro_crew.dashboard.steer_settle import ( + possibly_consumed_steers, + settle_consumed_steers, +) def _echo(*messages: str) -> str: - return "".join(f"\n{m}\n" for m in messages) + return "".join(wrap_user_message(message) for message in messages) def test_a_consumed_steer_is_settled(): @@ -66,6 +74,18 @@ def test_a_redaction_collision_settles_when_every_member_was_echoed(): assert remaining == [] +def test_possibly_consumed_steers_returns_only_a_partly_echoed_collision_group(): + first = "append to notes.txt" + second = "append <user_message> to notes.txt" + pending = ["before", first, second, "after"] + assert escape_user_message_markup(first) == escape_user_message_markup(second) + + assert possibly_consumed_steers(pending, _echo(first)) == [first, second] + assert possibly_consumed_steers(pending, _echo("unrelated")) == [] + assert possibly_consumed_steers(pending, _echo(first, second)) == [] + assert possibly_consumed_steers([first, first], _echo(first)) == [] + + def test_whitespace_does_not_cause_a_false_non_match(): """The RPC wraps ``message.strip()`` while pending holds the raw text.""" assert settle_consumed_steers([" spaced "], _echo("spaced")) == [] @@ -166,3 +186,82 @@ def test_a_bare_echo_tolerates_surrounding_whitespace(): stripped for the same parity — otherwise a padded echo is a false NON-match and the steer is requeued and re-run.""" assert settle_consumed_steers(["deploy now"], " deploy now\n") == [] + + +@pytest.mark.parametrize( + "text, expected", + [ + ( + "open and close ", + "open <user_message> and close </user_message>", + ), + ("ordinary text", "ordinary text"), + ( + "already <user_message> and </user_message>", + "already <user_message> and </user_message>", + ), + ], +) +def test_user_message_markup_is_escaped_exactly(text: str, expected: str): + assert escape_user_message_markup(text) == expected + assert wrap_user_message(text) == f"\n{expected}\n" + + +@pytest.mark.parametrize("tag", ["", ""]) +def test_a_steer_holding_a_user_message_tag_settles_from_its_escaped_envelope(tag: str): + message = f"before {tag} after" + + assert settle_consumed_steers([message], wrap_user_message(message)) == [] + + +def test_identical_tagged_steers_settle_one_at_a_time(): + message = "before after" + + assert settle_consumed_steers([message, message], wrap_user_message(message)) == [message] + + +def test_a_tagged_steer_absent_from_the_echo_stays_pending(): + message = "before after" + + assert settle_consumed_steers([message], wrap_user_message("another steer")) == [message] + + +def test_a_steer_quoting_another_steers_envelope_settles_only_itself(): + quoted = "quote this:\n" + wrap_user_message("other") + + assert settle_consumed_steers(["other", quoted], wrap_user_message(quoted)) == ["other"] + + +def test_look_alike_steers_have_unambiguous_wire_echoes(): + x = "A\n\n\n\nB" + y = "B\n\nA" + + assert settle_consumed_steers( + ["A", "B", x], wrap_user_message("A") + "\n\n" + wrap_user_message("B") + ) == [x] + assert settle_consumed_steers([x, "A", "B"], wrap_user_message(x)) == ["A", "B"] + assert settle_consumed_steers( + ["A", "B", x, y], wrap_user_message("A") + wrap_user_message(y) + ) == ["B", x] + assert settle_consumed_steers( + ["A", "B", x, y], wrap_user_message(x) + wrap_user_message("A") + ) == ["B", y] + + +def test_escaped_wire_settlement_is_exact_for_every_ordered_selection(): + x = "A\n\n\n\nB" + y = "B\n\nA" + messages = ["A", "B", x, y] + + for pending_order in permutations(messages): + pending = list(pending_order) + for count in range(len(messages) + 1): + for reality in permutations(messages, count): + for separator in ("", "\n", "\n\n"): + echo = separator.join(wrap_user_message(message) for message in reality) + expected = [message for message in pending if message not in reality] + assert settle_consumed_steers(pending, echo) == expected, ( + pending, + reality, + repr(separator), + ) diff --git a/test/test_stop_addresses_linked_session.py b/test/test_stop_addresses_linked_session.py index 2e1b3d36cbc..0158a368d31 100644 --- a/test/test_stop_addresses_linked_session.py +++ b/test/test_stop_addresses_linked_session.py @@ -45,6 +45,10 @@ def __init__(self, linked: str = "", app: str = ""): self._stop_escalated_card_id = None self._queue: list[dict] = [] self._pending_steers: list[dict] = [] + # A real slot carries both from birth (slot_ladder): a hard kill settles a + # take-back still in flight before it discards the pending steers. + self._steer_retract = None + self._steer_clear = None self.running = True self.key = "test-slot" self.agent = "kirocrew" diff --git a/website/src/api/client/chat.ts b/website/src/api/client/chat.ts index 49cff387735..78cabb8bd5c 100644 --- a/website/src/api/client/chat.ts +++ b/website/src/api/client/chat.ts @@ -171,6 +171,10 @@ export function createChatEndpoints({ post, put, del, patch, j, jAccepted, jfetc cancelQueuedMessage: (slot: string, queueId: string) => del('/api/chat/slots/' + encodeURIComponent(slot) + '/queue/' + encodeURIComponent(queueId)).then(j), editQueuedMessage: (slot: string, queueId: string, content: string) => patch('/api/chat/slots/' + encodeURIComponent(slot) + '/queue/' + encodeURIComponent(queueId), { content }).then(j), reorderQueuedMessages: (slot: string, order: string[]) => put('/api/chat/slots/' + encodeURIComponent(slot) + '/queue/order', { order }).then(j), + /** Take back a steer the running turn has not read yet (the row's `meta.mid`). + * Resolves once the take-back is armed; what became of the steer lands on its + * own row (`steerState`), which is where the caller reads the outcome. */ + retractSteer: (slot: string, mid: string, action: 'unsend' | 'edit' | 'queue') => post('/api/chat/slots/' + encodeURIComponent(slot) + '/steers/' + encodeURIComponent(mid) + '/retract', { action }).then(j), interruptSlot: (slot: string, queueId?: string) => post('/api/chat/slots/' + encodeURIComponent(slot) + '/interrupt', queueId ? { queue_id: queueId } : {}).then(j), /** Ask the sleeping `wait` tool to return early. Cooperative, not a stop: * the turn continues with a normal tool result. `waitId` must name the sleep diff --git a/website/src/hooks/useSteerRetract.ts b/website/src/hooks/useSteerRetract.ts new file mode 100644 index 00000000000..c3185786cef --- /dev/null +++ b/website/src/hooks/useSteerRetract.ts @@ -0,0 +1,403 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { api } from '../api/client' +import { ApiError } from '../api/apiError' +import { queueEntryAttachments } from '../store/chatSlice' +import { restoreQueuedContent } from '../utils/fileTokens' +import { revealComposer } from '../pages/chat/composerFocus' +import type { MessageQuote } from '../chat-core/composer/messageQuote' +import { DRAFT_TTL_MS } from '../utils/draftConstants' +import { safeGetItem, safeRemoveItem } from '../utils/safeStorage' +import { createSlotDraftStore } from '../utils/slotDraftStore' +import type { ChatMessage } from '../types' + +/** What the person asked to do with a steer the running turn has not read yet. */ +export type SteerRetractAction = 'unsend' | 'edit' | 'queue' + +/** Why a take-back did not happen, when the row itself does not already say so. */ +export type SteerRetractRefusal = 'busy' | 'unsupported' | 'failed' + +/** A take-back in flight: what was asked, and the row as it stood when asked, so + * an Edit can put the text back in the composer once the row says it was taken. */ +interface PendingTakeBack { + slot: string + mid: string + action: SteerRetractAction + content: string + meta: Record | undefined + observedRunning: boolean + requestFailed: boolean +} + +const TAKE_BACKS_STORAGE_KEY = 'mc-steer-take-backs' +const TAKE_BACKS_TIMESTAMPS_KEY = `${TAKE_BACKS_STORAGE_KEY}-ts` +const TAKE_BACK_ACTIONS = new Set(['unsend', 'edit', 'queue']) + +function sanitizePendingTakeBack(value: unknown): PendingTakeBack | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const record = value as Record + if ( + typeof record.slot !== 'string' || !record.slot || + typeof record.mid !== 'string' || !record.mid || + typeof record.action !== 'string' || !TAKE_BACK_ACTIONS.has(record.action as SteerRetractAction) || + typeof record.content !== 'string' || + typeof record.observedRunning !== 'boolean' || + typeof record.requestFailed !== 'boolean' + ) return null + if (record.meta !== undefined && (!record.meta || typeof record.meta !== 'object' || Array.isArray(record.meta))) return null + return { + slot: record.slot, + mid: record.mid, + action: record.action as SteerRetractAction, + content: record.content, + meta: record.meta ? { ...(record.meta as Record) } : undefined, + observedRunning: record.observedRunning, + requestFailed: record.requestFailed, + } +} + +const takeBackStore = createSlotDraftStore({ + key: TAKE_BACKS_STORAGE_KEY, + storage: 'local', + ttlMs: DRAFT_TTL_MS, + // A record is the only copy of a taken-back Edit until its row settles, so never evict it; a write that does not fit refuses the Edit before sending it. + sanitize: sanitizePendingTakeBack, +}) + +/** One recovery record per slot, independent of the currently mounted chat. */ +const pendingTakeBackBySlot = new Map() + +/** Take-backs started here, unlike recovery records loaded from another tab. */ +const locallyStartedTakeBackBySlot = new Map() + +/** Recovery writes attempted in this page session. A reload may try them again. */ +const attemptedRecovery = new Set() +const recoveryKey = (taken: Pick) => + JSON.stringify([taken.slot, taken.mid, taken.action]) + +/** Clear module recovery state between tests, preserving storage to simulate reload. */ +export function __resetSteerTakeBacksForTests(): void { + pendingTakeBackBySlot.clear() + locallyStartedTakeBackBySlot.clear() + attemptedRecovery.clear() + takeBackStore.__resetForTests?.() +} + +export interface SteerRetractOptions { + /** The slot whose transcript `messages` is. */ + slot: string | null + messages: ChatMessage[] + /** Whether this slot's history fetch has settled. Defaults true for store-free hosts. */ + transcriptAuthoritative?: boolean + slotRunning: boolean + /** Merge a taken-back steer's text, attachments and quote into the composer. */ + restoreDraft: (text: string, files: string[], aliases?: Record, quote?: MessageQuote) => boolean | void + /** The paths the composer has staged right now (see `useQueuedMessageActions`). */ + stagedFiles?: () => readonly string[] + /** The composer's project root (see `useQueuedMessageActions`). */ + project?: () => string | undefined +} + +export interface SteerRetract { + onSteerAction: (mid: string, action: SteerRetractAction) => void + /** The action in flight for each row `mid`. */ + pending: Readonly> + /** Why the last take-back of each row `mid` did not happen. */ + refusals: Readonly> + /** Rows whose take-back request failed before its outcome was known. */ + requestFailures: Readonly> +} + +const REFUSAL_BY_CODE: Readonly> = { + // The turn read it, or is about to say so on the row. + steer_not_pending: null, + steer_retract_busy: 'busy', + steer_retract_unsupported: 'unsupported', +} + +/** A 4xx is the route's final answer. Transport failures and 5xx leave the outcome unknown. */ +function definitiveRefusalOf(err: unknown): SteerRetractRefusal | null | undefined { + if (!(err instanceof ApiError) || err.status < 400 || err.status >= 500) return undefined + if (err.body) { + try { + const code = (JSON.parse(err.body) as { code?: unknown }).code + if (typeof code === 'string' && code in REFUSAL_BY_CODE) return REFUSAL_BY_CODE[code] + } catch { + // Not the gateway's JSON: an ordinary refusal. + } + } + return 'failed' +} + +function takeBackLocks(): LockManager | undefined { + return typeof navigator === 'undefined' ? undefined : navigator.locks +} + +function withTakeBackLock(callback: () => void): void { + const locks = takeBackLocks() + if (!locks) { + callback() + return + } + // One store-wide Web Lock makes whole-blob recovery updates atomic across tabs. + void locks.request(TAKE_BACKS_STORAGE_KEY, callback) +} + +function clearStoredTakeBacks(): void { + safeRemoveItem(TAKE_BACKS_STORAGE_KEY) + safeRemoveItem(TAKE_BACKS_TIMESTAMPS_KEY) +} + +function loadStoredTakeBacks(): Record { + const raw = safeGetItem(TAKE_BACKS_STORAGE_KEY) + let parsed: Record | null = null + if (raw !== null) { + try { + const value = JSON.parse(raw) as unknown + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid take-back store') + parsed = value as Record + } catch { + clearStoredTakeBacks() + return {} + } + } + const stored = takeBackStore.load() + let needsRewrite = parsed !== null && Object.keys(parsed).length !== Object.keys(stored).length + for (const [slot, taken] of Object.entries(stored)) { + if (taken.slot === slot) continue + delete stored[slot] + needsRewrite = true + } + if (raw !== null && Object.keys(stored).length === 0) clearStoredTakeBacks() + else if (needsRewrite) takeBackStore.save(stored) + return stored +} + +function storedTakeBackForSlot(slot: string): PendingTakeBack | undefined { + const taken = loadStoredTakeBacks()[slot] + if (!taken) return undefined + if (taken.slot === slot) return taken + removeStoredTakeBack(slot) + return undefined +} + +function peekStoredTakeBackForSlot(slot: string): PendingTakeBack | undefined { + const taken = takeBackStore.peek()[slot] + return taken?.slot === slot ? taken : undefined +} + +function persistTakeBack(taken: PendingTakeBack): boolean { + pendingTakeBackBySlot.set(taken.slot, taken) + const stored = loadStoredTakeBacks() + takeBackStore.set(stored, taken.slot, taken) + return takeBackStore.save(stored) +} + +function removeStoredTakeBack(slot: string, expected?: Pick): void { + const stored = loadStoredTakeBacks() + const current = stored[slot] + if (!current) return + if (expected && (current.mid !== expected.mid || current.action !== expected.action)) return + delete stored[slot] + takeBackStore.save(stored) + if (Object.keys(stored).length === 0) clearStoredTakeBacks() +} + +function takeBackForSlot(slot: string | null): PendingTakeBack | undefined { + if (!slot) return undefined + const inMemory = pendingTakeBackBySlot.get(slot) + if (inMemory) return inMemory + const stored = peekStoredTakeBackForSlot(slot) + if (stored && attemptedRecovery.has(recoveryKey(stored))) return undefined + if (stored) pendingTakeBackBySlot.set(slot, stored) + return stored +} + +function pendingForSlot(slot: string | null): Readonly> { + const taken = takeBackForSlot(slot) + return taken ? { [taken.mid]: taken.action } : {} +} + +function requestFailuresForSlot(slot: string | null): Readonly> { + const taken = takeBackForSlot(slot) + return taken?.requestFailed ? { [taken.mid]: true } : {} +} + +function without(record: Readonly>, key: string): Readonly> { + if (!(key in record)) return record + const next = { ...record } + delete next[key] + return next +} + +/** + * Unsend, Edit and Queue instead on a steer the running turn has not read yet. + * + * The route answers before anything is known: kiro-cli is asked to drop the steer, + * and the turn then reports whether it did. That verdict lands on the steer's own + * row, so this reads it there rather than from the request: + * + * - `retracted`: taken back. Edit puts the text back in the composer, from the row + * as it stood when the person asked, the way a cancelled queue card does. + * - `consumed`: the turn read it first. The row's badge says so. + * - `requeued`: the turn ended first and the steer runs as its own message, which + * is what Queue instead asked for. + * - `steerRetract: false`: the agent refused to take a steer back. + * - the turn gone with the row still `written`: a hard stop discarded the steer, so + * an Edit or a Queue instead gets its text back in the composer rather than lose it. + * + * Reading the row rather than a reply also covers a dropped socket: the row is + * re-read with the transcript. + */ +export function useSteerRetract({ slot, messages, transcriptAuthoritative = true, slotRunning, restoreDraft, stagedFiles, project }: SteerRetractOptions): SteerRetract { + const [pending, setPending] = useState>>(() => pendingForSlot(slot)) + const [refusals, setRefusals] = useState>>({}) + const [requestFailures, setRequestFailures] = useState>>(() => requestFailuresForSlot(slot)) + const slotRef = useRef(slot) + slotRef.current = slot + const messagesRef = useRef(messages) + messagesRef.current = messages + const slotRunningRef = useRef(slotRunning) + slotRunningRef.current = slotRunning + const restoreDraftRef = useRef(restoreDraft) + restoreDraftRef.current = restoreDraft + const stagedFilesRef = useRef(stagedFiles) + stagedFilesRef.current = stagedFiles + const projectRef = useRef(project) + projectRef.current = project + + const settle = useCallback((taken: PendingTakeBack, refusal?: SteerRetractRefusal | null, keepStored = false) => { + if (pendingTakeBackBySlot.get(taken.slot) !== taken) return + pendingTakeBackBySlot.delete(taken.slot) + if (locallyStartedTakeBackBySlot.get(taken.slot) === taken) { + locallyStartedTakeBackBySlot.delete(taken.slot) + } + if (!keepStored) { + withTakeBackLock(() => removeStoredTakeBack(taken.slot, taken)) + } + if (slotRef.current !== taken.slot) return + setPending(prev => without(prev, taken.mid)) + setRefusals(prev => (refusal ? { ...prev, [taken.mid]: refusal } : without(prev, taken.mid))) + setRequestFailures(prev => without(prev, taken.mid)) + }, []) + + const restore = useCallback((taken: PendingTakeBack) => { + // The row holds the wire form (attachment markers, image lines and quote + // block). Attachment markers return to composer form while the quote stays durable. + const { text, files, aliases } = restoreQueuedContent( + taken.content, + queueEntryAttachments(taken.meta).files, + stagedFilesRef.current?.(), + projectRef.current?.(), + ) + const stored = restoreDraftRef.current(text, files, aliases) + revealComposer() + return stored + }, []) + + const restoreAndSettle = useCallback((taken: PendingTakeBack) => { + const stored = restore(taken) + if (stored === false) attemptedRecovery.add(recoveryKey(taken)) + settle(taken, undefined, stored === false) + }, [restore, settle]) + + const onSteerAction = useCallback((mid: string, action: SteerRetractAction) => { + if (!slot) return + // Without a cross-tab lock another tab can overwrite the record that holds the only copy of an Edit's text. + if (action === 'edit' && !takeBackLocks()) { + setPending(prev => without(prev, mid)) + setRefusals(prev => ({ ...prev, [mid]: 'failed' })) + setRequestFailures(prev => without(prev, mid)) + return + } + withTakeBackLock(() => { + if (slotRef.current !== slot) return + const active = pendingTakeBackBySlot.get(slot) + if (active && locallyStartedTakeBackBySlot.get(slot) === active) return + const row = messagesRef.current.find(m => m.meta?.mid === mid) + if (!row) return + const outstanding = active ?? storedTakeBackForSlot(slot) + if (outstanding) { + // A stored take-back may hold the only copy of an Edit's text, so refuse before replacing it: + // busy while it settles, failed once this page could not save that text to the message box. + const refusal = attemptedRecovery.has(recoveryKey(outstanding)) ? 'failed' : 'busy' + setPending(prev => without(prev, mid)) + setRefusals(prev => ({ ...prev, [mid]: refusal })) + setRequestFailures(prev => without(prev, mid)) + return + } + const taken: PendingTakeBack = { + slot, + mid, + action, + content: row.content, + meta: row.meta, + observedRunning: slotRunningRef.current, + requestFailed: false, + } + const initiallyStored = persistTakeBack(taken) + if (action === 'edit' && !initiallyStored) { + pendingTakeBackBySlot.delete(slot) + setPending(prev => without(prev, mid)) + setRefusals(prev => ({ ...prev, [mid]: 'failed' })) + setRequestFailures(prev => without(prev, mid)) + return + } + locallyStartedTakeBackBySlot.set(slot, taken) + setPending({ [mid]: action }) + setRefusals(prev => without(prev, mid)) + setRequestFailures(prev => without(prev, mid)) + api.retractSteer(slot, mid, action).catch((err: unknown) => { + const refusal = definitiveRefusalOf(err) + if (refusal !== undefined) { + settle(taken, refusal) + return + } + withTakeBackLock(() => { + if (pendingTakeBackBySlot.get(taken.slot) !== taken) return + const current = storedTakeBackForSlot(taken.slot) + if (current && (current.mid !== taken.mid || current.action !== taken.action)) return + if (!current && initiallyStored) return + taken.requestFailed = true + persistTakeBack(taken) + if (slotRef.current === taken.slot) setRequestFailures({ [taken.mid]: true }) + }) + }) + }) + }, [slot, settle]) + + useEffect(() => { + setPending(pendingForSlot(slot)) + setRefusals({}) + setRequestFailures(requestFailuresForSlot(slot)) + }, [slot]) + + useEffect(() => { + if (!slot) return + const taken = pendingTakeBackBySlot.get(slot) + if (!taken) return + const row = messages.find(message => message.meta?.mid === taken.mid) + if (!row && transcriptAuthoritative && messages.length > 0) { + // A row missing from the loaded page may only be older than it. If the take-back + // went through, the server cleared that row's text and this record is the only + // copy, so an Edit gets its text back: a duplicate is recoverable, a lost text is not. + if (taken.action === 'edit') restoreAndSettle(taken) + else settle(taken) + return + } + if (slotRunning && !taken.observedRunning) taken.observedRunning = true + const state = row?.meta?.steerState + if (state === 'retracted') { + if (taken.action === 'edit') restoreAndSettle(taken) + else settle(taken) + } else if (state === 'consumed' || state === 'requeued') { + settle(taken) + } else if (row?.meta?.steerRetract === false) { + settle(taken, 'unsupported') + } else if (transcriptAuthoritative && messages.length > 0 && taken.observedRunning && !slotRunning) { + if (taken.action !== 'unsend') restoreAndSettle(taken) + else settle(taken) + } + }, [messages, transcriptAuthoritative, slotRunning, slot, restoreAndSettle, settle]) + + return { onSteerAction, pending, refusals, requestFailures } +} diff --git a/website/src/hooks/websocket/chatStream.ts b/website/src/hooks/websocket/chatStream.ts index 415cbfe20c5..d33dfabc1e6 100644 --- a/website/src/hooks/websocket/chatStream.ts +++ b/website/src/hooks/websocket/chatStream.ts @@ -165,6 +165,9 @@ export function useChatStream({ dispatch, buffers, voice, reconnectingRef }: Cha // which is keyed on `mid`, resolves this row -- without it that patch // matches nothing and the state never moves until a reload. const steerMid = (data as { mid?: unknown }).mid + // Whether this steer can be taken back while it waits (the gateway stamps it + // only where the harness can), so another tab draws the same controls. + const steerRetract = (data as { steerRetract?: unknown }).steerRetract === true const steerMeta = (data as { meta?: unknown }).meta const steerFiles = steerMeta && typeof steerMeta === 'object' ? (steerMeta as { files?: unknown }).files : undefined const steerDirs = steerMeta && typeof steerMeta === 'object' ? (steerMeta as { dirs?: unknown }).dirs : undefined @@ -173,7 +176,7 @@ export function useChatStream({ dispatch, buffers, voice, reconnectingRef }: Cha const steerQuote = queueEntryQuote(steerMeta).quote dispatch(appendSlotMessage({ slot: (data as { slot?: string }).slot || store.getState().chat.activeSlot || '', - message: { role: 'user', content: (data as { content?: string }).content || '', cls: 'msg msg-u', meta: { steer: true, ...(typeof steerSid === 'string' && steerSid ? { sendId: steerSid } : {}), ...(typeof steerState === 'string' && steerState ? { steerState } : {}), ...(typeof steerMid === 'string' && steerMid ? { mid: steerMid } : {}), ...(Array.isArray(steerFiles) ? { files: steerFiles } : {}), ...(Array.isArray(steerDirs) ? { dirs: steerDirs } : {}), ...(steerQuote ? { quote: steerQuote } : {}) }, ts: (data as { ts?: string }).ts }, + message: { role: 'user', content: (data as { content?: string }).content || '', cls: 'msg msg-u', meta: { steer: true, ...(typeof steerSid === 'string' && steerSid ? { sendId: steerSid } : {}), ...(typeof steerState === 'string' && steerState ? { steerState } : {}), ...(typeof steerMid === 'string' && steerMid ? { mid: steerMid } : {}), ...(steerRetract ? { steerRetract: true } : {}), ...(Array.isArray(steerFiles) ? { files: steerFiles } : {}), ...(Array.isArray(steerDirs) ? { dirs: steerDirs } : {}), ...(steerQuote ? { quote: steerQuote } : {}) }, ts: (data as { ts?: string }).ts }, })) // Steering is the other way to type into a busy session, so it // settles the rank exactly like a queued send. The server appends a diff --git a/website/src/i18n/en.context.json b/website/src/i18n/en.context.json index 97f4f712c6d..08ded0b7a7c 100644 --- a/website/src/i18n/en.context.json +++ b/website/src/i18n/en.context.json @@ -340,6 +340,18 @@ "pages.chat.toolDetails.raw": "Segment label on the tool-call details panel, paired with \"Formatted\". Selects the unprocessed payload -- the exact bytes the agent sent or received, with escape sequences left as written -- rather than the prettified table view. \"Raw\" as in unprocessed data, not uncooked food. Keep it to one short word if the language allows; it sits in a two-segment toggle.", "pages.chat.userMessage.redirecting_the_running_turn_not_yet_confirmed": "Tooltip (title attribute) on the muted \"Steering…\" indicator above a chat message the user redirected into the agent's RUNNING turn, explaining the jargon for first-time users: the redirect was sent but the backend has not yet confirmed the running turn took it. \"Turn\" means one conversation turn (the agent's current run).", "pages.chat.userMessage.steering": "Muted progress indicator above a chat message the user redirected into the agent's RUNNING turn (a \"steer\"), shown while the backend has not yet confirmed the redirect. Progressive verb — \"steering is in progress\" — NOT the noun used elsewhere for \"steering files\". Sits where the confirmed \"Steered into the running turn\" badge later appears, so echo that key's verb.", + "pages.chat.userMessage.steer_unsend": "Item in the small \"…\" menu beside the muted \"Steering…\" indicator on a chat message the user sent into the agent's RUNNING turn while the agent has not read it yet. Choosing it takes the message back so the agent never sees it, like unsending a chat message. Imperative verb, one or two words.", + "pages.chat.userMessage.steer_edit": "Item in the same \"…\" menu as \"Unsend\": takes the not-yet-read message back and puts its text in the message box so the user can change it and send it again. Imperative verb, one word if possible. Not the \"Edit & Resend\" action on ordinary messages.", + "pages.chat.userMessage.steer_queue_instead": "Small text button beside the muted \"Steering…\" indicator, next to the \"…\" menu that holds \"Edit\" and \"Unsend\": takes the not-yet-read message back from the running turn and puts it in the queue, so it is sent after the agent finishes instead of redirecting the current work. \"Queue\" is the verb (put in the waiting line), the same meaning as the composer's \"Queue message\" button. Keep it short.", + "pages.chat.userMessage.steer_take_back_more": "Accessible name and tooltip of the small \"…\" menu button beside \"Queue instead\" on a chat message the agent has not read yet. The menu holds the \"Edit\" and \"Unsend\" actions, so the label names both: imperative verbs joined by \"or\".", + "pages.chat.userMessage.steer_taking_back": "Muted progress line that replaces the \"Steering…\" indicator and its controls while the dashboard waits to learn whether the agent dropped the message before reading it. Progressive form, \"it\" is the user's message.", + "pages.chat.userMessage.steer_retracted_unsend": "Muted one-line note left in the chat transcript where a message used to be, after the user unsent it before the agent read it. The message itself is no longer shown. Past tense, addressed to the user, like \"You unsent a message\" in chat apps.", + "pages.chat.userMessage.steer_retracted_edit": "Same muted note as \"You unsent this message\", for the case where the user took the message back to edit it: its text went back into the message box, the text field where the user types messages. Use the product's usual term for that field. The note is a link-styled button that moves focus to the message box. Past tense, addressed to the user.", + "pages.chat.userMessage.steer_retracted_queue": "Same muted note as \"You unsent this message\", for the case where the user moved the message to the queue: it now waits to be sent after the agent finishes. \"Queue\" is the waiting line of messages, as in the composer's \"Queue message\" button. Past tense, addressed to the user.", + "pages.chat.userMessage.steer_take_back_busy": "Muted line under the \"Steering…\" indicator when taking a message back was refused because another message is still being sent or taken back at that moment. Nothing changed; the user can press the button again. Two short sentences.", + "pages.chat.userMessage.steer_take_back_unsupported": "Muted line under the \"Steering…\" indicator when the agent the chat runs on refused to drop a message it has not read. The message stays and the agent will read it. \"Agent\" is the AI agent the chat talks to.", + "pages.chat.userMessage.steer_take_back_failed": "Muted line under the \"Steering…\" indicator when taking a message back failed for any other reason (for example the request did not reach the server). Nothing changed. Short.", + "pages.chat.userMessage.steer_take_back_unconfirmed": "Error notice under the \"Taking it back…\" progress line when the request failed before the server confirmed whether the message was removed. Nothing is needed from the user: the row stays pending and updates by itself when the agent reaches the message. \"This\" is that status on the row and \"it\" is the user's message. Two short sentences.", "pages.chat.userMessage.turn_ended_before_this_applied_runs_as_its_own_message": "Muted one-line explanation above a chat message the user tried to redirect into the agent's running turn, shown when that turn ended before the redirect applied and the message therefore runs as its own, separate message afterwards. \"Turn\" means one conversation turn (the agent's current run), not a game move or a waiting-line turn. Use a state-neutral present tense: at render time the follow-up message may not have executed yet, and the same line is also read later in history.", "pages.chat.sideChat.off_record_marker": "Small label at the top of the Side Chat composer band (the tinted box where a side question is typed), beside the same icon as the \"Ask about this\" toolbar button. Two parts split by a middle dot: the panel's own name (\"Side Chat\" — translate it the same way as pages.chat.sidePanel.menu_side) and a reassurance that text typed here is NOT saved into the main conversation. Keep it to one short line.", "pages.chatPage.open_folder": "aria-label and tooltip on the folder chip inside a SENT chat message. Clicking opens that directory in the side panel's file tree; {{path}} is the absolute directory path. Mirror the phrasing of pages.chatPage.open_file.", diff --git a/website/src/i18n/locales/bn.json b/website/src/i18n/locales/bn.json index 89a8bda4253..5a8a783157a 100644 --- a/website/src/i18n/locales/bn.json +++ b/website/src/i18n/locales/bn.json @@ -11844,6 +11844,18 @@ "copied": "কপি হয়েছে", "copy_failed": "কপি করা যায়নি", "steering": "স্টিয়ার করা হচ্ছে…", + "steer_unsend": "ফিরিয়ে নাও", + "steer_edit": "সম্পাদনা করো", + "steer_queue_instead": "পরিবর্তে সারিতে রাখো", + "steer_take_back_more": "সম্পাদনা করো বা ফিরিয়ে নাও", + "steer_taking_back": "ফিরিয়ে নেওয়া হচ্ছে…", + "steer_retracted_unsend": "তুমি এই বার্তাটি ফিরিয়ে নিয়েছ", + "steer_retracted_edit": "তুমি এই বার্তাটি আবার তোমার বার্তা বাক্সে ফিরিয়ে এনেছ", + "steer_retracted_queue": "তুমি এই বার্তাটি সারিতে সরিয়েছ", + "steer_take_back_busy": "আরেকটি বার্তা এখনও পাঠানো হচ্ছে। আবার চেষ্টা করো।", + "steer_take_back_unsupported": "এই এজেন্ট পাঠানো বার্তা ফিরিয়ে নিতে পারে না।", + "steer_take_back_failed": "ফিরিয়ে নেওয়া যায়নি।", + "steer_take_back_unconfirmed": "এটি ফিরিয়ে নেওয়া হয়েছে কিনা নিশ্চিত করা যায়নি। এজেন্ট বার্তাটিতে পৌঁছালে এটি হালনাগাদ হবে।", "delivery_pending": "ডেলিভারি অপেক্ষমাণ…", "redirecting_the_running_turn_not_yet_confirmed": "চলমান টার্ন পুনর্নির্দেশ করা হচ্ছে — এখনও নিশ্চিত হয়নি।", "turn_ended_before_this_applied_runs_as_its_own_message": "এটি প্রযোজ্য হওয়ার আগেই টার্ন শেষ হয়ে গেছে — এটি একটি আলাদা বার্তা হিসেবে চলে।", diff --git a/website/src/i18n/locales/de.json b/website/src/i18n/locales/de.json index cbd1b7bcd43..525d8795035 100644 --- a/website/src/i18n/locales/de.json +++ b/website/src/i18n/locales/de.json @@ -11844,6 +11844,18 @@ "copied": "Kopiert", "copy_failed": "Kopieren fehlgeschlagen", "steering": "Wird eingesteuert…", + "steer_unsend": "Zurückholen", + "steer_edit": "Bearbeiten", + "steer_queue_instead": "Stattdessen einreihen", + "steer_take_back_more": "Bearbeiten oder zurückholen", + "steer_taking_back": "Wird zurückgeholt…", + "steer_retracted_unsend": "Du hast diese Nachricht zurückgeholt", + "steer_retracted_edit": "Du hast diese Nachricht zurück in dein Eingabefeld geholt", + "steer_retracted_queue": "Du hast diese Nachricht in die Warteschlange verschoben", + "steer_take_back_busy": "Eine andere Nachricht wird noch gesendet. Versuche es erneut.", + "steer_take_back_unsupported": "Dieser Agent kann eine gesendete Nachricht nicht zurückholen.", + "steer_take_back_failed": "Konnte nicht zurückgeholt werden.", + "steer_take_back_unconfirmed": "Es konnte nicht bestätigt werden, dass die Nachricht zurückgeholt wurde. Das aktualisiert sich, sobald der Agent sie erreicht.", "delivery_pending": "Zustellung ausstehend…", "redirecting_the_running_turn_not_yet_confirmed": "Der laufende Zug wird umgeleitet — noch nicht bestätigt.", "turn_ended_before_this_applied_runs_as_its_own_message": "Der Zug endete, bevor dies angewendet wurde — wird als eigene Nachricht ausgeführt.", diff --git a/website/src/i18n/locales/en-XA.json b/website/src/i18n/locales/en-XA.json index 99fb30040ef..4c67cfa5082 100644 --- a/website/src/i18n/locales/en-XA.json +++ b/website/src/i18n/locales/en-XA.json @@ -11456,6 +11456,18 @@ "copy_failed": "[Çøþý ƒàìĺèð ··········]", "redirecting_the_running_turn_not_yet_confirmed": "[Ŕèðìŕèçţìñğ ţĥè ŕùññìñğ ţùŕñ — ñøţ ýèţ çøñƒìŕɱèð. ·························]", "steering": "[Şţèèŕìñğ… ··············]", + "steer_unsend": "[Ùñşèñð ·········]", + "steer_edit": "[Èðìţ ······]", + "steer_queue_instead": "[Ǫùèùè ìñşţèàð ············]", + "steer_take_back_more": "[Èðìţ øŕ ùñşèñð ·············]", + "steer_taking_back": "[Ţàķìñğ ìţ ƀàçķ… ··············]", + "steer_retracted_unsend": "[Ýøù ùñşèñţ ţĥìş ɱèşşàğè ················]", + "steer_retracted_edit": "[Ýøù ɱøṽèð ţĥìş ɱèşşàğè ƀàçķ ţø ýøùŕ ɱèşşàğè ƀøẋ ························]", + "steer_retracted_queue": "[Ýøù ɱøṽèð ţĥìş ɱèşşàğè ţø ţĥè ǫùèùè ··················]", + "steer_take_back_busy": "[Àñøţĥèŕ ɱèşşàğè ìş şţìĺĺ øñ ìţş ẁàý. Ţŕý àğàìñ. ························]", + "steer_take_back_unsupported": "[Ţĥìş àğèñţ çàñ'ţ ţàķè ƀàçķ à şèñţ ɱèşşàğè. ·····················]", + "steer_take_back_failed": "[Çøùĺðñ'ţ ţàķè ìţ ƀàçķ. ···············]", + "steer_take_back_unconfirmed": "[Çøùĺðñ'ţ çøñƒìŕɱ ìţ ẁàş ţàķèñ ƀàçķ. Ţĥìş ùþðàţèş ẁĥèñ ţĥè àğèñţ ŕèàçĥèş ìţ. ·······················]", "delivery_pending": "[Ðèĺìṽèŕý þèñðìñğ… ···············]", "turn_ended_before_this_applied_runs_as_its_own_message": "[Ţùŕñ èñðèð ƀèƒøŕè ţĥìş àþþĺìèð — ŕùñş àş ìţş øẁñ ɱèşşàğè. ····················]", "quote_message": "[Ǫùøţè ɱèşşàğè ············]", diff --git a/website/src/i18n/locales/en.manual.json b/website/src/i18n/locales/en.manual.json index a6de2ac5a9a..48d48df6611 100644 --- a/website/src/i18n/locales/en.manual.json +++ b/website/src/i18n/locales/en.manual.json @@ -5407,6 +5407,18 @@ "copy_failed": "Copy failed", "redirecting_the_running_turn_not_yet_confirmed": "Redirecting the running turn — not yet confirmed.", "steering": "Steering…", + "steer_unsend": "Unsend", + "steer_edit": "Edit", + "steer_queue_instead": "Queue instead", + "steer_take_back_more": "Edit or unsend", + "steer_taking_back": "Taking it back…", + "steer_retracted_unsend": "You unsent this message", + "steer_retracted_edit": "You moved this message back to your message box", + "steer_retracted_queue": "You moved this message to the queue", + "steer_take_back_busy": "Another message is still on its way. Try again.", + "steer_take_back_unsupported": "This agent can't take back a sent message.", + "steer_take_back_failed": "Couldn't take it back.", + "steer_take_back_unconfirmed": "Couldn't confirm it was taken back. This updates when the agent reaches it.", "delivery_pending": "Delivery pending…", "turn_ended_before_this_applied_runs_as_its_own_message": "Turn ended before this applied — runs as its own message.", "quote_message": "Quote message", diff --git a/website/src/i18n/locales/es.json b/website/src/i18n/locales/es.json index 7fad38ff26d..584f2a1e74e 100644 --- a/website/src/i18n/locales/es.json +++ b/website/src/i18n/locales/es.json @@ -12026,6 +12026,18 @@ "copied": "Copiado", "copy_failed": "No se pudo copiar", "steering": "Redirigiendo…", + "steer_unsend": "Deshacer envío", + "steer_edit": "Editar", + "steer_queue_instead": "Poner en cola", + "steer_take_back_more": "Editar o deshacer el envío", + "steer_taking_back": "Retirándolo…", + "steer_retracted_unsend": "Anulaste el envío de este mensaje", + "steer_retracted_edit": "Devolviste este mensaje a tu cuadro de mensaje", + "steer_retracted_queue": "Moviste este mensaje a la cola", + "steer_take_back_busy": "Otro mensaje todavía se está enviando. Inténtalo de nuevo.", + "steer_take_back_unsupported": "Este agente no puede retirar un mensaje enviado.", + "steer_take_back_failed": "No se pudo retirar.", + "steer_take_back_unconfirmed": "No se pudo confirmar que se retiró. Esto se actualiza cuando el agente llegue al mensaje.", "delivery_pending": "Entrega pendiente…", "redirecting_the_running_turn_not_yet_confirmed": "Redirigiendo el turno en curso — aún sin confirmar.", "turn_ended_before_this_applied_runs_as_its_own_message": "El turno terminó antes de que esto se aplicara — se ejecuta como un mensaje independiente.", diff --git a/website/src/i18n/locales/fr.json b/website/src/i18n/locales/fr.json index d84851f28f7..be3589aad87 100644 --- a/website/src/i18n/locales/fr.json +++ b/website/src/i18n/locales/fr.json @@ -12026,6 +12026,18 @@ "copied": "Copié", "copy_failed": "Échec de la copie", "steering": "Réorientation en cours…", + "steer_unsend": "Annuler l’envoi", + "steer_edit": "Modifier", + "steer_queue_instead": "Mettre en file d’attente", + "steer_take_back_more": "Modifier ou annuler l’envoi", + "steer_taking_back": "Retrait en cours…", + "steer_retracted_unsend": "Tu as annulé l’envoi de ce message", + "steer_retracted_edit": "Tu as remis ce message dans ta zone de message", + "steer_retracted_queue": "Tu as déplacé ce message dans la file d’attente", + "steer_take_back_busy": "Un autre message est encore en cours d’envoi. Réessaie.", + "steer_take_back_unsupported": "Cet agent ne peut pas retirer un message envoyé.", + "steer_take_back_failed": "Impossible de le retirer.", + "steer_take_back_unconfirmed": "Impossible de confirmer son retrait. Ceci se met à jour dès que l’agent l’atteint.", "delivery_pending": "Envoi en attente…", "redirecting_the_running_turn_not_yet_confirmed": "Réorientation du tour en cours — pas encore confirmé.", "turn_ended_before_this_applied_runs_as_its_own_message": "Le tour s'est terminé avant que ceci ne s'applique — s'exécute comme un message distinct.", diff --git a/website/src/i18n/locales/hi.json b/website/src/i18n/locales/hi.json index 6a5972982b7..c189e5b70fa 100644 --- a/website/src/i18n/locales/hi.json +++ b/website/src/i18n/locales/hi.json @@ -11844,6 +11844,18 @@ "copied": "कॉपी हो गया", "copy_failed": "कॉपी नहीं हो सका", "steering": "निर्देशित किया जा रहा है…", + "steer_unsend": "वापस लो", + "steer_edit": "संपादित करो", + "steer_queue_instead": "इसके बजाय कतार में डालो", + "steer_take_back_more": "संपादित करो या वापस लो", + "steer_taking_back": "वापस लिया जा रहा है…", + "steer_retracted_unsend": "तुमने यह संदेश वापस ले लिया", + "steer_retracted_edit": "तुमने यह संदेश वापस अपने संदेश बॉक्स में डाल दिया", + "steer_retracted_queue": "तुमने यह संदेश कतार में डाल दिया", + "steer_take_back_busy": "एक और संदेश अभी भेजा जा रहा है। फिर से कोशिश करो।", + "steer_take_back_unsupported": "यह एजेंट भेजा गया संदेश वापस नहीं ले सकता।", + "steer_take_back_failed": "वापस नहीं लिया जा सका।", + "steer_take_back_unconfirmed": "यह पुष्टि नहीं हो सकी कि इसे वापस लिया गया था। एजेंट के संदेश तक पहुँचने पर यह अपडेट हो जाएगा।", "delivery_pending": "डिलीवरी लंबित…", "redirecting_the_running_turn_not_yet_confirmed": "चल रहे टर्न को पुनर्निर्देशित किया जा रहा है — अभी पुष्टि नहीं हुई है।", "turn_ended_before_this_applied_runs_as_its_own_message": "यह लागू होने से पहले टर्न समाप्त हो गया — यह एक अलग संदेश के रूप में चलता है।", diff --git a/website/src/i18n/locales/it.json b/website/src/i18n/locales/it.json index 7d98c386991..168269d313a 100644 --- a/website/src/i18n/locales/it.json +++ b/website/src/i18n/locales/it.json @@ -12026,6 +12026,18 @@ "copied": "Copiato", "copy_failed": "Copia non riuscita", "steering": "Indirizzamento in corso…", + "steer_unsend": "Annulla invio", + "steer_edit": "Modifica", + "steer_queue_instead": "Metti in coda", + "steer_take_back_more": "Modifica o annulla invio", + "steer_taking_back": "Ritiro in corso…", + "steer_retracted_unsend": "Hai annullato l’invio di questo messaggio", + "steer_retracted_edit": "Hai riportato questo messaggio nella tua casella del messaggio", + "steer_retracted_queue": "Hai spostato questo messaggio nella coda", + "steer_take_back_busy": "Un altro messaggio è ancora in fase di invio. Riprova.", + "steer_take_back_unsupported": "Questo agente non può ritirare un messaggio inviato.", + "steer_take_back_failed": "Impossibile ritirarlo.", + "steer_take_back_unconfirmed": "Non è stato possibile confermare il ritiro. Questo si aggiorna quando l’agente raggiunge il messaggio.", "delivery_pending": "Consegna in attesa…", "redirecting_the_running_turn_not_yet_confirmed": "Reindirizzamento del turno in corso — non ancora confermato.", "turn_ended_before_this_applied_runs_as_its_own_message": "Il turno è terminato prima che questo venisse applicato — viene eseguito come messaggio separato.", diff --git a/website/src/i18n/locales/ja.json b/website/src/i18n/locales/ja.json index 0005ca613f0..70c6470c28f 100644 --- a/website/src/i18n/locales/ja.json +++ b/website/src/i18n/locales/ja.json @@ -11662,6 +11662,18 @@ "copied": "コピーしました", "copy_failed": "コピーできませんでした", "steering": "追加しています…", + "steer_unsend": "送信を取り消す", + "steer_edit": "編集", + "steer_queue_instead": "代わりにキューに追加", + "steer_take_back_more": "編集または送信を取り消す", + "steer_taking_back": "取り消しています…", + "steer_retracted_unsend": "このメッセージの送信を取り消しました", + "steer_retracted_edit": "このメッセージをメッセージ欄に戻しました", + "steer_retracted_queue": "このメッセージをキューに移動しました", + "steer_take_back_busy": "別のメッセージを送信中です。もう一度お試しください。", + "steer_take_back_unsupported": "このエージェントは送信済みのメッセージを取り消せません。", + "steer_take_back_failed": "取り消せませんでした。", + "steer_take_back_unconfirmed": "取り消されたことを確認できませんでした。エージェントがこのメッセージに到達すると表示が更新されます。", "delivery_pending": "送信確認待ち…", "redirecting_the_running_turn_not_yet_confirmed": "実行中のターンをリダイレクトしています — まだ確認されていません。", "turn_ended_before_this_applied_runs_as_its_own_message": "適用される前にターンが終了したため、別のメッセージとして実行されます。", diff --git a/website/src/i18n/locales/ko.json b/website/src/i18n/locales/ko.json index a7e5c94fc62..3f36334fa7b 100644 --- a/website/src/i18n/locales/ko.json +++ b/website/src/i18n/locales/ko.json @@ -11662,6 +11662,18 @@ "copied": "복사되었습니다", "copy_failed": "복사하지 못했습니다", "steering": "추가하는 중…", + "steer_unsend": "보내기 취소", + "steer_edit": "편집", + "steer_queue_instead": "대신 대기열에 추가", + "steer_take_back_more": "편집 또는 보내기 취소", + "steer_taking_back": "취소하는 중…", + "steer_retracted_unsend": "이 메시지의 보내기를 취소했습니다", + "steer_retracted_edit": "이 메시지를 메시지 입력란으로 다시 옮겼습니다", + "steer_retracted_queue": "이 메시지를 대기열로 옮겼습니다", + "steer_take_back_busy": "다른 메시지를 아직 보내는 중입니다. 다시 시도하세요.", + "steer_take_back_unsupported": "이 에이전트는 보낸 메시지를 취소할 수 없습니다.", + "steer_take_back_failed": "취소하지 못했습니다.", + "steer_take_back_unconfirmed": "메시지가 취소되었는지 확인하지 못했습니다. 에이전트가 메시지에 도달하면 이 내용이 업데이트됩니다.", "delivery_pending": "전송 대기 중…", "redirecting_the_running_turn_not_yet_confirmed": "실행 중인 턴을 리디렉션하는 중 — 아직 확인되지 않았습니다.", "turn_ended_before_this_applied_runs_as_its_own_message": "적용되기 전에 턴이 종료되어 별도의 메시지로 실행됩니다.", diff --git a/website/src/i18n/locales/pt.json b/website/src/i18n/locales/pt.json index 4c846d1338c..8fb0d4d91ff 100644 --- a/website/src/i18n/locales/pt.json +++ b/website/src/i18n/locales/pt.json @@ -12026,6 +12026,18 @@ "copied": "Copiado", "copy_failed": "Falha ao copiar", "steering": "Direcionando…", + "steer_unsend": "Cancelar envio", + "steer_edit": "Editar", + "steer_queue_instead": "Enfileirar em vez disso", + "steer_take_back_more": "Editar ou cancelar envio", + "steer_taking_back": "Retirando…", + "steer_retracted_unsend": "Você cancelou o envio desta mensagem", + "steer_retracted_edit": "Você devolveu esta mensagem para a sua caixa de mensagem", + "steer_retracted_queue": "Você moveu esta mensagem para a fila", + "steer_take_back_busy": "Outra mensagem ainda está sendo enviada. Tente novamente.", + "steer_take_back_unsupported": "Este agente não consegue retirar uma mensagem enviada.", + "steer_take_back_failed": "Não foi possível retirá-la.", + "steer_take_back_unconfirmed": "Não foi possível confirmar que a mensagem foi retirada. Isto é atualizado quando o agente chegar a ela.", "delivery_pending": "Entrega pendente…", "redirecting_the_running_turn_not_yet_confirmed": "Redirecionando o turno em andamento — ainda não confirmado.", "turn_ended_before_this_applied_runs_as_its_own_message": "O turno terminou antes de isto ser aplicado — é executado como uma mensagem separada.", diff --git a/website/src/i18n/locales/ru.json b/website/src/i18n/locales/ru.json index a5c6cb0b9c5..715e8861916 100644 --- a/website/src/i18n/locales/ru.json +++ b/website/src/i18n/locales/ru.json @@ -12208,6 +12208,18 @@ "copied": "Скопировано", "copy_failed": "Не удалось скопировать", "steering": "Направляется…", + "steer_unsend": "Отозвать", + "steer_edit": "Изменить", + "steer_queue_instead": "В очередь", + "steer_take_back_more": "Изменить или отозвать", + "steer_taking_back": "Отзывается…", + "steer_retracted_unsend": "Сообщение отозвано", + "steer_retracted_edit": "Сообщение возвращено в поле сообщения", + "steer_retracted_queue": "Сообщение перенесено в очередь", + "steer_take_back_busy": "Другое сообщение ещё отправляется. Повтори попытку.", + "steer_take_back_unsupported": "Этот агент не может отозвать отправленное сообщение.", + "steer_take_back_failed": "Не удалось отозвать.", + "steer_take_back_unconfirmed": "Не удалось подтвердить, что сообщение отозвано. Это обновится, когда агент дойдёт до него.", "delivery_pending": "Ожидание доставки...", "redirecting_the_running_turn_not_yet_confirmed": "Перенаправление выполняющегося хода — ещё не подтверждено.", "turn_ended_before_this_applied_runs_as_its_own_message": "Ход завершился до того, как это применилось, — сообщение выполняется отдельно.", diff --git a/website/src/i18n/locales/zh-CN.json b/website/src/i18n/locales/zh-CN.json index 21c5aa86975..cde90d179f2 100644 --- a/website/src/i18n/locales/zh-CN.json +++ b/website/src/i18n/locales/zh-CN.json @@ -11662,6 +11662,18 @@ "copied": "已复制", "copy_failed": "复制失败", "steering": "正在引导…", + "steer_unsend": "撤回", + "steer_edit": "编辑", + "steer_queue_instead": "改为排队", + "steer_take_back_more": "编辑或撤回", + "steer_taking_back": "正在撤回…", + "steer_retracted_unsend": "你撤回了这条消息", + "steer_retracted_edit": "你把这条消息移回了消息框", + "steer_retracted_queue": "你把这条消息移到了队列中", + "steer_take_back_busy": "另一条消息仍在发送中,稍后重试。", + "steer_take_back_unsupported": "此代理无法撤回已发送的消息。", + "steer_take_back_failed": "无法撤回。", + "steer_take_back_unconfirmed": "无法确认消息是否已撤回。代理处理到这条消息时,此处会更新。", "delivery_pending": "正在等待送达…", "redirecting_the_running_turn_not_yet_confirmed": "正在重定向正在运行的这一轮——尚未确认。", "turn_ended_before_this_applied_runs_as_its_own_message": "这一轮在此生效前已结束,此消息作为独立消息运行。", diff --git a/website/src/pages/ChatPage.transcriptRendering.test.tsx b/website/src/pages/ChatPage.transcriptRendering.test.tsx index 00c81abc7c6..ae6a2fa7ef4 100644 --- a/website/src/pages/ChatPage.transcriptRendering.test.tsx +++ b/website/src/pages/ChatPage.transcriptRendering.test.tsx @@ -45,7 +45,8 @@ function props(over: Partial = {}): Props { handleAsk: undefined, handleQuote: fn(), handleEditResend: fn(), editLast: null, farmPassRef: { current: false }, handleEditConsumed: fn(), renderUserContentCb: () => , transcriptHot: false, toggleAct: fn(), handleSpeak: fn(), handleApplyPlan: vi.fn(async () => true), renderedDisplayItems: [], stableMsgKey: (m: ChatMessage) => String(m.ts), handleLoadEarlier: fn(), isPinned: () => false, - handleJumpToQuote: fn(), handleTogglePinForMessage: fn(), + handleJumpToQuote: fn(), handleTogglePinForMessage: fn(), handleSteerAction: fn(), + steerActionPending: {}, steerActionRefusals: {}, steerActionRequestFailures: {}, ...over, } } diff --git a/website/src/pages/ChatPage.tsx b/website/src/pages/ChatPage.tsx index c6a0403acab..dade4986316 100644 --- a/website/src/pages/ChatPage.tsx +++ b/website/src/pages/ChatPage.tsx @@ -836,6 +836,7 @@ export default function ChatPage({ mode, embedded, embedMode, popout, noUrlSync const { queuedMessages, systemDeliveryCount, steer, stopTurn, keepQuestionAnswer, answerQuestionCard, handleCancelQueued, handleInterruptQueued, handleEditQueued, handleReorderQueued, queuePendingIds, + handleSteerAction, steerActionPending, steerActionRefusals, steerActionRequestFailures, } = useBusyTurnControls({ activeSlot, messageQuote, @@ -844,6 +845,7 @@ export default function ChatPage({ mode, embedded, embedMode, popout, noUrlSync activeSlotRef, slotRunning, messages, + transcriptAuthoritative: !slotLoading && slotSwitchTarget !== activeSlot, send, steerMutation, composerRef, @@ -903,7 +905,7 @@ export default function ChatPage({ mode, embedded, embedMode, popout, noUrlSync approve, dismissApproval, revealAppInPanel, lastTextIdx, regenerating, handleRegenerate, continuable, continuing, freshSession, handleContinue, handleFreshSession, interrupted, openDefaultModelSetting, openKiroSignIn, openMemberCapabilities, openModelPickerFromError, openTurnLimitSetting, quoteWholeMessage, handleAsk, handleQuote, handleEditResend, editLast, farmPassRef, handleEditConsumed, renderUserContentCb, transcriptHot, toggleAct, handleSpeak, handleApplyPlan, renderedDisplayItems, stableMsgKey, handleLoadEarlier, isPinned, handleJumpToQuote, - handleTogglePinForMessage, + handleTogglePinForMessage, handleSteerAction, steerActionPending, steerActionRefusals, steerActionRequestFailures, }) const { mobileSessions, drawerMounted, drawerX, drawerPanelRef, drawerScrimRef, sideOverlayX, sideOverlayPanelRef, sideOverlayPhase, drawerScrim, openSidebar, closedSessionActions, sidebarFolderActions, diff --git a/website/src/pages/chat/UserMessage.tsx b/website/src/pages/chat/UserMessage.tsx index 49c236618d2..8ea1f5fca4c 100644 --- a/website/src/pages/chat/UserMessage.tsx +++ b/website/src/pages/chat/UserMessage.tsx @@ -1,11 +1,12 @@ import { memo, useState, useRef, useEffect, useCallback, type ReactNode } from 'react' import { motion } from 'framer-motion' -import { Pencil, Send, Copy, Check, Link2, MessageSquare, Target, Pin, PinOff, X, Clock, Quote, MoreHorizontal } from 'lucide-react' +import { Pencil, Send, Copy, Check, Link2, MessageSquare, Target, Pin, PinOff, X, Clock, Quote, MoreHorizontal, Undo2, ArrowUpFromLine } from 'lucide-react' import { copyToClipboard } from '../../utils/clipboard' import { copySessionLink } from '../../utils/shareUrl' import { ICON_ACTION_ROW_CLS } from '../../utils/touchActions' import { useSearchHighlight, useCurrentOcc } from '../../hooks/SearchHighlightContext' import { useImeGuard } from '../../hooks/useImeGuard' +import { useReducedMotion } from '../../hooks/useReducedMotion' import { applySearchHighlights, clearSearchHighlights } from '../../utils/domHighlight' import { scrollCurrentMatchIntoView } from '../../utils/searchScroll' import { containedSelectionRange } from '../../utils/selectionContainment' @@ -15,12 +16,15 @@ import { i18nT } from '../../i18n/t' import { useLanguageGeneration } from '../../i18n/useLanguageGeneration' import InfoTip from '../../components/InfoTip' import ErrorNotice from '../../components/ErrorNotice' +import { Btn } from '../../components/ui' import SteerDecisionLine from './SteerDecisionLine' import QuoteCard from './QuoteCard' import MessageContextMenu, { type MessageMenuItem } from './MessageContextMenu' import { prependQuote, quoteBlock, readMessageQuote, stripQuoteBlock, type MessageQuote } from '../../chat-core/composer/messageQuote' import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '../../components/ui/dropdown-menu' import { readSteerRecord } from './decisionRecord' +import { revealComposer } from './composerFocus' +import type { SteerRetractAction, SteerRetractRefusal } from '../../hooks/useSteerRetract' // Steer bubbles play a one-shot entrance (slide-in + ring pulse) when they land. // The chat transcript is virtualized, so a row can remount when scrolled away and // back; without this guard the entrance would replay every time. Module-level set @@ -99,9 +103,19 @@ interface UserMessageProps { /** Scroll to the message this row quotes (`meta.quote`). Absent, the card is * drawn but is not a control. */ onJumpToQuote?: (quote: MessageQuote) => void + /** Take this steer back before the running turn reads it, by its row `mid`. + * The controls are drawn only on a pending steer whose row says it can be taken + * back (`meta.steerRetract`), which the gateway stamps where the harness can. */ + onSteerAction?: (mid: string, action: SteerRetractAction) => void + /** The take-back in flight for this row: its controls give way to a progress line. */ + steerActionPending?: SteerRetractAction + /** Why this row's last take-back did not happen. */ + steerActionRefusal?: SteerRetractRefusal + /** The take-back request failed before its outcome was known. */ + steerActionRequestFailed?: boolean } -const UserMessage = memo(function UserMessage({ content, meta, timestamp, timestampTitle, renderContent, canEdit, messageIndex, messageTs, onEditResend, doubleClickToEdit = false, slotKey, slotTitle, mode, pinned, onTogglePin, onReplyInThread, slotRunning, hideSteerBadge, tone = 'default', editRequest, onEditConsumed, onQuoteMessage, onJumpToQuote }: UserMessageProps) { +const UserMessage = memo(function UserMessage({ content, meta, timestamp, timestampTitle, renderContent, canEdit, messageIndex, messageTs, onEditResend, doubleClickToEdit = false, slotKey, slotTitle, mode, pinned, onTogglePin, onReplyInThread, slotRunning, hideSteerBadge, tone = 'default', editRequest, onEditConsumed, onQuoteMessage, onJumpToQuote, onSteerAction, steerActionPending, steerActionRefusal, steerActionRequestFailed }: UserMessageProps) { useLanguageGeneration() // memo() bails out of the provider-level repaint; subscribe directly const [editing, setEditing] = useState(false) const ime = useImeGuard() @@ -188,6 +202,14 @@ const UserMessage = memo(function UserMessage({ content, meta, timestamp, timest // steer/queue vocabulary the steer-only surface exists to hide. const pendingSteer = !hideSteerBadge && steerMeta && !!slotRunning && (steerState === 'written' || (steerOptimistic && !steerState)) const requeuedSteer = !hideSteerBadge && steerMeta && steerState === 'requeued' + // Taken back before the turn read it: the row keeps its place in the transcript + // and says what became of the text, but draws no bubble -- the agent never saw it. + const retractedAction = steerMeta && steerState === 'retracted' + ? ((meta as { steerRetractAction?: string }).steerRetractAction || 'unsend') + : null + const steerMid = typeof (meta as { mid?: unknown } | undefined)?.mid === 'string' ? (meta as { mid: string }).mid : '' + const steerTakeBack = pendingSteer && !!onSteerAction && !!steerMid + && (meta as { steerRetract?: unknown } | undefined)?.steerRetract === true // Fired from an EFFECT rather than a `useState` initializer, because the state // this depends on arrives AFTER mount. The optimistic bubble mounts with // `{ steer: true, optimistic: true }` and no `steerState`, so `isSteer` is @@ -353,6 +375,82 @@ const UserMessage = memo(function UserMessage({ content, meta, timestamp, timest // the read-only and editing renders. const handleDoubleClick = useCallback(() => { startEdit() }, [startEdit]) + // A take-back settles the row in place: the bubble the person was looking at + // shrinks away and the note fades in at the same spot, so the row reads as one + // element changing. The server clears a retracted row's text, so the bubble + // the collapse draws is the body this row last showed while it was pending. + // Only a row that was mounted pending plays it; a row read back already + // retracted (history, reload) is drawn as the note from the start. + const reducedMotion = useReducedMotion() + const [mountedRetracted] = useState(() => !!retractedAction) + const [collapseDone, setCollapseDone] = useState(false) + const lastBubbleBodyRef = useRef(carriedQuote ? stripQuoteBlock(content, carriedQuote) : content) + useEffect(() => { + if (!retractedAction) lastBubbleBodyRef.current = carriedQuote ? stripQuoteBlock(content, carriedQuote) : content + }, [retractedAction, content, carriedQuote]) + + if (retractedAction) { + const settleAnimated = !mountedRetracted && !reducedMotion + const retractedIcon = retractedAction === 'queue' + ?