From 66ba4e511489bb6dcfffe56fe742a23cdab1aa40 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:39:39 -0700 Subject: [PATCH] test(tui_gateway): pin that a live-turn rewind is not redirected; document the 4009 contract The contributor test covers the queue fall-through; the Desktop's normal case is a redirect-capable AIAgent, where busy_input_mode=interrupt turned the edit into a mid-turn redirect and left the un-edited transcript in place. Pin that branch too, and document in the rewind section that a truncating submit refuses with 4009 while a turn runs so hosts interrupt + retry (the Desktop already does). --- tests/tui_gateway/test_tui_gateway_server.py | 22 +++++++++++++++++++ .../programmatic-integration.md | 2 ++ 2 files changed, 24 insertions(+) diff --git a/tests/tui_gateway/test_tui_gateway_server.py b/tests/tui_gateway/test_tui_gateway_server.py index 43bc506e42..fea556633a 100644 --- a/tests/tui_gateway/test_tui_gateway_server.py +++ b/tests/tui_gateway/test_tui_gateway_server.py @@ -13399,6 +13399,28 @@ def test_prompt_submit_truncation_signals_busy_instead_of_queueing(monkeypatch): server._sessions.pop("sid", None) +def test_prompt_submit_truncation_refuses_redirect_of_live_turn(): + """The redirect-capable agent (the Desktop's normal case) is the branch that silently + absorbed the edit: `busy_input_mode=interrupt` turned the rewind into a mid-turn + redirect and left the un-edited transcript in place.""" + redirected = [] + agent = types.SimpleNamespace( + _supports_active_turn_redirect=True, redirect=lambda text: redirected.append(text) or True) + server._sessions["sid"] = _session(agent=agent, running=True, history=[{"role": "user", "content": "original"}]) + + try: + resp = server.handle_request({ + "id": "1", "method": "prompt.submit", + "params": {"session_id": "sid", "text": "edited while thinking", "truncate_before_user_ordinal": 0, + "confirm_truncate": True, "confirm_empty_truncate": True}}) + + assert resp.get("error", {}).get("code") == 4009 + assert redirected == [] + assert server._sessions["sid"]["history"] == [{"role": "user", "content": "original"}] + finally: + server._sessions.pop("sid", None) + + def test_prompt_submit_refuses_turn_when_truncate_persist_fails(monkeypatch): """If replace_messages fails during edit/regenerate truncate, do not run the turn. diff --git a/website/docs/developer-guide/programmatic-integration.md b/website/docs/developer-guide/programmatic-integration.md index 08def49235..45adae4604 100644 --- a/website/docs/developer-guide/programmatic-integration.md +++ b/website/docs/developer-guide/programmatic-integration.md @@ -72,6 +72,8 @@ A rewind / edit / regenerate is a `prompt.submit` that drops part of the stored A truncation parameter without `confirm_truncate` is refused with code `4004` or `4029` and nothing is written. Hosts that implement rewind must set the flag at the moment the user asks for it, and must never keep truncation parameters in state across ordinary submits. Prefer `truncate_before_row_id` (from resume `row_id` / `_row_id`) over ordinals; keep the ordinal as a back-compat / optimistic-row path only when no durable id is available yet. +A truncating submit is never absorbed by the busy-input policy. While a turn is still running, an ordinary `prompt.submit` is steered, redirected, or queued (`display.busy_input_mode`), but a rewind / edit / regenerate refuses with code `4009` (`session busy`) instead — queueing it would drop the history cut and run the edit as a plain follow-up after the un-edited turn. Hosts call `session.interrupt` and retry the same submit until it lands; the Desktop app does this automatically, so editing a message while Hermes is still thinking stops the live turn and reruns from the edited prompt. + On a successful truncating submit against a durable session, the `prompt.submit` result additionally carries `survivor_user_row_ids` — the fresh post-rewrite row IDs of the surviving user turns, in visible-user-ordinal order. The rewrite re-inserts the kept prefix as new rows, so every row ID the host cached before the rewind is stale afterward; rebind cached IDs from this list (a `null` entry means that turn has no durable ID — drop the cached one) or the next rewind targeting an older surviving turn will be refused with `4018`. ### Events streamed back