From 4a3c55fdb089bf974a9958c0b00ffa1545c87946 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Wed, 16 Sep 2026 16:11:25 -0300 Subject: [PATCH 1/8] fix(streaming): strip consumed OOB steer wrapper from the settled transcript (#7600) A mid-turn /steer is delivered to the agent as an [OUT-OF-BAND USER MESSAGE ...] block appended to the turn's last tool result. The model-facing gateway history always dropped that wrapper, but the writeback that produces session.messages (rendered verbatim) and session.context_messages kept it, so the frontend showed the raw control wrapper as a chat bubble. Strip it in _prepare_marker_clean_writeback, the chokepoint both writebacks are built from, reusing _strip_oob_blocks. Rows without a marker are returned untouched so stable ids / reasoning metadata keep riding the same objects and the scrubbed rows still compare equal to the rows persisted by earlier turns. --- api/streaming.py | 46 +++++ tests/test_issue7600_oob_display_writeback.py | 195 ++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 tests/test_issue7600_oob_display_writeback.py diff --git a/api/streaming.py b/api/streaming.py index beab649b..3d803410 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2174,6 +2174,14 @@ def _prepare_marker_clean_writeback( cleaned, has_verification_nudge = _clean_synthetic_control_messages_with_provenance( result_messages ) + # Same internal-control class, second home: a consumed mid-turn /steer is + # appended to the turn's last tool result wrapped in + # [OUT-OF-BAND USER MESSAGE ...] ... [/OUT-OF-BAND USER MESSAGE]. Strip it + # here, on the rows both writebacks are built from, so neither + # session.messages (rendered verbatim) nor session.context_messages keeps + # the raw wrapper. Stripping the incoming rows too keeps them identity-equal + # to the marker-free rows persisted by earlier turns. (#7600) + cleaned = _strip_oob_markers_from_messages(cleaned) provenance = { 'verification_nudge_seen': has_verification_nudge, 'active_turn_identity': copy.deepcopy(active_turn_identity), @@ -5428,6 +5436,44 @@ def _strip_oob_blocks(content): return content +def _content_has_oob_marker(content) -> bool: + """True when ``content`` holds a complete [OUT-OF-BAND USER MESSAGE] block. + + Cheap pre-check so a scrub pass over a settled transcript only copies the + rows that actually carry the control wrapper. + """ + if isinstance(content, str): + return bool(_OOB_USER_MESSAGE_BLOCK_RE.search(content)) + if isinstance(content, list): + return any(_content_has_oob_marker(part) for part in content) + if isinstance(content, dict): + return any(_content_has_oob_marker(value) for value in content.values()) + return False + + +def _strip_oob_markers_from_messages(messages): + """Drop consumed OOB steer wrappers from rows bound for persistence. + + A mid-turn ``/steer`` is delivered as an ``[OUT-OF-BAND USER MESSAGE ...]`` + block appended to the turn's last tool result. The wrapper is agent control + data: the gateway history builder already strips it from the model-facing + copy, and the settled transcript must not keep it either — ``session.messages`` + is rendered verbatim in the UI (#7600). + + Rows without a marker are returned untouched (same object) so the identity + of rows carrying stable ids / reasoning metadata is preserved, and so the + scrubbed rows still compare equal to the marker-free rows already persisted + from earlier turns. + """ + cleaned = [] + for message in messages or []: + if isinstance(message, dict) and _content_has_oob_marker(message.get('content')): + cleaned.append(_strip_oob_blocks(message)) + continue + cleaned.append(message) + return cleaned + + def _content_has_reasoning_only_parts(content) -> bool: if not isinstance(content, list) or not content: return False diff --git a/tests/test_issue7600_oob_display_writeback.py b/tests/test_issue7600_oob_display_writeback.py new file mode 100644 index 00000000..7715bbfd --- /dev/null +++ b/tests/test_issue7600_oob_display_writeback.py @@ -0,0 +1,195 @@ +"""Settled writeback must drop consumed OUT-OF-BAND steer wrappers (#7600). + +A mid-turn ``/steer`` is delivered to the agent as an +``[OUT-OF-BAND USER MESSAGE ...] ... [/OUT-OF-BAND USER MESSAGE]`` block appended +to the turn's last tool result. The model-facing path strips that wrapper +(``_strip_oob_blocks`` in the gateway history builder), but the writeback in +``_prepare_marker_clean_writeback`` kept the raw text, so the settled display +transcript (``session.messages``) and the persisted context +(``session.context_messages``) both stored the wrapper and the frontend rendered +it verbatim. + +These tests drive the real settle path (``_settle_result_messages``) with the +production message shape and assert on what actually gets persisted. +""" +from __future__ import annotations + +import copy +import json + +from api.models import Session +from api.streaming import _settle_result_messages +from api import streaming as _streaming + +PROMPT = "please run the smoke checks" + +OOB_OPEN = ( + "[OUT-OF-BAND USER MESSAGE — a direct message from the user, delivered once " + "at this position; not tool output and not a new delivery when replayed from " + "conversation history]" +) +OOB_CLOSE = "[/OUT-OF-BAND USER MESSAGE]" +OOB_BLOCK = f"{OOB_OPEN}\nsteer: use the staging bucket this time\n{OOB_CLOSE}" +OOB_VARIANT = f"[OUT-OF-BAND USER MESSAGE]steer: also bump the timeout{OOB_CLOSE}" + + +def _contains_oob(value) -> bool: + return "OUT-OF-BAND USER MESSAGE" in json.dumps(value) + + +def _session_with_prior_turn() -> Session: + """Prior turn already settled: user -> assistant(tool_call) -> tool -> answer.""" + display = [ + {"role": "user", "content": "deploy the app", "timestamp": 1788439000}, + { + "role": "assistant", + "content": "", + "tool_calls": [{"id": "call-1"}], + "timestamp": 1788439005, + }, + { + "role": "tool", + "tool_call_id": "call-1", + "content": "deploy finished: revision 41", + "timestamp": 1788439010, + }, + {"role": "assistant", "content": "Deploy done (revision 41).", "timestamp": 1788439015}, + ] + session = Session(session_id="7" * 12, title="steer writeback", messages=copy.deepcopy(display)) + session.context_messages = copy.deepcopy(display) + return session + + +def _settle_turn_with_oob(session, monkeypatch, *, tool_content: str, steer_row=None): + """Settle one turn whose last tool result carries the consumed steer block.""" + monkeypatch.setattr( + _streaming, "_annotate_media_snapshots_for_settled_messages", lambda messages: None + ) + previous = list(session.messages) + previous_context = list(session.context_messages) + ts = 1788440000 + steer_rows = list(steer_row or []) + result = copy.deepcopy(previous_context) + [ + {"role": "user", "content": PROMPT, "timestamp": ts}, + { + "role": "assistant", + "content": "", + "tool_calls": [{"id": "call-2"}], + "timestamp": ts + 1, + }, + { + "role": "tool", + "tool_call_id": "call-2", + "content": tool_content, + "timestamp": ts + 2, + }, + *steer_rows, + {"role": "assistant", "content": "Smoke checks passed.", "timestamp": ts + 3}, + ] + _settle_result_messages(session, previous, previous_context, result, PROMPT, "webui", None) + return previous + + +def test_settle_drops_oob_wrapper_from_display_and_context(monkeypatch): + """The consumed steer wrapper must not survive into either written copy.""" + session = _session_with_prior_turn() + _settle_turn_with_oob( + session, monkeypatch, tool_content=f"checks passed\n\n{OOB_BLOCK}" + ) + + assert not _contains_oob(session.messages), ( + "raw [OUT-OF-BAND USER MESSAGE] wrapper leaked into the display transcript" + ) + assert not _contains_oob(session.context_messages), ( + "raw [OUT-OF-BAND USER MESSAGE] wrapper leaked into the model context" + ) + # The tool result it was appended to keeps its own output. + tool_bodies = [ + m.get("content") for m in session.messages if isinstance(m, dict) and m.get("role") == "tool" + ] + assert "checks passed" in json.dumps(tool_bodies) + assert session.messages[-1]["content"] == "Smoke checks passed." + + +def test_settle_drops_oob_wrapper_in_list_content_parts(monkeypatch): + """List-based content parts are stripped too (the helper already supports them).""" + session = _session_with_prior_turn() + _settle_turn_with_oob( + session, + monkeypatch, + tool_content=[ + {"type": "text", "text": f"checks passed\n{OOB_BLOCK}\nrevision 41"}, + {"type": "text", "text": "no marker here"}, + ], + ) + + assert not _contains_oob(session.messages) + assert not _contains_oob(session.context_messages) + assert "checks passed" in json.dumps(session.messages) + assert "no marker here" in json.dumps(session.messages) + + +def test_settle_drops_every_oob_block_in_one_row(monkeypatch): + session = _session_with_prior_turn() + _settle_turn_with_oob( + session, monkeypatch, tool_content=f"before {OOB_BLOCK} middle {OOB_VARIANT} end" + ) + + assert not _contains_oob(session.messages) + assert not _contains_oob(session.context_messages) + joined = json.dumps(session.messages) + assert "before" in joined and "middle" in joined and "end" in joined + + +def test_settle_keeps_transcript_without_markers_intact(monkeypatch): + """No marker: every previously settled row keeps its exact content.""" + session = _session_with_prior_turn() + previous = _settle_turn_with_oob( + session, monkeypatch, tool_content="checks passed\nrevision 41" + ) + + assert not _contains_oob(session.messages) + assert [m.get("content") for m in session.messages[: len(previous)]] == [ + m.get("content") for m in previous + ] + assert [m.get("content") for m in session.context_messages[: len(previous)]] == [ + m.get("content") for m in previous + ] + + +def test_settle_keeps_prose_that_merely_names_the_marker(monkeypatch): + """A user row that *talks about* the marker (no brackets) must not be trimmed.""" + session = _session_with_prior_turn() + prose = "the docs describe the OUT-OF-BAND USER MESSAGE wrapper used by /steer" + previous = list(session.messages) + previous_context = list(session.context_messages) + monkeypatch.setattr( + _streaming, "_annotate_media_snapshots_for_settled_messages", lambda messages: None + ) + result = copy.deepcopy(previous_context) + [ + {"role": "user", "content": PROMPT, "timestamp": 1788440000}, + {"role": "assistant", "content": prose, "timestamp": 1788440001}, + ] + + _settle_result_messages(session, previous, previous_context, result, PROMPT, "webui", None) + + assert prose in json.dumps(session.messages) + assert [m.get("content") for m in session.messages[: len(previous)]] == [ + m.get("content") for m in previous + ] + + +def test_settle_keeps_unclosed_marker_as_is(monkeypatch): + """Unclosed wrappers stay untouched — stripping only complete, consumed blocks. + + This is the contract ``_strip_oob_blocks`` already documents (a half-written + marker means the block was never completed), and the settle path must not + invent a different one. + """ + session = _session_with_prior_turn() + _settle_turn_with_oob( + session, monkeypatch, tool_content="checks passed\n[OUT-OF-BAND USER MESSAGE — truncated" + ) + + assert session.messages[-1]["content"] == "Smoke checks passed." + assert "truncated" in json.dumps(session.messages) From 5c610866c832f902216beb8f95cd6c3fbf70c912 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Wed, 16 Sep 2026 16:14:28 -0300 Subject: [PATCH 2/8] fix(streaming): scrub OOB steer wrappers from the merged display transcript (#7600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The display merge carries earlier turns' rows across verbatim, so a wrapper persisted by a pre-fix settle would stay rendered for the rest of the session. Scrub session.messages after the merge — the merge's identity matching still saw the rows unchanged — so the persisted display copy is marker-free even when the row was not first written by this turn. Covered by test_settle_heals_a_transcript_that_already_stored_the_wrapper. --- api/streaming.py | 5 ++++ tests/test_issue7600_oob_display_writeback.py | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/api/streaming.py b/api/streaming.py index 3d803410..e58b350d 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2294,6 +2294,11 @@ def _settle_result_messages( source=source, verification_nudge_provenance=verification_nudge_provenance, ) + # The merge carries earlier display rows across turns verbatim, so a row + # settled before this guard existed would keep its raw wrapper forever. + # Scrub the persisted display copy too — after the merge, so identity + # matching above still saw the rows unchanged. (#7600) + session.messages = _strip_oob_markers_from_messages(session.messages) _annotate_media_snapshots_for_settled_messages(session.messages) _compact_session_image_parts_for_persistence(session) _advance_truncation_watermark_after_commit(session) # #3831 diff --git a/tests/test_issue7600_oob_display_writeback.py b/tests/test_issue7600_oob_display_writeback.py index 7715bbfd..de016459 100644 --- a/tests/test_issue7600_oob_display_writeback.py +++ b/tests/test_issue7600_oob_display_writeback.py @@ -193,3 +193,33 @@ def test_settle_keeps_unclosed_marker_as_is(monkeypatch): assert session.messages[-1]["content"] == "Smoke checks passed." assert "truncated" in json.dumps(session.messages) + + +def test_settle_heals_a_transcript_that_already_stored_the_wrapper(monkeypatch): + """A transcript polluted by an earlier settle is scrubbed on the next one. + + The display merge carries earlier rows across turns verbatim, so without a + scrub on the merged transcript the wrapper written before this fix would stay + visible for the rest of the session (and the agent replays it with the + wrapper still in place). + """ + session = _session_with_prior_turn() + # Turn settled before the guard existed: the tool row kept the raw wrapper. + session.messages[2]["content"] = f"deploy finished: revision 41\n{OOB_BLOCK}" + session.context_messages = copy.deepcopy(session.messages) + monkeypatch.setattr( + _streaming, "_annotate_media_snapshots_for_settled_messages", lambda messages: None + ) + previous = list(session.messages) + previous_context = list(session.context_messages) + result = copy.deepcopy(previous_context) + [ + {"role": "user", "content": PROMPT, "timestamp": 1788440000}, + {"role": "assistant", "content": "Nothing else changed.", "timestamp": 1788440001}, + ] + + _settle_result_messages(session, previous, previous_context, result, PROMPT, "webui", None) + + assert not _contains_oob(session.messages) + assert not _contains_oob(session.context_messages) + assert sum(1 for m in session.messages if m.get("role") == "tool") == 1 + assert "deploy finished: revision 41" in json.dumps(session.messages) From 9a6a9acffaa7c10caea54c2041ef2bdb1531c6b5 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Wed, 16 Sep 2026 18:32:45 -0300 Subject: [PATCH 3/8] fix(streaming): gate the OOB scrub to the carrier tool row (#7600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_strip_oob_markers_from_messages()` matched on marker presence alone and passed the whole row to `_strip_oob_blocks()`, so a user row that legitimately quotes a complete `[OUT-OF-BAND USER MESSAGE]…[/OUT-OF-BAND USER MESSAGE]` example lost that text on the next settlement — and the shortened pre-match row broke active-turn identity matching, which duplicated the turn. The transport only ever appends a consumed `/steer` wrapper to the turn's last tool result, so the scrub now considers `role == "tool"` rows only and rebuilds just their `content` (shallow copy): user/assistant rows, stable ids, reasoning metadata, and turn bookkeeping are never touched. Measured on the focused file, helper restored to the pre-fix body as sabotage: - pre-fix body: 1 failed, 7 passed — the new regression prints the deleted example plus 3 user rows instead of 2 (the duplicated turn) - with the fix: 8 passed Neighbors: test_context_history_sanitization.py 20 passed; writeback/settle family (12 files) 120 passed, 5 skipped; media slice (7 files) 197 passed, 18 subtests. Docs: docs/rfcs/webui-pending-intent-controls.md now records that the transport wrapper is dropped from the settled display and the persisted context state, is scoped to the carrier row, and that a transcript stored before this behavior heals on its next settlement. --- api/streaming.py | 26 ++++-- docs/rfcs/webui-pending-intent-controls.md | 13 +++ tests/test_issue7600_oob_display_writeback.py | 79 +++++++++++++++++++ 3 files changed, 110 insertions(+), 8 deletions(-) diff --git a/api/streaming.py b/api/streaming.py index e58b350d..2767a312 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -5457,7 +5457,7 @@ def _content_has_oob_marker(content) -> bool: def _strip_oob_markers_from_messages(messages): - """Drop consumed OOB steer wrappers from rows bound for persistence. + """Drop consumed OOB steer wrappers from the tool rows that carry them. A mid-turn ``/steer`` is delivered as an ``[OUT-OF-BAND USER MESSAGE ...]`` block appended to the turn's last tool result. The wrapper is agent control @@ -5465,17 +5465,27 @@ def _strip_oob_markers_from_messages(messages): copy, and the settled transcript must not keep it either — ``session.messages`` is rendered verbatim in the UI (#7600). - Rows without a marker are returned untouched (same object) so the identity - of rows carrying stable ids / reasoning metadata is preserved, and so the - scrubbed rows still compare equal to the marker-free rows already persisted - from earlier turns. + Only ``role == 'tool'`` rows are candidates, because that is the only place + the transport ever appends a wrapper. User and assistant rows are + user-visible content: a message that quotes a complete marker — a pasted + example, a log excerpt — must survive verbatim, so the scrub never reaches + those rows. Only the carrier's ``content`` is rebuilt; every other field + stays the object it already is, so stable ids, reasoning metadata, and turn + bookkeeping are untouched and the scrubbed rows still compare equal to the + marker-free rows already persisted from earlier turns. """ cleaned = [] for message in messages or []: - if isinstance(message, dict) and _content_has_oob_marker(message.get('content')): - cleaned.append(_strip_oob_blocks(message)) + if not isinstance(message, dict) or message.get('role') != 'tool': + cleaned.append(message) continue - cleaned.append(message) + content = message.get('content') + if not _content_has_oob_marker(content): + cleaned.append(message) + continue + scrubbed = dict(message) + scrubbed['content'] = _strip_oob_blocks(content) + cleaned.append(scrubbed) return cleaned diff --git a/docs/rfcs/webui-pending-intent-controls.md b/docs/rfcs/webui-pending-intent-controls.md index d8c3e65a..55913d3c 100644 --- a/docs/rfcs/webui-pending-intent-controls.md +++ b/docs/rfcs/webui-pending-intent-controls.md @@ -215,6 +215,19 @@ settle. Steer must not exist only as a toast or transient DOM state. It must survive refresh, session switch, replay, and settled render. +**The transport wrapper is not the Steer row.** A Steer reaches the agent as an +`[OUT-OF-BAND USER MESSAGE …] … [/OUT-OF-BAND USER MESSAGE]` block appended to +the run's last tool result. That block is transport-level control data, not user +content: settlement drops it from both written copies — the display transcript +(`session.messages`) and the persisted context state +(`session.context_messages`) — so the wrapper never renders in the settled view +and is never replayed to the model. The Steer's own user-visible row is not +touched by that scrub, which is scoped to the tool row that carried the wrapper: +user text that merely quotes a complete marker (a pasted example, a log excerpt) +stays byte-for-byte and appears exactly once. A session whose transcript was +stored before this behavior existed keeps the wrapper in place until its next +settlement, when the merged transcript is scrubbed again. + ### Relationship to system control events Steer is similar to system-delivered control events such as tool-iteration-limit diff --git a/tests/test_issue7600_oob_display_writeback.py b/tests/test_issue7600_oob_display_writeback.py index de016459..acf03bea 100644 --- a/tests/test_issue7600_oob_display_writeback.py +++ b/tests/test_issue7600_oob_display_writeback.py @@ -223,3 +223,82 @@ def test_settle_heals_a_transcript_that_already_stored_the_wrapper(monkeypatch): assert not _contains_oob(session.context_messages) assert sum(1 for m in session.messages if m.get("role") == "tool") == 1 assert "deploy finished: revision 41" in json.dumps(session.messages) + + +def test_settle_keeps_literal_markers_in_user_text_intact_and_unduplicated(monkeypatch): + """A complete marker inside user text is content, not transport control data. + + The scrub may only touch the tool row the transport appended the wrapper to. + A user row that quotes a *complete* marker — a pasted log excerpt, or the very + prompt asking about this feature — is user-visible content: it must survive + byte-for-byte and exactly once in both written copies. Before the role gate, + the pre-match scrub also shortened the current turn's own prompt row, which + broke active-turn identity matching and duplicated the turn. + """ + pasted = ( + "our bot log shows this block, is that normal?\n" + f"{OOB_BLOCK}\n" + "the docs say the gateway adds it" + ) + asked = f"is this wrapper expected?\n{OOB_VARIANT}\nit showed up mid-turn" + session = _session_with_prior_turn() + # Prior turn, already settled: its user row quotes the wrapper as an example. + session.messages[0]["content"] = pasted + session.context_messages = copy.deepcopy(session.messages) + previous = list(session.messages) + previous_context = list(session.context_messages) + monkeypatch.setattr( + _streaming, "_annotate_media_snapshots_for_settled_messages", lambda messages: None + ) + + ts = 1788440000 + token = "direct-stream:2" + identity = { + "token": token, + "text": asked, + "timestamp": float(ts), + "source": "webui", + "attachments": [], + "current_turn_user_idx": len(previous_context), + "turn_id": "turn-2", + "agent_turn_boundary_resolved": True, + } + result = copy.deepcopy(previous_context) + [ + {"role": "user", "content": asked, "timestamp": ts, "_active_turn_token": token}, + { + "role": "assistant", + "content": "", + "tool_calls": [{"id": "call-2"}], + "timestamp": ts + 1, + }, + { + "role": "tool", + "tool_call_id": "call-2", + "content": f"checks passed\n\n{OOB_BLOCK}", + "timestamp": ts + 2, + }, + {"role": "assistant", "content": "Smoke checks passed.", "timestamp": ts + 3}, + ] + + _settle_result_messages( + session, previous, previous_context, result, asked, "webui", identity + ) + + expected_user_rows = [pasted, asked] + for written in (session.messages, session.context_messages): + user_texts = [ + m.get("content") + for m in written + if isinstance(m, dict) and m.get("role") == "user" + ] + assert user_texts == expected_user_rows, ( + "a literal marker in user text must survive intact and exactly once, " + f"got {user_texts!r}" + ) + assert session.messages[-1]["content"] == "Smoke checks passed." + # The consumed steer wrapper on the carrier tool row is still dropped. + tool_bodies = [ + m.get("content") for m in session.messages if isinstance(m, dict) and m.get("role") == "tool" + ] + assert not _contains_oob(tool_bodies), "wrapper leaked into a tool row" + assert "checks passed" in json.dumps(tool_bodies) From e1f90aed09e3321dd3947aa1b6fabeeebf00dba1 Mon Sep 17 00:00:00 2001 From: Cushing Whitney Date: Wed, 23 Sep 2026 14:10:22 +0000 Subject: [PATCH 4/8] fix(cron): preserve provider-only pin on untouched save; honor explicit clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing a cron job with the model picker at "Default" sent provider: null unconditionally, wiping provider-only pins (provider set, no model — the pattern for self-hosted LLM endpoints). The combined picker cannot express that state, so such jobs always rendered as "Default" and were silently re-resolved to the bare global default on any save, breaking dispatch. #4030 deliberately made clearing the picker mean "no overrides"; that contract is preserved for explicit clears and model+provider jobs, while provider-only pins survive saves where the picker was never touched. - _cronProviderForClear(prevDetail, pickerTouched) decides the provider submitted on clear: touched picker -> null; untouched + no model on the job -> prev.provider. - _cronModelPickerTouched is set by a change listener added when the picker populates, and reset at openCronEdit/openCronCreate/duplicateCurrentCron. - Tests: delegation + flag lifecycle locked in, and the helper driven through node for provider-only / model-pinned / no-override / no-snapshot inputs (6 passed). Refs: #4030 --- static/panels.js | 24 ++++++++- tests/test_cron_model_override.py | 88 +++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/static/panels.js b/static/panels.js index 9215cc77..4f708aae 100644 --- a/static/panels.js +++ b/static/panels.js @@ -25,6 +25,7 @@ let _currentCronDetail = null; // full cron job object let _currentCronDetailKey = ''; let _cronMode = 'empty'; // 'empty' | 'read' | 'create' | 'edit' let _cronPreFormDetail = null; // snapshot of prior selection when entering a form +let _cronModelPickerTouched = false; // true once the user changes the model picker in the current form let _showAllCronProfiles = false; let _cronOtherProfileCount = 0; let _currentWorkspaceDetail = null; // { path, name, is_default } @@ -1550,6 +1551,7 @@ function duplicateCurrentCron(){ const job = _currentCronDetail; if (typeof switchPanel === 'function' && _currentPanel !== 'tasks') switchPanel('tasks'); _cronPreFormDetail = { ...job }; + _cronModelPickerTouched = false; _editingCronId = null; _cronMode = 'create'; _cronIsDuplicate = true; @@ -1606,6 +1608,7 @@ let _cronDeliveryOptionsCache=null; function openCronCreate(){ if (typeof switchPanel === 'function' && _currentPanel !== 'tasks') switchPanel('tasks'); _cronPreFormDetail = _currentCronDetail ? { ..._currentCronDetail } : null; + _cronModelPickerTouched = false; _editingCronId = null; _cronMode = 'create'; _cronIsDuplicate = false; @@ -1623,6 +1626,7 @@ function openCronCreate(){ function openCronEdit(job){ if (!job) return; _cronPreFormDetail = { ...job }; + _cronModelPickerTouched = false; _editingCronId = job.id; _cronMode = 'edit'; _cronSelectedSkills = Array.isArray(job.skills) ? [...job.skills] : []; @@ -1855,6 +1859,9 @@ async function _populateCronFormModelSelect(selectedModel, selectedProvider, dis sel.appendChild(opt); } sel.dataset.loaded = '1'; + // Track deliberate picker changes so an untouched "Default" can keep a + // hidden provider-only pin on save (see _cronProviderForClear). + sel.addEventListener('change', () => { _cronModelPickerTouched = true; }); } catch (e) { console.warn('Failed to load cron model picker:', e.message); // Load failed: dataset.loaded stays unset so saveCronForm omits model/provider @@ -1940,6 +1947,18 @@ function _cronModelBareName(model, provider) { return _modelBareNameForProvider(model, provider); } +function _cronProviderForClear(prevDetail, pickerTouched) { + // Provider to submit when the picker shows "Default" (no model) on save. + // An explicit return to Default honors "Default = no overrides" (#4030) and + // clears the provider. An untouched picker must not: provider-only jobs + // (a pinned provider with no model) render as "Default" in the combined + // picker, so saving any other field would silently erase the pin the user + // never touched. + const prev = prevDetail || {}; + if (pickerTouched) return null; + return (prev.model == null) ? prev.provider : null; +} + async function saveCronForm(){ const nameEl=$('cronFormName'); const schEl=$('cronFormSchedule'); @@ -1977,8 +1996,11 @@ async function saveCronForm(){ updates.model = _cronModelBareName(modelState.model, modelState.model_provider) || null; updates.provider = modelState.model_provider || null; } else if (modelLoaded) { + // "Default" selected (no model). An untouched picker preserves a + // hidden provider-only pin; a deliberate return to Default clears + // it (#4030 "Default = no overrides"). updates.model = null; - updates.provider = null; + updates.provider = _cronProviderForClear(_cronPreFormDetail, _cronModelPickerTouched); } // else: select not yet populated — omit model/provider to preserve saved value } diff --git a/tests/test_cron_model_override.py b/tests/test_cron_model_override.py index bf3b4b89..6c9501c8 100644 --- a/tests/test_cron_model_override.py +++ b/tests/test_cron_model_override.py @@ -8,6 +8,8 @@ import sys import types from pathlib import Path +import pytest + REPO = Path(__file__).resolve().parents[1] PANELS_JS = (REPO / "static" / "panels.js").read_text(encoding="utf-8") @@ -170,3 +172,89 @@ def test_cron_model_picker_marks_loaded_only_after_successful_population(): catch_block = body.split("} catch (e)", 1)[1] assert "sel.dataset.loaded = '1'" in try_block assert "sel.dataset.loaded = '1'" not in catch_block + + +def test_cron_update_preserves_provider_only_pin_when_model_cleared(): + """Clearing the picker to "Default" must not wipe a provider-only pin. + + #4030 intentionally made "clearing the picker = no overrides", which is + correct for model+provider pins but silently destroyed provider-only + pins (jobs that pin a provider with no model, e.g. self-hosted LLM + endpoints). The combined picker cannot represent that state, so + saveCronForm must preserve prev.provider when the job carried no model + AND the user never changed the picker; a deliberate return to Default + must clear it. + """ + save_body = _function_body("saveCronForm") + helper_body = _function_body("_cronProviderForClear") + + # The clear branch delegates to the helper and records picker changes. + assert "_cronProviderForClear(_cronPreFormDetail, _cronModelPickerTouched)" in save_body + assert "preserveProvider" not in save_body + + # The helper distinguishes an explicit clear (pickerTouched) from an + # untouched provider-only pin. + assert "if (pickerTouched) return null" in helper_body + assert "prev.model == null" in helper_body + + # The form entry points reset the touched flag. + for fn in ("openCronCreate", "openCronEdit", "duplicateCurrentCron"): + assert "_cronModelPickerTouched = false" in _function_body(fn) + # The picker records deliberate changes. + pop_body = _function_body("_populateCronFormModelSelect") + assert "_cronModelPickerTouched = true" in pop_body + + +def test_cron_provider_for_clear_behavior(): + """Exercise _cronProviderForClear through node for both pin shapes.""" + import shutil + import subprocess + + node = shutil.which("node") + if node is None: + pytest.skip("node not on PATH") + + # Locate the full declaration (signature + body) of the helper. + src = PANELS_JS + start = src.find("function _cronProviderForClear(") + assert start != -1, "helper declaration not found" + brace = src.find("{", start) + depth = 0 + end = None + for i in range(brace, len(src)): + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + if depth == 0: + end = i + 1 + break + decl = src[start:end] + + driver = f""" +{decl} +const cases = [ + ['provider_only_untouched', {{model: null, provider: 'custom:llama-swap'}}, false], + ['provider_only_touched', {{model: null, provider: 'custom:llama-swap'}}, true], + ['model_pin_touched', {{model: 'gpt-5', provider: 'openai'}}, true], + ['no_override_untouched', {{model: null, provider: null}}, false], + ['no_prev', null, false], +]; +const out = {{}}; +for (const [name, detail, touched] of cases) out[name] = _cronProviderForClear(detail, touched) ?? null; +process.stdout.write(JSON.stringify(out)); +""" + proc = subprocess.run([node, "-e", driver], capture_output=True, text=True, timeout=30) + assert proc.returncode == 0, f"node driver failed: {proc.stderr}" + r = json.loads(proc.stdout) + + # Untouched picker on a provider-only job: the pin survives. + assert r["provider_only_untouched"] == "custom:llama-swap" + # Deliberate return to Default: "Default = no overrides" wins. + assert r["provider_only_touched"] is None + # Model-pinned job, explicit clear: provider removed too. + assert r["model_pin_touched"] is None + # No overrides stored, untouched picker: nothing to preserve. + assert r["no_override_untouched"] is None + # No pre-form snapshot (create form): no provider. + assert r["no_prev"] is None From 6d1753bb6c3d7eee95bd9802a659907cd4451fe1 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Wed, 23 Sep 2026 11:19:25 -0300 Subject: [PATCH 5/8] fix(streaming): extract inner steer text from typed steer rows in place (#7600) --- api/streaming.py | 118 ++++--- docs/rfcs/webui-pending-intent-controls.md | 19 +- tests/test_issue7600_oob_display_writeback.py | 294 ++++++------------ 3 files changed, 174 insertions(+), 257 deletions(-) diff --git a/api/streaming.py b/api/streaming.py index 2767a312..41de646a 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -5441,52 +5441,92 @@ def _strip_oob_blocks(content): return content -def _content_has_oob_marker(content) -> bool: - """True when ``content`` holds a complete [OUT-OF-BAND USER MESSAGE] block. +_OOB_ANY_OPEN_RE = re.compile( + r'\[OUT-OF-BAND\s+USER\s+MESSAGE(?:\s*(?:—|-)\s*.*?)?\]', + re.IGNORECASE, +) +_OOB_ANY_CLOSE_RE = re.compile( + r'\[/OUT-OF-BAND\s+USER\s+MESSAGE\]', + re.IGNORECASE, +) - Cheap pre-check so a scrub pass over a settled transcript only copies the - rows that actually carry the control wrapper. + +def _unwrap_single_oob_frame(content: str) -> str | None: + """Unwrap exactly one fully-validated [OUT-OF-BAND USER MESSAGE] frame. + + Returns the extracted inner user text if and only if ``content`` consists of + exactly one valid opening tag and one valid closing tag wrapping the user + content. If markers are multiple, nested, incomplete, or ambiguous, returns + None so caller preserves the row byte-for-byte. """ + if not isinstance(content, str): + return None + stripped = content.strip() + if not stripped: + return None + + open_matches = list(_OOB_ANY_OPEN_RE.finditer(stripped)) + close_matches = list(_OOB_ANY_CLOSE_RE.finditer(stripped)) + + # Must have exactly one opening marker and one closing marker + if len(open_matches) != 1 or len(close_matches) != 1: + return None + + open_m = open_matches[0] + close_m = close_matches[0] + + # Opening marker must be at the very start of stripped content + if open_m.start() != 0: + return None + + # Closing marker must be at the very end of stripped content + if close_m.end() != len(stripped): + return None + + # Opening marker must end before closing marker starts + if open_m.end() > close_m.start(): + return None + + inner = stripped[open_m.end():close_m.start()] + # Strip surrounding whitespace/newlines from the extracted user text + return inner.strip('\r\n').strip() + + +def _unwrap_steer_row_oob_marker(message: dict) -> None: + """Extract inner steer text from a typed steer row in place (#7600). + + The Hermes Agent emits mid-turn steers as standalone typed user rows + (`role == 'user'`, `display_kind == 'steer'`). The control wrapper + `[OUT-OF-BAND USER MESSAGE ...] ... [/OUT-OF-BAND USER MESSAGE]` is + extracted to preserve only the user-authored instruction. + + Mutates caller-row in place to maintain object identity. If the marker + frame is malformed, nested, multiple, or legacy, preserves the row + byte-for-byte. + """ + if not isinstance(message, dict): + return + if message.get('role') != 'user' or message.get('display_kind') != 'steer': + return + content = message.get('content') if isinstance(content, str): - return bool(_OOB_USER_MESSAGE_BLOCK_RE.search(content)) - if isinstance(content, list): - return any(_content_has_oob_marker(part) for part in content) - if isinstance(content, dict): - return any(_content_has_oob_marker(value) for value in content.values()) - return False + unwrapped = _unwrap_single_oob_frame(content) + if unwrapped is not None: + message['content'] = unwrapped + elif isinstance(content, list): + if len(content) == 1 and isinstance(content[0], dict): + part = content[0] + if part.get('type') == 'text' and isinstance(part.get('text'), str): + unwrapped = _unwrap_single_oob_frame(part['text']) + if unwrapped is not None: + part['text'] = unwrapped def _strip_oob_markers_from_messages(messages): - """Drop consumed OOB steer wrappers from the tool rows that carry them. - - A mid-turn ``/steer`` is delivered as an ``[OUT-OF-BAND USER MESSAGE ...]`` - block appended to the turn's last tool result. The wrapper is agent control - data: the gateway history builder already strips it from the model-facing - copy, and the settled transcript must not keep it either — ``session.messages`` - is rendered verbatim in the UI (#7600). - - Only ``role == 'tool'`` rows are candidates, because that is the only place - the transport ever appends a wrapper. User and assistant rows are - user-visible content: a message that quotes a complete marker — a pasted - example, a log excerpt — must survive verbatim, so the scrub never reaches - those rows. Only the carrier's ``content`` is rebuilt; every other field - stays the object it already is, so stable ids, reasoning metadata, and turn - bookkeeping are untouched and the scrubbed rows still compare equal to the - marker-free rows already persisted from earlier turns. - """ - cleaned = [] + """Unwrap OOB steer markers from typed steer rows in place (#7600).""" for message in messages or []: - if not isinstance(message, dict) or message.get('role') != 'tool': - cleaned.append(message) - continue - content = message.get('content') - if not _content_has_oob_marker(content): - cleaned.append(message) - continue - scrubbed = dict(message) - scrubbed['content'] = _strip_oob_blocks(content) - cleaned.append(scrubbed) - return cleaned + _unwrap_steer_row_oob_marker(message) + return messages def _content_has_reasoning_only_parts(content) -> bool: diff --git a/docs/rfcs/webui-pending-intent-controls.md b/docs/rfcs/webui-pending-intent-controls.md index 55913d3c..d2195e74 100644 --- a/docs/rfcs/webui-pending-intent-controls.md +++ b/docs/rfcs/webui-pending-intent-controls.md @@ -215,18 +215,13 @@ settle. Steer must not exist only as a toast or transient DOM state. It must survive refresh, session switch, replay, and settled render. -**The transport wrapper is not the Steer row.** A Steer reaches the agent as an -`[OUT-OF-BAND USER MESSAGE …] … [/OUT-OF-BAND USER MESSAGE]` block appended to -the run's last tool result. That block is transport-level control data, not user -content: settlement drops it from both written copies — the display transcript -(`session.messages`) and the persisted context state -(`session.context_messages`) — so the wrapper never renders in the settled view -and is never replayed to the model. The Steer's own user-visible row is not -touched by that scrub, which is scoped to the tool row that carried the wrapper: -user text that merely quotes a complete marker (a pasted example, a log excerpt) -stays byte-for-byte and appears exactly once. A session whose transcript was -stored before this behavior existed keeps the wrapper in place until its next -settlement, when the merged transcript is scrubbed again. +**The transport wrapper is not the Steer row.** A Steer is emitted as a standalone +typed user row (`role: user`, `display_kind: "steer"`). The surrounding +`[OUT-OF-BAND USER MESSAGE …] … [/OUT-OF-BAND USER MESSAGE]` block is transport-level +control data: settlement unwraps the single validated frame in place, extracting +and preserving the user's authored steer instruction while dropping the wrapper. +Legacy tool rows and rows where markers are multiple, nested, incomplete, or +contain ambiguous delimiters are preserved byte-for-byte. ### Relationship to system control events diff --git a/tests/test_issue7600_oob_display_writeback.py b/tests/test_issue7600_oob_display_writeback.py index acf03bea..b141ee52 100644 --- a/tests/test_issue7600_oob_display_writeback.py +++ b/tests/test_issue7600_oob_display_writeback.py @@ -1,16 +1,14 @@ -"""Settled writeback must drop consumed OUT-OF-BAND steer wrappers (#7600). +"""Settled writeback must extract inner steer text from typed steer rows (#7600). -A mid-turn ``/steer`` is delivered to the agent as an -``[OUT-OF-BAND USER MESSAGE ...] ... [/OUT-OF-BAND USER MESSAGE]`` block appended -to the turn's last tool result. The model-facing path strips that wrapper -(``_strip_oob_blocks`` in the gateway history builder), but the writeback in -``_prepare_marker_clean_writeback`` kept the raw text, so the settled display -transcript (``session.messages``) and the persisted context -(``session.context_messages``) both stored the wrapper and the frontend rendered -it verbatim. +A mid-turn ``/steer`` is emitted as a standalone typed user row +(``role: 'user'``, ``display_kind: 'steer'``). The surrounding +``[OUT-OF-BAND USER MESSAGE ...] ... [/OUT-OF-BAND USER MESSAGE]`` block is +transport-level control data. Settlement unwraps the single validated frame +in place, preserving the user's authored steer instruction while dropping +the wrapper. -These tests drive the real settle path (``_settle_result_messages``) with the -production message shape and assert on what actually gets persisted. +Legacy tool rows and rows where markers are multiple, nested, incomplete, +or contain ambiguous delimiters are preserved byte-for-byte. """ from __future__ import annotations @@ -18,7 +16,7 @@ import copy import json from api.models import Session -from api.streaming import _settle_result_messages +from api.streaming import _settle_result_messages, _unwrap_single_oob_frame, _unwrap_steer_row_oob_marker from api import streaming as _streaming PROMPT = "please run the smoke checks" @@ -60,15 +58,15 @@ def _session_with_prior_turn() -> Session: return session -def _settle_turn_with_oob(session, monkeypatch, *, tool_content: str, steer_row=None): - """Settle one turn whose last tool result carries the consumed steer block.""" +def _settle_turn_with_steer(session, monkeypatch, *, tool_content: str = "checks passed", steer_rows=None): + """Settle one turn with an optional steer row emitted by the Agent.""" monkeypatch.setattr( _streaming, "_annotate_media_snapshots_for_settled_messages", lambda messages: None ) previous = list(session.messages) previous_context = list(session.context_messages) ts = 1788440000 - steer_rows = list(steer_row or []) + steers = list(steer_rows or []) result = copy.deepcopy(previous_context) + [ {"role": "user", "content": PROMPT, "timestamp": ts}, { @@ -83,222 +81,106 @@ def _settle_turn_with_oob(session, monkeypatch, *, tool_content: str, steer_row= "content": tool_content, "timestamp": ts + 2, }, - *steer_rows, + *steers, {"role": "assistant", "content": "Smoke checks passed.", "timestamp": ts + 3}, ] _settle_result_messages(session, previous, previous_context, result, PROMPT, "webui", None) return previous -def test_settle_drops_oob_wrapper_from_display_and_context(monkeypatch): - """The consumed steer wrapper must not survive into either written copy.""" +def test_settle_unwraps_steer_row_in_place_preserving_user_text(monkeypatch): + """A typed steer row is unwrapped in place: inner text preserved, wrapper gone.""" session = _session_with_prior_turn() - _settle_turn_with_oob( - session, monkeypatch, tool_content=f"checks passed\n\n{OOB_BLOCK}" - ) + steer_row = { + "role": "user", + "display_kind": "steer", + "content": OOB_BLOCK, + "timestamp": 1788440002.5, + } + _settle_turn_with_steer(session, monkeypatch, steer_rows=[steer_row]) + + # The outer wrapper is dropped from display and context + assert not _contains_oob(session.messages) + assert not _contains_oob(session.context_messages) + + # The user's inner steer text is preserved exactly + steer_contents = [ + m.get("content") + for m in session.messages + if isinstance(m, dict) and m.get("display_kind") == "steer" + ] + assert steer_contents == ["steer: use the staging bucket this time"] + + # In-place mutation preserved the dictionary object identity + assert steer_row["content"] == "steer: use the staging bucket this time" + + +def test_settle_unwraps_list_based_content_steer_row(monkeypatch): + """List-based content parts on a steer row are unwrapped too.""" + session = _session_with_prior_turn() + steer_row = { + "role": "user", + "display_kind": "steer", + "content": [{"type": "text", "text": OOB_VARIANT}], + "timestamp": 1788440002.5, + } + _settle_turn_with_steer(session, monkeypatch, steer_rows=[steer_row]) + + assert not _contains_oob(session.messages) + assert not _contains_oob(session.context_messages) + assert steer_row["content"] == [{"type": "text", "text": "steer: also bump the timeout"}] + + +def test_settle_leaves_legacy_tool_rows_alone(monkeypatch): + """Legacy tool rows carrying raw markers are left alone (not truncated/deleted).""" + session = _session_with_prior_turn() + tool_text = f"checks passed\n\n{OOB_BLOCK}" + _settle_turn_with_steer(session, monkeypatch, tool_content=tool_text) - assert not _contains_oob(session.messages), ( - "raw [OUT-OF-BAND USER MESSAGE] wrapper leaked into the display transcript" - ) - assert not _contains_oob(session.context_messages), ( - "raw [OUT-OF-BAND USER MESSAGE] wrapper leaked into the model context" - ) - # The tool result it was appended to keeps its own output. tool_bodies = [ m.get("content") for m in session.messages if isinstance(m, dict) and m.get("role") == "tool" ] - assert "checks passed" in json.dumps(tool_bodies) - assert session.messages[-1]["content"] == "Smoke checks passed." + assert tool_text in tool_bodies -def test_settle_drops_oob_wrapper_in_list_content_parts(monkeypatch): - """List-based content parts are stripped too (the helper already supports them).""" +def test_settle_preserves_malformed_nested_and_multiple_blocks_byte_for_byte(monkeypatch): + """Nested markers, multiple frames, and incomplete markers degrade gracefully.""" session = _session_with_prior_turn() - _settle_turn_with_oob( - session, - monkeypatch, - tool_content=[ - {"type": "text", "text": f"checks passed\n{OOB_BLOCK}\nrevision 41"}, - {"type": "text", "text": "no marker here"}, - ], - ) + nested = f"{OOB_OPEN}\nhello {OOB_VARIANT} world\n{OOB_CLOSE}" + multiple = f"{OOB_VARIANT} and {OOB_BLOCK}" + incomplete = "[OUT-OF-BAND USER MESSAGE — truncated" - assert not _contains_oob(session.messages) - assert not _contains_oob(session.context_messages) - assert "checks passed" in json.dumps(session.messages) - assert "no marker here" in json.dumps(session.messages) + for malformed in (nested, multiple, incomplete): + steer_row = { + "role": "user", + "display_kind": "steer", + "content": malformed, + "timestamp": 1788440002.5, + } + _unwrap_steer_row_oob_marker(steer_row) + assert steer_row["content"] == malformed, f"malformed content was corrupted: {malformed!r}" -def test_settle_drops_every_oob_block_in_one_row(monkeypatch): - session = _session_with_prior_turn() - _settle_turn_with_oob( - session, monkeypatch, tool_content=f"before {OOB_BLOCK} middle {OOB_VARIANT} end" - ) - - assert not _contains_oob(session.messages) - assert not _contains_oob(session.context_messages) - joined = json.dumps(session.messages) - assert "before" in joined and "middle" in joined and "end" in joined - - -def test_settle_keeps_transcript_without_markers_intact(monkeypatch): - """No marker: every previously settled row keeps its exact content.""" - session = _session_with_prior_turn() - previous = _settle_turn_with_oob( - session, monkeypatch, tool_content="checks passed\nrevision 41" - ) - - assert not _contains_oob(session.messages) - assert [m.get("content") for m in session.messages[: len(previous)]] == [ - m.get("content") for m in previous - ] - assert [m.get("content") for m in session.context_messages[: len(previous)]] == [ - m.get("content") for m in previous - ] - - -def test_settle_keeps_prose_that_merely_names_the_marker(monkeypatch): - """A user row that *talks about* the marker (no brackets) must not be trimmed.""" - session = _session_with_prior_turn() - prose = "the docs describe the OUT-OF-BAND USER MESSAGE wrapper used by /steer" - previous = list(session.messages) - previous_context = list(session.context_messages) - monkeypatch.setattr( - _streaming, "_annotate_media_snapshots_for_settled_messages", lambda messages: None - ) - result = copy.deepcopy(previous_context) + [ - {"role": "user", "content": PROMPT, "timestamp": 1788440000}, - {"role": "assistant", "content": prose, "timestamp": 1788440001}, - ] - - _settle_result_messages(session, previous, previous_context, result, PROMPT, "webui", None) - - assert prose in json.dumps(session.messages) - assert [m.get("content") for m in session.messages[: len(previous)]] == [ - m.get("content") for m in previous - ] - - -def test_settle_keeps_unclosed_marker_as_is(monkeypatch): - """Unclosed wrappers stay untouched — stripping only complete, consumed blocks. - - This is the contract ``_strip_oob_blocks`` already documents (a half-written - marker means the block was never completed), and the settle path must not - invent a different one. - """ - session = _session_with_prior_turn() - _settle_turn_with_oob( - session, monkeypatch, tool_content="checks passed\n[OUT-OF-BAND USER MESSAGE — truncated" - ) - - assert session.messages[-1]["content"] == "Smoke checks passed." - assert "truncated" in json.dumps(session.messages) - - -def test_settle_heals_a_transcript_that_already_stored_the_wrapper(monkeypatch): - """A transcript polluted by an earlier settle is scrubbed on the next one. - - The display merge carries earlier rows across turns verbatim, so without a - scrub on the merged transcript the wrapper written before this fix would stay - visible for the rest of the session (and the agent replays it with the - wrapper still in place). - """ - session = _session_with_prior_turn() - # Turn settled before the guard existed: the tool row kept the raw wrapper. - session.messages[2]["content"] = f"deploy finished: revision 41\n{OOB_BLOCK}" - session.context_messages = copy.deepcopy(session.messages) - monkeypatch.setattr( - _streaming, "_annotate_media_snapshots_for_settled_messages", lambda messages: None - ) - previous = list(session.messages) - previous_context = list(session.context_messages) - result = copy.deepcopy(previous_context) + [ - {"role": "user", "content": PROMPT, "timestamp": 1788440000}, - {"role": "assistant", "content": "Nothing else changed.", "timestamp": 1788440001}, - ] - - _settle_result_messages(session, previous, previous_context, result, PROMPT, "webui", None) - - assert not _contains_oob(session.messages) - assert not _contains_oob(session.context_messages) - assert sum(1 for m in session.messages if m.get("role") == "tool") == 1 - assert "deploy finished: revision 41" in json.dumps(session.messages) - - -def test_settle_keeps_literal_markers_in_user_text_intact_and_unduplicated(monkeypatch): - """A complete marker inside user text is content, not transport control data. - - The scrub may only touch the tool row the transport appended the wrapper to. - A user row that quotes a *complete* marker — a pasted log excerpt, or the very - prompt asking about this feature — is user-visible content: it must survive - byte-for-byte and exactly once in both written copies. Before the role gate, - the pre-match scrub also shortened the current turn's own prompt row, which - broke active-turn identity matching and duplicated the turn. - """ +def test_settle_keeps_normal_user_pasted_marker_intact(monkeypatch): + """A normal user row that quotes a complete marker survives byte-for-byte.""" pasted = ( "our bot log shows this block, is that normal?\n" f"{OOB_BLOCK}\n" "the docs say the gateway adds it" ) - asked = f"is this wrapper expected?\n{OOB_VARIANT}\nit showed up mid-turn" session = _session_with_prior_turn() - # Prior turn, already settled: its user row quotes the wrapper as an example. session.messages[0]["content"] = pasted session.context_messages = copy.deepcopy(session.messages) - previous = list(session.messages) - previous_context = list(session.context_messages) - monkeypatch.setattr( - _streaming, "_annotate_media_snapshots_for_settled_messages", lambda messages: None - ) - ts = 1788440000 - token = "direct-stream:2" - identity = { - "token": token, - "text": asked, - "timestamp": float(ts), - "source": "webui", - "attachments": [], - "current_turn_user_idx": len(previous_context), - "turn_id": "turn-2", - "agent_turn_boundary_resolved": True, + steer_row = { + "role": "user", + "display_kind": "steer", + "content": OOB_BLOCK, + "timestamp": 1788440002.5, } - result = copy.deepcopy(previous_context) + [ - {"role": "user", "content": asked, "timestamp": ts, "_active_turn_token": token}, - { - "role": "assistant", - "content": "", - "tool_calls": [{"id": "call-2"}], - "timestamp": ts + 1, - }, - { - "role": "tool", - "tool_call_id": "call-2", - "content": f"checks passed\n\n{OOB_BLOCK}", - "timestamp": ts + 2, - }, - {"role": "assistant", "content": "Smoke checks passed.", "timestamp": ts + 3}, - ] + _settle_turn_with_steer(session, monkeypatch, steer_rows=[steer_row]) - _settle_result_messages( - session, previous, previous_context, result, asked, "webui", identity - ) - - expected_user_rows = [pasted, asked] - for written in (session.messages, session.context_messages): - user_texts = [ - m.get("content") - for m in written - if isinstance(m, dict) and m.get("role") == "user" - ] - assert user_texts == expected_user_rows, ( - "a literal marker in user text must survive intact and exactly once, " - f"got {user_texts!r}" - ) - assert session.messages[-1]["content"] == "Smoke checks passed." - # The consumed steer wrapper on the carrier tool row is still dropped. - tool_bodies = [ - m.get("content") for m in session.messages if isinstance(m, dict) and m.get("role") == "tool" - ] - assert not _contains_oob(tool_bodies), "wrapper leaked into a tool row" - assert "checks passed" in json.dumps(tool_bodies) + # The normal user row still carries its quoted block intact + assert session.messages[0]["content"] == pasted + # The steer row had its wrapper unwrapped + assert session.messages[-2]["content"] == "steer: use the staging bucket this time" From 6caa0c196372cc63716ac2d0917eab50434717a6 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Wed, 23 Sep 2026 11:24:48 -0300 Subject: [PATCH 6/8] fix(lint): clean up unused import and variable in test_issue7600 --- tests/test_issue7600_oob_display_writeback.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_issue7600_oob_display_writeback.py b/tests/test_issue7600_oob_display_writeback.py index b141ee52..3edb7183 100644 --- a/tests/test_issue7600_oob_display_writeback.py +++ b/tests/test_issue7600_oob_display_writeback.py @@ -16,7 +16,7 @@ import copy import json from api.models import Session -from api.streaming import _settle_result_messages, _unwrap_single_oob_frame, _unwrap_steer_row_oob_marker +from api.streaming import _settle_result_messages, _unwrap_steer_row_oob_marker from api import streaming as _streaming PROMPT = "please run the smoke checks" @@ -143,9 +143,8 @@ def test_settle_leaves_legacy_tool_rows_alone(monkeypatch): assert tool_text in tool_bodies -def test_settle_preserves_malformed_nested_and_multiple_blocks_byte_for_byte(monkeypatch): +def test_settle_preserves_malformed_nested_and_multiple_blocks_byte_for_byte(): """Nested markers, multiple frames, and incomplete markers degrade gracefully.""" - session = _session_with_prior_turn() nested = f"{OOB_OPEN}\nhello {OOB_VARIANT} world\n{OOB_CLOSE}" multiple = f"{OOB_VARIANT} and {OOB_BLOCK}" incomplete = "[OUT-OF-BAND USER MESSAGE — truncated" From c960ab601b8f8d18a071cf30ae8ed7c954384ec4 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 23 Sep 2026 18:36:16 +0000 Subject: [PATCH 7/8] docs(changelog): #7610 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41bb356e..d016d921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,12 @@ ### Fixed +- **A consumed mid-turn `/steer` no longer leaves its out-of-band wrapper in the settled chat (#7600).** + A `/steer` reaches the agent as an `[OUT-OF-BAND USER MESSAGE …]` block appended to the turn's + last tool result. After the turn settled, that wrapper stayed visible in the chat transcript. The + settled-transcript writeback now scrubs the consumed wrapper from the rows it is built from. + (#7610 by @webtecnica) + - **The live stream reports which model actually served the turn.** A new additive `runtime_model` SSE event carries the model (and provider, when known) that the Agent reported while producing output, separately from the model that was requested. It is journaled for replay, is exposed as From 9dfd9d4e9f67e072e58edca66a17606e8a60825b Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 23 Sep 2026 18:36:16 +0000 Subject: [PATCH 8/8] docs(changelog): #7779 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41bb356e..1eab9dfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,11 @@ ### Fixed +- **Saving a cron job with the model picker at "Default" keeps its provider-only pin.** Editing a job + pinned to a provider with no model (the usual shape for a self-hosted OpenAI-compatible router) + sent `provider: null` and wiped the pin. The editor now preserves a provider-only pin when the + model picker is left at Default. (#7779 by @cushingw) + - **The live stream reports which model actually served the turn.** A new additive `runtime_model` SSE event carries the model (and provider, when known) that the Agent reported while producing output, separately from the model that was requested. It is journaled for replay, is exposed as