{"repo":"NousResearch/hermes-agent","public_id":"39558","display_label":"Desktop app loses intermediate assistant text around tool calls","summary":"Nearly all members describe the Desktop app dropping or overwriting assistant text emitted before or between tool calls, leaving only the final message once a turn completes. Many are bug reports and others are fix PRs touching message.complete, replaceTextPart, or interim-message handling; one non-English report about incomplete desktop results may be related.","subsystem":"apps/desktop","counts":{"members":20,"open_issues":0,"open_issues_distinct":0,"open_prs":2,"closed_issues":11,"merged_prs":2,"attempt_prs":7},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":65919,"merged_at":"2026-07-20T15:42:31+00:00"},"resolution":{"state":"partially_resolved","by":"pr-65919"},"freshness":{"state":"verified","last_verified_at":"2026-09-24T00:30:46+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.13494736842105257},"fix_edges":[{"issue_number":39558,"pr_number":65919,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-20T15:42:31+00:00"},{"issue_number":46989,"pr_number":65919,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-20T15:42:31+00:00"},{"issue_number":53204,"pr_number":107386,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-09-24T00:30:46+00:00"},{"issue_number":54905,"pr_number":65919,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-20T15:42:31+00:00"},{"issue_number":61297,"pr_number":65919,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-20T15:42:31+00:00"},{"issue_number":119716,"pr_number":107386,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-09-24T00:30:46+00:00"}],"members":[{"kind":"issue","number":39558,"title":"Desktop: intermediate assistant text (emitted before a tool call) disappears from the rendered thread when the turn completes","url":null,"state":"closed"},{"kind":"issue","number":40903,"title":"Hermes Desktop drops intermediate assistant messages in multi-message turns (v0.16.0)","url":null,"state":"closed"},{"kind":"issue","number":43893,"title":"Desktop: intermediate assistant text lost when messages have both content and tool_calls","url":null,"state":"closed"},{"kind":"issue","number":46989,"title":"replaceTextPart causes visible flicker \\u2014 initial text disappears when message completes","url":null,"state":"closed"},{"kind":"issue","number":53204,"title":"[Bug]: Tool calls in same response overwrite preceding assistant text in Desktop app","url":null,"state":"closed"},{"kind":"issue","number":54905,"title":"Desktop: all intermediate assistant text lost during multi-tool turns — only final message survives","url":null,"state":"closed"},{"kind":"issue","number":56813,"title":"Desktop: assistant text segments between tool calls disappear after turn completes","url":null,"state":"closed"},{"kind":"issue","number":61297,"title":"[Bug]: desktop可视化的结果不全","url":null,"state":"closed"},{"kind":"issue","number":61524,"title":"Desktop: intermediate assistant text lost in multi-tool turns — two-root-cause fix (gateway + replaceTextPart)","url":null,"state":"closed"},{"kind":"issue","number":61802,"title":"Desktop app: discard streaming intermediate text after completion (unlike CLI/Codex)","url":null,"state":"closed"},{"kind":"issue","number":64532,"title":"Desktop: assistant message disappears after fact_store call in same turn","url":null,"state":"closed"},{"kind":"pr","number":39576,"title":"fix(desktop): preserve interim assistant text","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65919","number":65919}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":41049,"title":"fix(desktop): preserve interim assistant text","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":43901,"title":"fix(desktop): preserve intermediate assistant text alongside tool_calls","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":53350,"title":"fix(desktop): keep assistant text streamed before an inline tool call","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#107386","number":107386},{"kind":"pr","value":"#95514","number":95514},{"kind":"pr","value":"#53204","number":53204}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":56104,"title":"fix(desktop): preserve multi-round streamed text in message.complete","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":61447,"title":"fix(desktop): honor display.interim_assistant_messages — stop collapsing mid-turn narration","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":61997,"title":"fix(desktop): keep interim assistant narration on message.complete","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#61447","number":61447},{"kind":"pr","value":"#61997","number":61997},{"kind":"pr","value":"#61822","number":61822}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":65919,"title":"fix(desktop, ink): don't wipe messages before final message","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_runtime_helpers.py","agent/conversation_loop.py","agent/turn_finalizer.py","agent/verification_evidence.py","apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts","apps/desktop/src/app/session/hooks/use-message-stream/index.ts","apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx","apps/desktop/src/app/session/hooks/use-session-actions.test.tsx","apps/desktop/src/app/types.ts","apps/desktop/src/lib/chat-messages.test.ts","apps/desktop/src/lib/chat-messages.ts","apps/desktop/src/lib/chat-runtime.ts","apps/desktop/src/lib/gateway-events.test.ts","apps/desktop/src/lib/gateway-events.ts","apps/desktop/src/types/hermes.ts","apps/shared/src/json-rpc-gateway.ts","cli-config.yaml.example","gateway/stream_consumer.py","hermes_cli/config.py","run_agent.py","tests/agent/test_turn_finalizer_iteration_limit_exit.py","tests/agent/test_verification_evidence.py","tests/agent/test_verification_stop_caching.py","tests/gateway/test_stream_consumer.py","tests/run_agent/test_run_agent_codex_responses.py","tests/run_agent/test_verification_continuation_budget.py","tests/tui_gateway/test_interim_assistant_callback.py","tui_gateway/server.py","ui-tui/src/__tests__/createGatewayEventHandler.test.ts","ui-tui/src/app/createGatewayEventHandler.ts","ui-tui/src/app/turnController.ts","ui-tui/src/gatewayTypes.ts"],"fix_hunks":[{"file":"agent/agent_runtime_helpers.py","hunks":["@@ -530,6 +530,12 @@ def _is_codex_interim(m: Dict) -> bool:\n             or m.get(\"finish_reason\") == \"incomplete\"\n         )\n \n+    def _is_verification_candidate(m: Dict) -> bool:\n+        return m.get(\"finish_reason\") in {\n+            \"verification_required\",\n+            \"verify_hook_continue\",\n+        }\n+\n     collapsed: List[Dict] = []\n     for msg in messages:\n         if (","@@ -542,6 +548,16 @@ def _is_codex_interim(m: Dict) -> bool:\n             and not _is_codex_interim(collapsed[-1])\n         ):\n             prev = collapsed[-1]\n+            # Verification candidate collapsing: when the earlier assistant\n+            # message is a provisional candidate (finish_reason =\n+            # verification_required / verify_hook_continue), the later\n+            # response supersedes it for model replay — replace rather than\n+            # union. Both remain durable in state.db; this only affects the\n+            # in-memory sequence sent to the model. (#65919 §7)\n+            if _is_verification_candidate(prev):\n+                collapsed[-1] = msg\n+                repairs += 1\n+                continue\n             # Union tool_calls (preserve order, both may carry them).\n             prev_calls = list(prev.get(\"tool_calls\") or [])\n             new_calls = list(msg.get(\"tool_calls\") or [])"]},{"file":"agent/conversation_loop.py","hunks":["@@ -690,6 +690,12 @@ def run_conversation(\n     # user-facing result available; it must not be confused with error or\n     # recovery text produced by unrelated exit paths.\n     _pending_verification_response = None\n+    # Tracks whether the pending verification candidate was already streamed\n+    # to the user as interim content. The finalizer uses this to set\n+    # ``_response_was_previewed`` ONLY when the pending candidate is actually\n+    # reused as the final response — not merely because any interim was\n+    # streamed. (#65919 review: response-loss blocker)\n+    _pending_verification_response_previewed = False\n \n     # Per-turn tally of consecutive successful credential-pool token refreshes,\n     # keyed by (provider, pool-entry-id). A persistent upstream 401 lets","@@ -5522,17 +5528,17 @@ def _perform_api_call(next_api_kwargs):\n                         getattr(agent, \"_verification_stop_nudges\", 0) + 1\n                     )\n                     final_msg[\"finish_reason\"] = \"verification_required\"\n-                    final_msg[\"_verification_stop_synthetic\"] = True\n+                    # The assistant response is real content — persist it and\n+                    # emit to the UI as an interim message so the user sees the\n+                    # attempted final answer before the verification loop runs.\n+                    # Only the nudge is flagged synthetic so it gets stripped\n+                    # from the durable transcript (#65919 §7).\n+                    agent._emit_interim_assistant_message(final_msg)\n                     messages.append(final_msg)\n-                    # Keep the attempted final answer in model history so the\n-                    # synthetic user nudge preserves role alternation, but do\n-                    # not surface it to the user as an interim answer. The\n-                    # whole point of this guard is to prevent premature\n-                    # \"done\" claims before checks run. Both the attempted\n-                    # answer and the nudge are flagged synthetic so neither\n-                    # persists — otherwise the resumed transcript keeps a\n-                    # premature \"done\" with the nudge stripped, producing an\n-                    # assistant→assistant adjacency. (#55733)\n+                    try:\n+                        agent._flush_messages_to_session_db(messages, conversation_history)\n+                    except Exception:\n+                        logger.debug(\"verify-on-stop interim flush failed\", exc_info=True)\n                     messages.append({\n                         \"role\": \"user\",\n                         \"content\": _verify_nudge,","@@ -5548,7 +5554,13 @@ def _perform_api_call(next_api_kwargs):\n                     # continuation-budget exhaustion.  ``final_response`` itself\n                     # must be cleared so the finalizer can distinguish this gate\n                     # from unrelated error/recovery exits. (#61631)\n+                    # Track whether this candidate was already streamed so the\n+                    # finalizer can mark the turn previewed only if the\n+                    # candidate is actually reused as the final response.\n                     _pending_verification_response = final_response\n+                    _pending_verification_response_previewed = (\n+                        agent._interim_content_was_streamed(final_response or \"\")\n+                    )\n                     final_response = None\n                     continue\n ","@@ -5587,12 +5599,17 @@ def _perform_api_call(next_api_kwargs):\n                 if _verify_nudge2:\n                     agent._pre_verify_nudges = _attempt + 1\n                     final_msg[\"finish_reason\"] = \"verify_hook_continue\"\n-                    final_msg[\"_pre_verify_synthetic\"] = True\n-                    # Same alternation contract as verify-on-stop: keep the\n-                    # attempted answer in history, follow it with a synthetic\n-                    # user nudge, and don't surface the premature answer. Both\n-                    # are flagged synthetic so neither persists. (#55733)\n+                    # The assistant response is real content — persist it and\n+                    # emit to the UI as an interim message so the user sees the\n+                    # attempted final answer before the pre_verify loop runs.\n+                    # Only the nudge is flagged synthetic so it gets stripped\n+                    # from the durable transcript (#65919 §7).\n+                    agent._emit_interim_assistant_message(final_msg)\n                     messages.append(final_msg)\n+                    try:\n+                        agent._flush_messages_to_session_db(messages, conversation_history)\n+                    except Exception:\n+                        logger.debug(\"pre_verify interim flush failed\", exc_info=True)\n                     messages.append({\n                         \"role\": \"user\",\n                         \"content\": _verify_nudge2,","@@ -5602,6 +5619,9 @@ def _perform_api_call(next_api_kwargs):\n                     logger.debug(\"pre_verify nudge issued (attempt %d)\",\n                                  agent._pre_verify_nudges)\n                     _pending_verification_response = final_response\n+                    _pending_verification_response_previewed = (\n+                        agent._interim_content_was_streamed(final_response or \"\")\n+                    )\n                     final_response = None\n                     continue\n ","@@ -5649,6 +5669,9 @@ def _perform_api_call(next_api_kwargs):\n                     # exhaustion path does not treat the narrated stop as\n                     # a completed answer.\n                     _pending_verification_response = final_response\n+                    _pending_verification_response_previewed = (\n+                        agent._interim_content_was_streamed(final_response or \"\")\n+                    )\n                     final_response = None\n                     continue\n ","@@ -5773,6 +5796,7 @@ def _perform_api_call(next_api_kwargs):\n         _should_review_memory=_should_review_memory,\n         _turn_exit_reason=_turn_exit_reason,\n         _pending_verification_response=_pending_verification_response,\n+        _pending_verification_response_previewed=_pending_verification_response_previewed,\n     )\n \n "]},{"file":"agent/turn_finalizer.py","hunks":["@@ -42,6 +42,30 @@ def _is_pure_tool_call_tail(msg: dict) -> bool:\n     return not flatten_message_text(msg.get(\"content\")).strip()\n \n \n+# Verification continuation scaffolding flags: verify-on-stop / pre_verify\n+# inject a synthetic user nudge to keep the agent going one more turn.\n+# These nudges must be stripped from returned/live history to avoid\n+# role-alternation breaks and poisoning the resumed transcript. The\n+# assistant response is real content and is not flagged. (#65919 §7)\n+_VERIFICATION_CONTINUATION_FLAGS = (\n+    \"_verification_stop_synthetic\",\n+    \"_pre_verify_synthetic\",\n+)\n+\n+\n+def _drop_verification_continuation_scaffolding(messages) -> None:\n+    \"\"\"Remove verification-continuation nudge messages from *messages* in place.\n+\n+    Only the synthetic nudges carry these flags, so this strips just the\n+    nudges while preserving the real attempted-final-answer that was\n+    persisted to state.db.\n+    \"\"\"\n+    messages[:] = [\n+        m for m in messages\n+        if not (isinstance(m, dict) and any(m.get(f) for f in _VERIFICATION_CONTINUATION_FLAGS))\n+    ]\n+\n+\n def finalize_turn(\n     agent,\n     *,","@@ -58,6 +82,7 @@ def finalize_turn(\n     _should_review_memory,\n     _turn_exit_reason,\n     _pending_verification_response=None,\n+    _pending_verification_response_previewed=False,\n ):\n     \"\"\"Run the post-loop finalization and return the turn ``result`` dict.\n ","@@ -91,6 +116,11 @@ def finalize_turn(\n         # fallible model call. The explicit pending value is the provenance\n         # guard: unrelated error/recovery exits can never enter this branch.\n         final_response = _pending_verification_response\n+        # Mark the turn as previewed only when the reused candidate was\n+        # actually streamed to the user as interim content. (#65919 review:\n+        # response-loss blocker)\n+        if _pending_verification_response_previewed:\n+            agent._response_was_previewed = True\n         _turn_exit_reason = f\"max_iterations_reached({api_call_count}/{agent.max_iterations})\"\n         iteration_limit_fallback = True\n         preserved_verification_fallback = True","@@ -206,6 +236,12 @@ def finalize_turn(\n     try:\n         agent._drop_trailing_empty_response_scaffolding(messages)\n \n+        # Drop verification-continuation nudges (synthetic user messages)\n+        # from the live history before the tail-assistant check — only the\n+        # nudges need stripping; the assistant candidate persists in\n+        # state.db. (#65919 §7)\n+        _drop_verification_continuation_scaffolding(messages)\n+\n         # When the turn was interrupted and the last message is a tool\n         # result, append a synthetic assistant message to close the\n         # tool-call sequence. Without this, the session persists a","@@ -235,6 +271,10 @@ def finalize_turn(\n         # single chokepoint every recovery ``break`` flows through, so the\n         # invariant \"delivered final_response ⇒ assistant row in transcript\"\n         # holds regardless of which path produced it. (#43849 / #44100)\n+        #\n+        # Compare content (not just role) so a verification candidate that\n+        # matches the final response is not duplicated at budget\n+        # exhaustion. (#65919 §7)\n         if final_response and not interrupted:\n             try:\n                 _tail = messages[-1] if messages else None","@@ -242,8 +282,10 @@ def finalize_turn(\n                 _tail = None\n             _tail_role = _tail.get(\"role\") if isinstance(_tail, dict) else None\n             if _tail_role != \"assistant\":\n+                # Tail is not an assistant row — append the final response\n+                # so the durable turn closes with the answer (#43849/#44100).\n                 messages.append({\"role\": \"assistant\", \"content\": final_response})\n-            elif isinstance(_tail, dict) and _is_pure_tool_call_tail(_tail):\n+            elif isinstance(_tail, dict) and _tail.get(\"content\") != final_response and _is_pure_tool_call_tail(_tail):\n                 # The tail IS an assistant row, but a *pure tool-call turn*:\n                 # tool_calls with no text of its own. The role check alone\n                 # leaves the #43849/#44100 invariant unmet — the user saw a","@@ -253,6 +295,11 @@ def finalize_turn(\n                 # instead of appending, so the durable turn ends with the answer\n                 # without disturbing the tool-call structure or creating an\n                 # assistant→assistant pair.\n+                #\n+                # The ``content != final_response`` guard prevents filling when\n+                # the tail already carries the final response text (verification\n+                # candidate collapse — the provisional answer was persisted and\n+                # reused as the terminal response, #65919 §7).\n                 _tail[\"content\"] = final_response\n                 # The row may have already been flushed to SQLite by the\n                 # incremental tool-call persist (conversation_loop.py:4990),"]},{"file":"agent/verification_evidence.py","hunks":["@@ -122,13 +122,13 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:\n     conn.commit()\n \n \n-def _split_segment_tokens(command: str) -> list[list[str]]:\n+def _split_segment_tokens(command: str, *, posix: bool = True) -> list[list[str]]:\n     segments: list[list[str]] = []\n     for segment in _SHELL_SPLIT_RE.split(command.strip()):\n         if not segment:\n             continue\n         try:\n-            tokens = [redacted:secret-assignment]\n+            tokens = [redacted:secret-assignment] posix=posix)\n         except ValueError:\n             continue\n         if tokens:","@@ -298,10 +298,13 @@ def _ad_hoc_script_args(tokens: [redacted:secret-assignment] root: str | Path | None) -> Optional[\n \n \n def _find_ad_hoc_match(command: str, root: str | Path | None) -> Optional[list[str]]:\n-    for tokens in _split_segment_tokens(command):\n-        trailing_args = _ad_hoc_script_args(tokens, root)\n-        if trailing_args is not None:\n-            return trailing_args\n+    # Try both posix=True (default) and posix=False (Windows backslash paths)\n+    # so ad-hoc verification scripts with backslash paths are matched on Windows.\n+    for posix in (True, False):\n+        for tokens in _split_segment_tokens(command, posix=posix):\n+            trailing_args = _ad_hoc_script_args(tokens, root)\n+            if trailing_args is not None:\n+                return trailing_args\n     return None\n \n "]},{"file":"apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts","hunks":["@@ -53,6 +53,7 @@ import { hasSessionInfoStatePatch, sessionInfoStatePatch, SUBAGENT_EVENT_TYPES,\n \n const COMPACTION_RESUME_EVENT_TYPES = new Set([\n   'message.delta',\n+  'message.interim',\n   'thinking.delta',\n   'reasoning.delta',\n   'reasoning.available',","@@ -71,9 +72,10 @@ interface GatewayEventDeps {\n   nativeSubagentSessionsRef: MutableRefObject<Set<string>>\n   appendAssistantDelta: (sessionId: string, delta: string) => void\n   appendReasoningDelta: (sessionId: string, delta: string, replace?: boolean) => void\n-  completeAssistantMessage: (sessionId: string, text: string) => void\n+  completeAssistantMessage: (sessionId: string, text: string, responsePreviewed?: boolean) => void\n   failAssistantMessage: (sessionId: string, errorMessage: string) => void\n   flushQueuedDeltas: (sessionId?: string) => void\n+  finalizeInterimAssistantMessage: (sessionId: string, text: string) => void\n   queryClient: QueryClient\n   refreshHermesConfig: () => Promise<void>\n   sessionInterrupted: (sessionId: string) => boolean","@@ -103,6 +105,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {\n     completeAssistantMessage,\n     failAssistantMessage,\n     flushQueuedDeltas,\n+    finalizeInterimAssistantMessage,\n     queryClient,\n     refreshHermesConfig,\n     sessionInterrupted,","@@ -386,6 +389,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {\n             awaitingResponse: true,\n             sawAssistantPayload: false,\n             interrupted: false,\n+            interimBoundaryPending: false,\n             turnStartedAt: Date.now()\n           }\n         })","@@ -397,6 +401,18 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {\n         if (sessionId) {\n           appendAssistantDelta(sessionId, coerceGatewayText(payload?.text))\n         }\n+      } else if (event.type === 'message.interim') {\n+        // The agent emitted interim assistant commentary (text alongside tool\n+        // calls, or the attempted final answer before a verify-on-stop nudge).\n+        // Finalize it as its own sealed bubble so message.complete doesn't wipe\n+        // it — the text was already streamed via message.delta and is visible.\n+        if (sessionId) {\n+          flushQueuedDeltas(sessionId)\n+          const text = coerceGatewayText(payload?.text)\n+          if (text) {\n+            finalizeInterimAssistantMessage(sessionId, text)\n+          }\n+        }\n       } else if (event.type === 'thinking.delta') {\n         // thinking.delta carries the kawaii spinner status (face + verb from\n         // KawaiiSpinner), not real reasoning. The bottom-of-thread loading","@@ -469,7 +485,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {\n         playCompletionSound()\n \n         const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)\n-        completeAssistantMessage(sessionId, finalText)\n+        completeAssistantMessage(sessionId, finalText, payload?.response_previewed)\n \n         if (isActiveEvent) {\n           setTurnStartedAt(null)","@@ -803,6 +819,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {\n       compactedTurnRef,\n       completeAssistantMessage,\n       failAssistantMessage,\n+      finalizeInterimAssistantMessage,\n       flushQueuedDeltas,\n       lastCwdInfoSessionRef,\n       nativeSubagentSessionsRef,"]},{"file":"apps/desktop/src/app/session/hooks/use-message-stream/index.ts","hunks":["@@ -10,6 +10,7 @@ import {\n   type ChatMessagePart,\n   chatMessageText,\n   type GatewayEventPayload,\n+  mergeFinalAssistantText,\n   reasoningPart,\n   renderMediaTags,\n   upsertToolPart","@@ -370,8 +371,60 @@ export function useMessageStream({\n     [flushQueuedDeltas, mutateStream, sessionInterrupted]\n   )\n \n-  const completeAssistantMessage = useCallback(\n+  const finalizeInterimAssistantMessage = useCallback(\n     (sessionId: string, text: string) => {\n+      updateSessionState(sessionId, state => {\n+        if (state.interrupted) {\n+          return state\n+        }\n+\n+        const authoritativeText = renderMediaTags(text).trim()\n+        if (!authoritativeText) {\n+          return state\n+        }\n+\n+        const streamId = state.streamId\n+\n+        const replaceTextPart = (parts: ChatMessagePart[]) => {\n+          const visibleText = stripGeneratedImageEchoes(\n+            authoritativeText, generatedImageEchoSources(parts)\n+          ).trim()\n+          return mergeFinalAssistantText(parts, visibleText)\n+        }\n+\n+        let nextMessages = state.messages\n+        if (streamId && nextMessages.some(m => m.id === streamId)) {\n+          // Finalize the existing streaming bubble in place\n+          nextMessages = nextMessages.map(m =>\n+            m.id === streamId\n+              ? { ...m, parts: replaceTextPart(m.parts), pending: false }\n+              : m\n+          )\n+        } else {\n+          // No streaming bubble — create a standalone interim message\n+          nextMessages = [...nextMessages, {\n+            id: `assistant-interim-${Date.now()}`,\n+            role: 'assistant' as const,\n+            parts: [assistantTextPart(authoritativeText)],\n+            pending: false,\n+            branchGroupId: state.pendingBranchGroup ?? undefined\n+          }]\n+        }\n+\n+        return {\n+          ...state,\n+          messages: nextMessages,\n+          streamId: null,\n+          interimBoundaryPending: true,\n+          sawAssistantPayload: state.sawAssistantPayload || Boolean(authoritativeText)\n+        }\n+      })\n+    },\n+    [updateSessionState]\n+  )\n+\n+  const completeAssistantMessage = useCallback(\n+    (sessionId: string, text: string, responsePreviewed?: boolean) => {\n       let shouldHydrate = false\n \n       const completedState = updateSessionState(sessionId, state => {","@@ -394,27 +447,11 @@ export function useMessageStream({\n         const streamId = state.streamId\n         const finalText = renderMediaTags(text).trim()\n         const completionError = completionErrorText(finalText)\n-        const normalize = (value: string) => value.replace(/\\s+/g, ' ').trim()\n+        const interimBoundaryPending = state.interimBoundaryPending\n \n         const replaceTextPart = (parts: ChatMessagePart[]) => {\n           const visibleFinalText = stripGeneratedImageEchoes(finalText, generatedImageEchoSources(parts)).trim()\n-          const dedupeReference = normalize(visibleFinalText)\n-\n-          const kept = parts.filter(part => {\n-            if (part.type === 'text') {\n-              return false\n-            }\n-\n-            if (part.type !== 'reasoning' || !dedupeReference) {\n-              return true\n-            }\n-\n-            const r = normalize(part.text)\n-\n-            return !(r && (dedupeReference.startsWith(r) || r.startsWith(dedupeReference)))\n-          })\n-\n-          return visibleFinalText ? [...kept, assistantTextPart(visibleFinalText)] : kept\n+          return mergeFinalAssistantText(parts, visibleFinalText)\n         }\n \n         const completeMessage = (message: ChatMessage): ChatMessage =>","@@ -454,7 +491,27 @@ export function useMessageStream({\n             const existing = prev[index]\n             const existingText = chatMessageText(existing).trim()\n \n-            if (existing.pending || (finalText && existingText === finalText)) {\n+            if (existing.pending || (!interimBoundaryPending && finalText && existingText === finalText)) {\n+              nextMessages = prev.map((message, messageIndex) =>\n+                messageIndex === index ? completeMessage(message) : message\n+              )\n+            } else if (\n+              interimBoundaryPending &&\n+              responsePreviewed &&\n+              finalText &&\n+              existingText &&\n+              finalText.startsWith(existingText)\n+            ) {\n+              // The verification candidate was published provisionally as an\n+              // interim message and then reused as the terminal response\n+              // (continuation-budget fallback). Settle the interim in place\n+              // instead of creating a duplicate — the DB has one row, so the\n+              // live UI must agree. (#65919 review: duplicate-message blocker)\n+              //\n+              // Prefix match (not exact equality): the final response may be\n+              // the streamed text plus a trailing delta.  mergeFinalAssistantText\n+              // (called via completeMessage) handles the actual merge — it\n+              // strips the old text parts and appends the full final text.\n               nextMessages = prev.map((message, messageIndex) =>\n                 messageIndex === index ? completeMessage(message) : message\n               )","@@ -480,6 +537,7 @@ export function useMessageStream({\n           awaitingResponse: false,\n           busy: false,\n           needsInput: false,\n+          interimBoundaryPending: false,\n           turnStartedAt: null\n         }\n       })","@@ -543,6 +601,7 @@ export function useMessageStream({\n           awaitingResponse: false,\n           busy: false,\n           needsInput: false,\n+          interimBoundaryPending: false,\n           turnStartedAt: null\n         }\n       })","@@ -560,6 +619,7 @@ export function useMessageStream({\n     completeAssistantMessage,\n     failAssistantMessage,\n     flushQueuedDeltas,\n+    finalizeInterimAssistantMessage,\n     queryClient,\n     refreshHermesConfig,\n     sessionInterrupted,","@@ -573,6 +633,7 @@ export function useMessageStream({\n     appendReasoningDelta,\n     completeAssistantMessage,\n     handleGatewayEvent,\n+    finalizeInterimAssistantMessage,\n     upsertToolCall\n   }\n }"]},{"file":"apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx","hunks":["@@ -0,0 +1,244 @@\n+import { QueryClient } from '@tanstack/react-query'\n+import { act, cleanup, render, waitFor } from '@testing-library/react'\n+import { useEffect, useRef } from 'react'\n+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n+\n+import type { ClientSessionState } from '@/app/types'\n+import { createClientSessionState } from '@/lib/chat-runtime'\n+import { chatMessageText } from '@/lib/chat-messages'\n+import { $todosBySession, clearSessionTodos, setSessionTodos } from '@/store/todos'\n+import type { RpcEvent } from '@/types/hermes'\n+\n+import { useMessageStream } from './index'\n+\n+const SID = 'session-1'\n+\n+let handleEvent: ((event: RpcEvent) => void) | null = null\n+let sessionStates: Map<string, ClientSessionState>\n+let mockCompleteSound: ReturnType<typeof vi.fn>\n+let mockHaptic: ReturnType<typeof vi.fn>\n+\n+function Harness() {\n+  const activeSessionIdRef = useRef<string | null>(SID)\n+  const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())\n+  const queryClientRef = useRef(new QueryClient())\n+\n+  const stream = useMessageStream({\n+    activeSessionIdRef,\n+    hydrateFromStoredSession: vi.fn(async () => undefined),\n+    queryClient: queryClientRef.current,\n+    refreshHermesConfig: vi.fn(async () => undefined),\n+    refreshSessions: vi.fn(async () => undefined),\n+    sessionStateByRuntimeIdRef,\n+    updateSessionState: (sessionId, updater) => {\n+      const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()\n+      const next = updater(current)\n+      sessionStateByRuntimeIdRef.current.set(sessionId, next)\n+      sessionStates.set(sessionId, next)\n+\n+      return next\n+    }\n+  })\n+\n+  useEffect(() => {\n+    handleEvent = stream.handleGatewayEvent\n+  }, [stream.handleGatewayEvent])\n+\n+  return null\n+}\n+\n+async function mountStream() {\n+  sessionStates = new Map()\n+  render(<Harness />)\n+  await waitFor(() => expect(handleEvent).not.toBeNull())\n+}\n+\n+const start = () => act(() => handleEvent!({ payload: {}, session_id: SID, type: 'message.start' }))\n+const delta = (text: string) => act(() => handleEvent!({ payload: { text }, session_id: SID, type: 'message.delta' }))\n+const interim = (text: string) =>\n+  act(() => handleEvent!({ payload: { text, already_streamed: true }, session_id: SID, type: 'message.interim' }))\n+const complete = (text: string) =>\n+  act(() => handleEvent!({ payload: { text }, session_id: SID, type: 'message.complete' }))\n+const completePreviewed = (text: string) =>\n+  act(() => handleEvent!({ payload: { text, response_previewed: true }, session_id: SID, type: 'message.complete' }))\n+\n+function getState(): ClientSessionState {\n+  return sessionStates.get(SID) ?? createClientSessionState()\n+}\n+\n+function assistantText(): string {\n+  const state = getState()\n+  const last = [...state.messages].reverse().find(m => m.role === 'assistant' && !m.hidden)\n+  return last ? chatMessageText(last) : ''\n+}\n+\n+function assistantMessages(): string[] {\n+  const state = getState()\n+  return state.messages\n+    .filter(m => m.role === 'assistant' && !m.hidden)\n+    .map(m => chatMessageText(m))\n+    .filter(Boolean)\n+}\n+\n+describe('useMessageStream interim text sealing', () => {\n+  beforeEach(() => {\n+    handleEvent = null\n+    clearSessionTodos(SID)\n+  })\n+\n+  afterEach(() => {\n+    cleanup()\n+    clearSessionTodos(SID)\n+    vi.restoreAllMocks()\n+  })\n+\n+  it('preserves interim text that the final response does not include', async () => {\n+    await mountStream()\n+    await start()\n+\n+    await delta('awaaaaa clean!! tsc zero errors')\n+    await interim('awaaaaa clean!! tsc zero errors')\n+\n+    await complete('All checks passed.')\n+\n+    const texts = assistantMessages()\n+    expect(texts).toContain('awaaaaa clean!! tsc zero errors')\n+    expect(texts).toContain('All checks passed.')\n+  })\n+\n+  it('dedupes interim text when the final response includes it', async () => {\n+    await mountStream()\n+    await start()\n+\n+    await delta('Let me check the files.')\n+    await interim('Let me check the files.')\n+\n+    await complete('Let me check the files. Everything looks good.')\n+\n+    const texts = assistantMessages()\n+    expect(texts).not.toContain('Let me check the files.Let me check the files.')\n+    expect(texts.some(t => t.includes('Let me check the files. Everything looks good.'))).toBe(true)\n+  })\n+\n+  it('clears interimBoundaryPending at turn end so the next turn starts clean', async () => {\n+    await mountStream()\n+    await start()\n+\n+    await delta('interim text')\n+    await interim('interim text')\n+    await complete('final text')\n+\n+    expect(getState().interimBoundaryPending).toBe(false)\n+\n+    await start()\n+    expect(getState().interimBoundaryPending).toBe(false)\n+\n+    await complete('new turn final')\n+\n+    const texts = assistantMessages()\n+    expect(texts[texts.length - 1]).toBe('new turn final')\n+  })\n+\n+  it('finalizes an interim segment without settling the turn', async () => {\n+    await mountStream()\n+    await start()\n+\n+    await delta('streaming text')\n+    await interim('streaming text')\n+\n+    // Turn is still active — busy stays true\n+    expect(getState().busy).toBe(true)\n+    expect(getState().interimBoundaryPending).toBe(true)\n+  })\n+\n+  it('keeps an identical final completion distinct from an interim reply without response_previewed', async () => {\n+    await mountStream()\n+    await start()\n+\n+    await interim('same reply')\n+    await complete('same reply')\n+\n+    // Without response_previewed, the interim and terminal replies are\n+    // distinct messages — the gateway didn't signal that the final reuses\n+    // the provisional candidate.\n+    const texts = assistantMessages()\n+    expect(texts.filter(t => t === 'same reply')).toHaveLength(2)\n+  })\n+\n+  it('settles an identical final completion onto the interim when response_previewed', async () => {\n+    await mountStream()\n+    await start()\n+\n+    await interim('same reply')\n+    await completePreviewed('same reply')\n+\n+    // With response_previewed, the final text is the same model response\n+    // that was published provisionally as an interim — settle onto the\n+    // existing interim instead of creating a duplicate. (#65919 review)\n+    const texts = assistantMessages()\n+    expect(texts.filter(t => t === 'same reply')).toHaveLength(1)\n+  })\n+\n+  it('settles a prefix-matched final onto the interim when response_previewed', async () => {\n+    await mountStream()\n+    await start()\n+\n+    // Interim text is a PREFIX of the final — the model streamed part of\n+    // its answer before the verify nudge fired, then the final includes\n+    // the same text plus a trailing delta.\n+    await interim('partial answer')\n+    await completePreviewed('partial answer with more detail')\n+\n+    // Prefix match: the final starts with the interim text, so settle\n+    // onto the interim instead of creating a duplicate bubble.\n+    const texts = assistantMessages()\n+    expect(texts.filter(t => t.includes('partial answer'))).toHaveLength(1)\n+    expect(texts[0]).toBe('partial answer with more detail')\n+  })\n+\n+  it('dedupes partial-stream-then-nudge: streamed prefix + interim + previewed final settles to one bubble', async () => {\n+    await mountStream()\n+    await start()\n+\n+    // The model streamed part of its answer via message.delta, then the\n+    // verify nudge fired. The interim seals the streamed text, then the\n+    // final response is the same text plus a trailing delta.\n+    await delta('partial streamed')\n+    await interim('partial streamed')\n+    await completePreviewed('partial streamed answer continued')\n+\n+    // One bubble, containing the full final text — not two.\n+    const texts = assistantMessages()\n+    expect(texts.filter(t => t.includes('partial streamed'))).toHaveLength(1)\n+    expect(texts[0]).toBe('partial streamed answer continued')\n+  })\n+\n+  it('ignores malformed message.interim payload', async () => {\n+    await mountStream()\n+    await start()\n+\n+    // No payload at all\n+    await act(() => handleEvent!({ type: 'message.interim' } as RpcEvent))\n+    // Empty text\n+    await act(() => handleEvent!({ payload: { text: '' }, session_id: SID, type: 'message.interim' } as RpcEvent))\n+    // Undefined text\n+    await act(() => handleEvent!({ payload: { text: undefined }, session_id: SID, type: 'message.interim' } as RpcEvent))\n+\n+    // Turn continues without finalizing or throwing\n+    expect(getState().busy).toBe(true)\n+    expect(getState().interimBoundaryPending).toBe(false)\n+  })\n+\n+  it('clears interimBoundaryPending on message.start', async () => {\n+    await mountStream()\n+    await start()\n+\n+    await delta('interim text')\n+    await interim('interim text')\n+    expect(getState().interimBoundaryPending).toBe(true)\n+\n+    // New turn starts\n+    await start()\n+    expect(getState().interimBoundaryPending).toBe(false)\n+  })\n+})"]},{"file":"apps/desktop/src/app/session/hooks/use-session-actions.test.tsx","hunks":["@@ -820,6 +820,7 @@ describe('resumeSession failure recovery', () => {\n             busy: false,\n             cwd: '',\n             fast: false,\n+            interimBoundaryPending: false,\n             interrupted: false,\n             messages: [],\n             model: '',"]},{"file":"apps/desktop/src/app/types.ts","hunks":["@@ -154,6 +154,8 @@ export interface ClientSessionState {\n   sawAssistantPayload: boolean\n   pendingBranchGroup: string | null\n   interrupted: boolean\n+  /** True after message.interim finalized a bubble in the still-running turn. */\n+  interimBoundaryPending: boolean\n   /** A blocking clarify prompt is waiting on the user for this session. Drives\n    *  the sidebar \"needs input\" indicator; cleared when the turn resumes/ends. */\n   needsInput: boolean"]},{"file":"apps/desktop/src/lib/chat-messages.test.ts","hunks":["@@ -5,7 +5,9 @@ import {\n   appendAssistantTextPart,\n   appendReasoningPart,\n   chatMessageText,\n+  mergeFinalAssistantText,\n   preserveLocalAssistantErrors,\n+  reasoningPart,\n   renderMediaTags,\n   toChatMessages,\n   upsertToolPart","@@ -785,3 +787,69 @@ describe('upsertToolPart', () => {\n     })\n   })\n })\n+\n+describe('mergeFinalAssistantText', () => {\n+  it('removes all text parts and appends the final text', () => {\n+    const parts = [\n+      { type: 'text' as const, text: 'streamed delta 1' },\n+      { type: 'text' as const, text: 'streamed delta 2' },\n+      { type: 'tool-call' as const, toolCallId: 'tc1', toolName: 'terminal', args: {} as never, argsText: '{}' }\n+    ]\n+\n+    const result = mergeFinalAssistantText(parts, 'final answer')\n+\n+    expect(result.filter(p => p.type === 'text')).toHaveLength(1)\n+    expect(result.filter(p => p.type === 'text')[0]).toMatchObject({ text: 'final answer' })\n+    expect(result.some(p => p.type === 'tool-call')).toBe(true)\n+  })\n+\n+  it('drops reasoning that the final text fully covers (reasoning ⊆ final)', () => {\n+    const parts = [\n+      reasoningPart('Let me check the files.'),\n+      { type: 'text' as const, text: 'streamed' }\n+    ]\n+\n+    const result = mergeFinalAssistantText(parts, 'Let me check the files. Everything looks good.')\n+\n+    expect(result.filter(p => p.type === 'reasoning')).toHaveLength(0)\n+    expect(result.filter(p => p.type === 'text')).toHaveLength(1)\n+  })\n+\n+  it('keeps a longer reasoning block when the final text is only a short prefix', () => {\n+    // #61447: a short final (\"Done.\") must NOT swallow a longer reasoning block\n+    // that merely starts with it.\n+    const parts = [\n+      reasoningPart('Done. The root cause was a bare catch block swallowing Stripe errors. The fix adds proper error logging.'),\n+      { type: 'text' as const, text: 'streamed' }\n+    ]\n+\n+    const result = mergeFinalAssistantText(parts, 'Done.')\n+\n+    expect(result.filter(p => p.type === 'reasoning')).toHaveLength(1)\n+    expect(result.filter(p => p.type === 'text')[0]).toMatchObject({ text: 'Done.' })\n+  })\n+\n+  it('keeps non-restating reasoning', () => {\n+    const parts = [\n+      reasoningPart('I analyzed the issue and found a race condition in the event loop.'),\n+      { type: 'text' as const, text: 'streamed' }\n+    ]\n+\n+    const result = mergeFinalAssistantText(parts, 'Fixed the race condition.')\n+\n+    expect(result.filter(p => p.type === 'reasoning')).toHaveLength(1)\n+    expect(result.filter(p => p.type === 'text')).toHaveLength(1)\n+  })\n+\n+  it('handles empty final text', () => {\n+    const parts = [\n+      { type: 'text' as const, text: 'streamed' },\n+      reasoningPart('some reasoning')\n+    ]\n+\n+    const result = mergeFinalAssistantText(parts, '')\n+\n+    expect(result.filter(p => p.type === 'text')).toHaveLength(0)\n+    expect(result.filter(p => p.type === 'reasoning')).toHaveLength(1)\n+  })\n+})"]},{"file":"apps/desktop/src/lib/chat-messages.ts","hunks":["@@ -87,6 +87,9 @@ export type GatewayEventPayload = {\n   label?: string\n   index?: number\n   aggregator?: string\n+  // message.complete — signals the final text was already previewed via\n+  // interim_assistant_callback, so the UI can settle instead of duplicating.\n+  response_previewed?: boolean\n }\n \n export function textPart(text: string): ChatMessagePart {","@@ -136,6 +139,49 @@ export function chatMessageText(message: ChatMessage): string {\n     .join('')\n }\n \n+const normalizeWs = (value: string) => value.replace(/\\s+/g, ' ').trim()\n+\n+/**\n+ * Merge the final assistant text into a message's parts.\n+ *\n+ * - Removes all existing `text` parts (they were streamed deltas, now superseded\n+ *   by the authoritative final response).\n+ * - Keeps `reasoning` parts, but drops one that the final text fully covers\n+ *   (reasoning ⊆ final) — the final restates it. A short final (\"Done.\") must\n+ *   NOT swallow a longer reasoning block that merely starts with it (#61447).\n+ * - Keeps all other part types (tool-call, image, etc.).\n+ * - Appends the final text as a new text part.\n+ */\n+export function mergeFinalAssistantText(\n+  parts: ChatMessagePart[],\n+  finalText: string\n+): ChatMessagePart[] {\n+  const dedupeReference = normalizeWs(finalText)\n+\n+  const kept = parts.filter(part => {\n+    if (part.type === 'text') {\n+      // Sealed text parts were already finalized into their own bubbles —\n+      // this filter only runs on the LAST streaming bubble, so there are no\n+      // sealed parts here. All text parts are streamed deltas that get\n+      // replaced by the authoritative final text.\n+      return false\n+    }\n+\n+    if (part.type !== 'reasoning' || !dedupeReference) {\n+      return true\n+    }\n+\n+    // Reasoning is a restatement only when the final FULLY covers it.\n+    // The reverse direction is not considered — a short final must not\n+    // swallow a longer reasoning block (#61447).\n+    const r = normalizeWs(part.text)\n+\n+    return !(r && dedupeReference.startsWith(r))\n+  })\n+\n+  return finalText ? [...kept, assistantTextPart(finalText)] : kept\n+}\n+\n const ATTACHED_CONTEXT_MARKER_RE = /(?:^|\\n)--- Attached Context ---\\s*\\n/\n const CONTEXT_WARNINGS_MARKER_RE = /(?:^|\\n)--- Context Warnings ---[\\s\\S]*$/\n const CONTEXT_REF_RE = /@(file|folder|url|image|tool|terminal):(?:\"[^\"\\n]+\"|'[^'\\n]+'|`[^`\\n]+`|\\S+)/g"]},{"file":"apps/desktop/src/lib/chat-runtime.ts","hunks":["@@ -54,6 +54,7 @@ export function createClientSessionState(\n     sawAssistantPayload: false,\n     pendingBranchGroup: null,\n     interrupted: false,\n+    interimBoundaryPending: false,\n     needsInput: false,\n     turnStartedAt: null,\n     usage: null"]},{"file":"apps/desktop/src/lib/gateway-events.test.ts","hunks":["@@ -13,6 +13,7 @@ describe('gateway event routing', () => {\n     // output, and dropping them loses the live response until a refetch (#42178).\n     expect(gatewayEventRequiresSessionId('message.delta')).toBe(false)\n     expect(gatewayEventRequiresSessionId('message.complete')).toBe(false)\n+    expect(gatewayEventRequiresSessionId('message.interim')).toBe(false)\n     expect(gatewayEventRequiresSessionId('reasoning.delta')).toBe(false)\n     expect(gatewayEventRequiresSessionId('tool.start')).toBe(false)\n     expect(gatewayEventRequiresSessionId('approval.request')).toBe(false)"]},{"file":"apps/desktop/src/lib/gateway-events.ts","hunks":["@@ -24,6 +24,7 @@ const UNSCOPED_STREAM_EVENT_TYPES = new Set([\n   'error',\n   'message.complete',\n   'message.delta',\n+  'message.interim',\n   'message.start',\n   'reasoning.available',\n   'reasoning.delta',"]},{"file":"apps/desktop/src/types/hermes.ts","hunks":["@@ -256,6 +256,7 @@ export interface HermesConfig {\n   display?: {\n     personality?: string\n     skin?: string\n+    interim_assistant_messages?: boolean\n   }\n   terminal?: {\n     cwd?: string"]},{"file":"apps/shared/src/json-rpc-gateway.ts","hunks":["@@ -3,6 +3,7 @@ export type GatewayEventName =\n   | 'session.info'\n   | 'message.start'\n   | 'message.delta'\n+  | 'message.interim'\n   | 'message.complete'\n   | 'thinking.delta'\n   | 'reasoning.delta'"]},{"file":"cli-config.yaml.example","hunks":["@@ -1181,11 +1181,14 @@ display:\n   #         cleanup_progress: true\n   cleanup_progress: false\n \n-  # Gateway-only natural mid-turn assistant updates.\n-  # When true, completed assistant status messages are sent as separate chat\n-  # messages. This is independent of tool_progress and gateway streaming.\n-  #   true:  Send mid-turn assistant updates as separate messages (default)\n-  #   false: Only send the final response\n+  # Natural mid-turn assistant updates.\n+  # On gateway platforms, when true, completed assistant status messages are\n+  # sent as separate chat messages. On the Desktop app, when true, mid-turn\n+  # assistant narration streamed between tool calls is kept in the transcript\n+  # instead of the bubble collapsing to only the final message on completion.\n+  # Independent of tool_progress and gateway streaming.\n+  #   true:  Keep/send mid-turn assistant updates (default)\n+  #   false: Only keep/send the final response\n   interim_assistant_messages: true\n \n   # Gateway-only long-running status heartbeats."]},{"file":"gateway/stream_consumer.py","hunks":["@@ -189,6 +189,13 @@ def __init__(\n         # subsequently failed.\n         self._final_content_delivered = False\n         self._delivered_commentary_texts: list[str] = []\n+        # Retains the finalized visible text of each streaming segment so\n+        # ``has_delivered_text`` can still match after ``_reset_segment_state``\n+        # clears ``_last_sent_text``. Without this, a segment break (triggered\n+        # by ``on_segment_break`` or ``on_commentary``) erases the only record\n+        # of what was delivered, and the gateway's final-send suppression\n+        # can't recognize an already-delivered response. (#65919 review)\n+        self._delivered_segment_texts: list[str] = []\n         # Cache adapter lifecycle capability: only platforms that need an\n         # explicit finalize call (e.g. DingTalk AI Cards) force us to make\n         # a redundant final edit.  Everyone else keeps the fast path.","@@ -320,7 +327,10 @@ def has_delivered_text(self, text: str) -> bool:\n         visible_prefix = self._visible_prefix().strip()\n         if visible_prefix == target:\n             return True\n-        return any(sent.strip() == target for sent in self._delivered_commentary_texts)\n+        return any(\n+            sent.strip() == target\n+            for sent in (*self._delivered_commentary_texts, *self._delivered_segment_texts)\n+        )\n \n     def on_segment_break(self) -> None:\n         \"\"\"Finalize the current stream segment and start a fresh message.\"\"\"","@@ -344,6 +354,13 @@ def _notify_new_message(self) -> None:\n     def _reset_segment_state(self, *, preserve_no_edit: bool = False) -> None:\n         if preserve_no_edit and self._message_id == \"__no_edit__\":\n             return\n+        # Retain the finalized visible text of the current segment before\n+        # clearing ``_last_sent_text``, so ``has_delivered_text`` can still\n+        # match it after a segment break. (#65919 review)\n+        if self._last_sent_text:\n+            finalized = self._clean_for_display(self._last_sent_text).strip()\n+            if finalized:\n+                self._delivered_segment_texts.append(finalized)\n         self._message_id = None\n         self._message_created_ts = None\n         self._accumulated = \"\""]},{"file":"hermes_cli/config.py","hunks":["@@ -1948,7 +1948,7 @@ def _ensure_hermes_home_managed(home: Path):\n             \"first_lines\": 2,\n             \"last_lines\": 2,\n         },\n-        \"interim_assistant_messages\": True,  # Gateway: show natural mid-turn assistant status messages\n+        \"interim_assistant_messages\": True,  # Gateway: send natural mid-turn assistant status messages. Desktop: keep mid-turn narration between tool calls instead of collapsing to the final message.\n         # Codex Responses models narrate progress in a dedicated commentary\n         # channel. When true (default), completed commentary messages are\n         # delivered as visible mid-turn updates via the interim message path."]},{"file":"run_agent.py","hunks":["@@ -229,11 +229,12 @@ def _session_source_for_agent(platform: Optional[str]) -> str:\n     \"_empty_recovery_synthetic\",\n     \"_empty_terminal_sentinel\",\n     \"_thinking_prefill\",\n-    # verify-on-stop and pre_verify nudges append a synthetic assistant\n-    # \"done\" plus a synthetic user nudge to keep the agent going one more\n-    # turn before it can claim completion. Those messages exist only to\n-    # drive the verification loop; persisting them poisons the resumed\n-    # transcript and breaks prompt-prefix cache reuse on later turns. (#55733)\n+    # verify-on-stop and pre_verify nudges append a synthetic user nudge to\n+    # keep the agent going one more turn before it can claim completion.\n+    # The nudge exists only to drive the verification loop; persisting it\n+    # poisons the resumed transcript and breaks prompt-prefix cache reuse\n+    # on later turns. The assistant candidate is NOT synthetic — it is\n+    # persisted and emitted as an interim message (#65919).\n     \"_verification_stop_synthetic\",\n     \"_pre_verify_synthetic\",\n     # kanban worker stop-guard: narrated exit without kanban_complete/block","@@ -4914,7 +4915,16 @@ def _interim_content_was_streamed(self, content: str) -> bool:\n         streamed = self._normalize_interim_visible_text(\n             self._strip_think_blocks(getattr(self, \"_current_streamed_assistant_text\", \"\") or \"\")\n         )\n-        return bool(streamed) and streamed == visible_content\n+        # Prefix match (not exact equality): the final response may be the\n+        # streamed text plus a trailing delta, or the stream may have been\n+        # partial when the verify nudge fired.  In both cases the streamed\n+        # content is a prefix of the final — that's enough to mark it\n+        # previewed (fails safe to a benign duplicate, never loses text).\n+        # The reverse direction (streamed longer than final) is NOT matched:\n+        # that could suppress a needed resend in the gateway path where\n+        # already_streamed=True calls on_segment_break() instead of\n+        # on_commentary() (#65919 review).\n+        return bool(streamed) and visible_content.startswith(streamed)\n \n     def _extract_codex_interim_visible_parts(\n         self,","@@ -5019,8 +5029,19 @@ def _fire_streamed_codex_commentary(self, text: str) -> None:\n         except Exception:\n             logger.debug(\"interim_assistant_callback error\", exc_info=True)\n \n-    def _emit_interim_assistant_message(self, assistant_msg: Dict[str, Any]) -> None:\n-        \"\"\"Surface a real mid-turn assistant commentary message to the UI layer.\"\"\"\n+    def _emit_interim_assistant_message(\n+        self, assistant_msg: Dict[str, Any]\n+    ) -> None:\n+        \"\"\"Surface a real mid-turn assistant commentary message to the UI layer.\n+\n+        Does NOT set ``_response_was_previewed`` — that flag means \"the final\n+        response was already shown to the user,\" but this helper is called for\n+        ordinary tool-call narration, intermediate acknowledgements, and\n+        verification candidates alike. Setting it here would cause the CLI to\n+        suppress a *different* final summary (e.g. from ``_handle_max_iterations``)\n+        when the only streamed text was unrelated mid-turn commentary. (#65919\n+        review: response-loss blocker)\n+        \"\"\"\n         cb = getattr(self, \"interim_assistant_callback\", None)\n         if cb is None or not isinstance(assistant_msg, dict):\n             return"]},{"file":"tests/agent/test_turn_finalizer_iteration_limit_exit.py","hunks":["@@ -296,3 +296,84 @@ def test_pending_response_records_kanban_timeout(monkeypatch):\n         end_run=True,\n         event_payload_extra={\"budget_used\": 60, \"budget_max\": 60},\n     )\n+\n+\n+def test_published_pending_candidate_is_not_duplicated_by_finalizer(monkeypatch):\n+    \"\"\"When budget exhaustion preserves a verification candidate that is\n+    already the tail assistant message, the finalizer must NOT append a\n+    duplicate. The content-comparison guard prevents this. (#65919 §7)\n+    \"\"\"\n+    monkeypatch.setattr(\"hermes_cli.plugins.invoke_hook\", lambda *_a, **_kw: [])\n+    agent = _LimitAgent()\n+    report = \"the composed report\"\n+\n+    result = finalize_turn(\n+        agent,\n+        final_response=report,\n+        api_call_count=60,\n+        interrupted=False,\n+        failed=False,\n+        # The candidate is already in messages as the tail assistant.\n+        messages=[\n+            {\"role\": \"user\", \"content\": \"task\"},\n+            {\"role\": \"assistant\", \"content\": report},\n+        ],\n+        conversation_history=[],\n+        effective_task_id=\"task\",\n+        turn_id=\"turn\",\n+        user_message=\"task\",\n+        original_user_message=\"task\",\n+        _should_review_memory=False,\n+        _turn_exit_reason=\"unknown\",\n+        _pending_verification_response=report,\n+    )\n+\n+    # The tail assistant already matches final_response — no duplicate appended.\n+    roles = [m[\"role\"] for m in result[\"messages\"]]\n+    assert roles == [\"user\", \"assistant\"]\n+    # Persisted messages should also have no duplicate.\n+    assert agent.persisted_messages is not None\n+    persisted_roles = [m[\"role\"] for m in agent.persisted_messages]\n+    assert persisted_roles == [\"user\", \"assistant\"]\n+\n+\n+def test_terminal_verification_failure_is_persisted_as_one_correction(monkeypatch):\n+    \"\"\"When verification fails terminally (nudge present but budget exhausted),\n+    the finalizer drops the synthetic nudge and the assistant candidate\n+    persists as a single correction. No duplicate assistant appended. (#65919 §7)\n+    \"\"\"\n+    monkeypatch.setattr(\"hermes_cli.plugins.invoke_hook\", lambda *_a, **_kw: [])\n+    agent = _LimitAgent()\n+    report = \"terminal failure correction\"\n+\n+    result = finalize_turn(\n+        agent,\n+        final_response=report,\n+        api_call_count=60,\n+        interrupted=False,\n+        failed=False,\n+        messages=[\n+            {\"role\": \"user\", \"content\": \"task\"},\n+            {\"role\": \"assistant\", \"content\": report},\n+            # Synthetic nudge — should be dropped by _drop_verification_continuation_scaffolding.\n+            {\"role\": \"user\", \"content\": \"[System: run tests]\", \"_verification_stop_synthetic\": True},\n+        ],\n+        conversation_history=[],\n+        effective_task_id=\"task\",\n+        turn_id=\"turn\",\n+        user_message=\"task\",\n+        original_user_message=\"task\",\n+        _should_review_memory=False,\n+        _turn_exit_reason=\"unknown\",\n+        _pending_verification_response=report,\n+    )\n+\n+    # The nudge is dropped; the assistant candidate is the tail and matches\n+    # final_response, so no duplicate is appended.\n+    roles = [m[\"role\"] for m in result[\"messages\"]]\n+    assert roles == [\"user\", \"assistant\"]\n+    # The nudge is gone from persisted messages too.\n+    assert agent.persisted_messages is not None\n+    persisted_contents = [m.get(\"content\") for m in agent.persisted_messages]\n+    assert \"[System: run tests]\" not in persisted_contents\n+    assert report in persisted_contents"]},{"file":"tests/agent/test_verification_evidence.py","hunks":["@@ -391,3 +391,36 @@ def test_recording_expires_old_edit_only_state(tmp_path, monkeypatch):\n     status = verification_status(session_id=\"old-session\", cwd=tmp_path)\n     assert status[\"status\"] == \"unverified\"\n     assert status[\"changed_paths\"] == []\n+\n+\n+def test_windows_backslash_ad_hoc_script_path_is_matched(tmp_path, monkeypatch):\n+    \"\"\"Ad-hoc verification scripts with Windows backslash paths must be\n+    matched by ``_find_ad_hoc_match`` trying ``posix=False`` in addition to\n+    the default ``posix=True``. (#53553 / #65919)\n+\n+    On Linux, ``Path`` doesn't parse Windows backslash paths, so we mock\n+    ``_is_temp_script_path`` to simulate the Windows environment where the\n+    path resolves correctly. The test verifies the posix=False splitting\n+    fallback — the actual fix from #53553.\n+    \"\"\"\n+    from agent.verification_evidence import _find_ad_hoc_match\n+\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \".hermes\"))\n+    (tmp_path / \"package.json\").write_text(\"{}\", encoding=\"utf-8\")\n+\n+    # On Windows, shlex.split(posix=True) eats backslashes as escape chars;\n+    # posix=False preserves them. Mock _is_temp_script_path so the test\n+    # focuses on the splitting fallback without needing a real Windows FS.\n+    def mock_is_temp_script(token, root):\n+        return \"hermes-ad-hoc\" in token and \".py\" in token\n+\n+    monkeypatch.setattr(\n+        \"agent.verification_evidence._is_temp_script_path\",\n+        mock_is_temp_script,\n+    )\n+\n+    win_script = r\"C:\\Users\\test\\AppData\\Local\\Temp\\hermes-ad-hoc-check.py\"\n+    result = _find_ad_hoc_match(f\"python {win_script}\", tmp_path)\n+    assert result is not None, (\n+        \"Windows backslash path should be matched via posix=False fallback\"\n+    )"]},{"file":"tests/agent/test_verification_stop_caching.py","hunks":["@@ -1,15 +1,15 @@\n \"\"\"Verification-loop synthetic scaffolding must never reach durable session state.\n \n-verify_on_stop / pre_verify append a synthetic assistant \"done\" plus a synthetic\n-user nudge to keep the agent going one more turn before it can claim completion.\n-These messages exist only to drive the loop; persisting them poisons the resumed\n-transcript and breaks prompt-prefix cache reuse on later turns (#55733).\n-\n-Both persistence sinks (SQLite flush + JSON snapshot) route through the single\n-``_is_ephemeral_scaffolding`` chokepoint, which is driven by\n-``_EPHEMERAL_SCAFFOLDING_FLAGS``. These tests assert that the verification-loop\n-flags are registered there and that both sinks drop the flagged messages while\n-keeping the real conversation.\n+verify_on_stop / pre_verify inject a synthetic user nudge to keep the agent\n+going one more turn before it can claim completion. The assistant response is\n+real content that persists and is emitted to the UI as an interim message.\n+Only the nudge (the synthetic user message) is flagged, so only the nudge\n+gets stripped from the durable transcript. This test file verifies:\n+\n+  - The verification-loop flags remain registered in\n+    ``_EPHEMERAL_SCAFFOLDING_FLAGS`` (so nudges are stripped).\n+  - The DB flush drops only the nudge, keeping the assistant candidate.\n+  - The JSON log drops only the nudge, keeping the assistant candidate.\n \"\"\"\n \n import json","@@ -34,15 +34,16 @@ def test_verification_flags_registered_as_ephemeral(tmp_path, monkeypatch):\n     assert \"_verification_stop_synthetic\" in ra._EPHEMERAL_SCAFFOLDING_FLAGS\n     assert \"_pre_verify_synthetic\" in ra._EPHEMERAL_SCAFFOLDING_FLAGS\n \n-    # The central classifier drives both persistence sinks.\n+    # The nudge messages ARE scaffolding (they carry the synthetic flag).\n     assert ra._is_ephemeral_scaffolding(\n-        {\"role\": \"assistant\", \"content\": \"done\", \"_verification_stop_synthetic\": True}\n+        {\"role\": \"user\", \"content\": \"[System: run tests]\", \"_pre_verify_synthetic\": True}\n     )\n     assert ra._is_ephemeral_scaffolding(\n-        {\"role\": \"user\", \"content\": \"[System: run tests]\", \"_pre_verify_synthetic\": True}\n+        {\"role\": \"user\", \"content\": \"[System: run tests]\", \"_verification_stop_synthetic\": True}\n     )\n-    # Real messages are not scaffolding.\n+    # Real messages (including the assistant candidate) are not.\n     assert not ra._is_ephemeral_scaffolding({\"role\": \"user\", \"content\": \"hi\"})\n+    assert not ra._is_ephemeral_scaffolding({\"role\": \"assistant\", \"content\": \"premature done\"})\n \n \n def _make_agent(ra, session_id, tmp_path):","@@ -64,14 +65,18 @@ def _make_agent(ra, session_id, tmp_path):\n     return agent\n \n \n-def test_db_flush_drops_verification_scaffolding(tmp_path, monkeypatch):\n+def test_db_flush_drops_only_nudge_keeps_candidate(tmp_path, monkeypatch):\n+    \"\"\"The assistant candidate is NOT flagged synthetic, so it persists.\n+    Only the nudge (flagged synthetic) is dropped from the DB flush.\"\"\"\n     monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \".hermes\"))\n     ra = _fresh_run_agent(tmp_path)\n     agent = _make_agent(ra, \"sess_db\", tmp_path)\n \n     messages = [\n         {\"role\": \"user\", \"content\": \"hi\"},\n-        {\"role\": \"assistant\", \"content\": \"premature done\", \"_verification_stop_synthetic\": True},\n+        # Assistant candidate — NOT flagged synthetic, persists.\n+        {\"role\": \"assistant\", \"content\": \"premature done\"},\n+        # Nudge — flagged synthetic, gets dropped.\n         {\"role\": \"user\", \"content\": \"[System: run tests]\", \"_verification_stop_synthetic\": True},\n         {\"role\": \"assistant\", \"content\": \"verified and clean\"},\n     ]","@@ -84,18 +89,24 @@ def test_db_flush_drops_verification_scaffolding(tmp_path, monkeypatch):\n     ]\n     assert \"hi\" in persisted\n     assert \"verified and clean\" in persisted\n-    assert \"premature done\" not in persisted\n+    # The assistant candidate persists — it is real content.\n+    assert \"premature done\" in persisted\n+    # Only the nudge is dropped.\n     assert \"[System: run tests]\" not in persisted\n \n \n-def test_json_log_drops_verification_scaffolding(tmp_path, monkeypatch):\n+def test_json_log_drops_only_nudge_keeps_candidate(tmp_path, monkeypatch):\n+    \"\"\"The assistant candidate is NOT flagged synthetic, so it persists in the\n+    JSON log. Only the nudge (flagged synthetic) is dropped.\"\"\"\n     monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \".hermes\"))\n     ra = _fresh_run_agent(tmp_path)\n     agent = _make_agent(ra, \"sess_json\", tmp_path)\n \n     messages = [\n         {\"role\": \"user\", \"content\": \"hi\"},\n-        {\"role\": \"assistant\", \"content\": \"premature done\", \"_pre_verify_synthetic\": True},\n+        # Assistant candidate — NOT flagged synthetic, persists.\n+        {\"role\": \"assistant\", \"content\": \"premature done\"},\n+        # Nudge — flagged synthetic, gets dropped.\n         {\"role\": \"user\", \"content\": \"[System: run tests]\", \"_pre_verify_synthetic\": True},\n         {\"role\": \"assistant\", \"content\": \"verified and clean\"},\n     ]","@@ -106,5 +117,10 @@ def test_json_log_drops_verification_scaffolding(tmp_path, monkeypatch):\n     assert log_file.exists()\n     data = json.loads(log_file.read_text(encoding=\"utf-8\"))\n     contents = [m.get(\"content\") for m in data[\"messages\"]]\n-    assert contents == [\"hi\", \"verified and clean\"]\n+    # The assistant candidate persists — it is real content.\n+    assert \"premature done\" in contents\n+    assert \"verified and clean\" in contents\n+    assert \"hi\" in contents\n+    # Only the nudge is dropped.\n+    assert \"[System: run tests]\" not in contents\n     assert all(not m.get(\"_pre_verify_synthetic\") for m in data[\"messages\"])"]},{"file":"tests/gateway/test_stream_consumer.py","hunks":["@@ -2379,3 +2379,40 @@ def test_flush_think_buffer_strips_orphan_close(self):\n             assert tag not in consumer._accumulated\n         assert \"trailing prose\" in consumer._accumulated\n         assert \"more\" in consumer._accumulated\n+\n+\n+class TestHasDeliveredTextAfterSegmentBreak:\n+    \"\"\"has_delivered_text must find a delivered segment after a segment break,\n+    but must not claim text from a failed delivery. (#65919 review)\"\"\"\n+\n+    def test_finds_delivered_segment_after_segment_break(self):\n+        \"\"\"A successfully delivered segment must still be found by\n+        has_delivered_text after _reset_segment_state runs.\"\"\"\n+        c = _make_consumer()\n+        # Simulate a successfully delivered segment\n+        c._last_sent_text = \"Here is the first segment\"\n+        c._reset_segment_state()\n+        # After the reset, has_delivered_text must still find it\n+        assert c.has_delivered_text(\"Here is the first segment\") is True\n+\n+    def test_does_not_find_undelivered_text(self):\n+        \"\"\"Text that was never delivered must not be claimed.\"\"\"\n+        c = _make_consumer()\n+        c._last_sent_text = \"delivered text\"\n+        c._reset_segment_state()\n+        assert c.has_delivered_text(\"never sent text\") is False\n+\n+    def test_finds_commentary_text(self):\n+        \"\"\"has_delivered_text must find commentary text delivered via\n+        on_commentary.\"\"\"\n+        c = _make_consumer()\n+        c._delivered_commentary_texts.append(\"interim commentary\")\n+        assert c.has_delivered_text(\"interim commentary\") is True\n+\n+    def test_does_not_match_empty(self):\n+        \"\"\"Empty/whitespace text must not match.\"\"\"\n+        c = _make_consumer()\n+        c._last_sent_text = \"some text\"\n+        c._reset_segment_state()\n+        assert c.has_delivered_text(\"\") is False\n+        assert c.has_delivered_text(\"   \") is False"]},{"file":"tests/run_agent/test_run_agent_codex_responses.py","hunks":["@@ -2371,6 +2371,37 @@ def failing_callback(_text):\n     }\n \n \n+def test_interim_content_was_streamed_matches_prefix_not_exact(monkeypatch):\n+    \"\"\"_interim_content_was_streamed should return True when the streamed text\n+    is a PREFIX of the final content (trailing delta added after stream, or\n+    partial stream before verify nudge).  Exact equality is too strict — it\n+    fails safe to a benign duplicate bubble instead of settling the interim.\n+    (#65919 review: prefix-based match like the TUI's finalTail dedup.)\"\"\"\n+    agent = _build_agent(monkeypatch)\n+\n+    # Exact match still works\n+    agent._current_streamed_assistant_text = \"hello world\"\n+    assert agent._interim_content_was_streamed(\"hello world\") is True\n+\n+    # Streamed is a prefix of the final (trailing delta) — should match\n+    agent._current_streamed_assistant_text = \"hello\"\n+    assert agent._interim_content_was_streamed(\"hello world\") is True\n+\n+    # Streamed is empty — should not match\n+    agent._current_streamed_assistant_text = \"\"\n+    assert agent._interim_content_was_streamed(\"hello world\") is False\n+\n+    # Final is empty — should not match\n+    agent._current_streamed_assistant_text = \"hello\"\n+    assert agent._interim_content_was_streamed(\"\") is False\n+\n+    # Streamed is LONGER than final (reverse direction) — should NOT match.\n+    # This is the unsafe direction: it could suppress a needed resend in the\n+    # gateway path where already_streamed=True calls on_segment_break().\n+    agent._current_streamed_assistant_text = \"hello world extra\"\n+    assert agent._interim_content_was_streamed(\"hello\") is False\n+\n+\n def test_interim_commentary_preserves_assistant_content(monkeypatch):\n     \"\"\"Interim commentary must not silently mutate assistant text containing\n     literal <memory-context> markers — that's legitimate model output (docs,"]},{"file":"tests/run_agent/test_verification_continuation_budget.py","hunks":["@@ -1,4 +1,4 @@\n-\"\"\"End-to-end regression coverage for verification budget exhaustion (#61631).\"\"\"\n+\"\"\"End-to-end regression coverage for verification budget exhaustion (#61631, #65919 §7).\"\"\"\n \n from types import SimpleNamespace\n from unittest.mock import MagicMock, patch","@@ -51,11 +51,12 @@ def _assert_pending_response_survives(agent, result):\n     assert result[\"turn_exit_reason\"] == \"max_iterations_reached(1/1)\"\n     assert result[\"completed\"] is False\n     assert agent._handle_max_iterations.call_count == 0\n+    # The nudge is stripped by _drop_verification_continuation_scaffolding,\n+    # so the role sequence is [user, assistant] — the candidate is the\n+    # tail and matches final_response so it is not duplicated. (#65919 §7)\n     assert [message[\"role\"] for message in result[\"messages\"]] == [\n         \"user\",\n         \"assistant\",\n-        \"user\",\n-        \"assistant\",\n     ]\n \n ","@@ -75,8 +76,8 @@ def model_call(_api_kwargs):\n         result = agent.run_conversation(\"edit changed.py\")\n \n     _assert_pending_response_survives(agent, result)\n-    assert result[\"messages\"][1][\"_verification_stop_synthetic\"] is True\n-    assert result[\"messages\"][2][\"_verification_stop_synthetic\"] is True\n+    # The assistant response persists (it is real, unflagged content).\n+    assert not result[\"messages\"][1].get(\"_verification_stop_synthetic\")\n \n \n def test_pre_verify_preserves_composed_report_at_budget_limit(agent, monkeypatch):","@@ -100,8 +101,8 @@ def model_call(_api_kwargs):\n         result = agent.run_conversation(\"edit changed.py\")\n \n     _assert_pending_response_survives(agent, result)\n-    assert result[\"messages\"][1][\"_pre_verify_synthetic\"] is True\n-    assert result[\"messages\"][2][\"_pre_verify_synthetic\"] is True\n+    # The assistant response persists (it is real, unflagged content).\n+    assert not result[\"messages\"][1].get(\"_pre_verify_synthetic\")\n \n \n def test_intermediate_ack_uses_summary_instead_of_premature_text(agent, monkeypatch):","@@ -144,3 +145,177 @@ def test_later_verified_response_supersedes_pending_report(agent, monkeypatch):\n     assert result[\"turn_exit_reason\"] == \"text_response(finish_reason=stop)\"\n     assert result[\"completed\"] is True\n     agent._handle_max_iterations.assert_not_called()\n+\n+\n+def test_multiple_verification_retries_publish_each_candidate_once(agent, monkeypatch):\n+    \"\"\"Multiple verification retries should publish each candidate once, in order.\"\"\"\n+    agent.max_iterations = 3\n+    agent.iteration_budget.max_total = 3\n+    answers = iter([\n+        _response(\"candidate one\"),\n+        _response(\"candidate two\"),\n+        _response(\"candidate three\"),\n+    ])\n+    agent._interruptible_api_call = lambda _kwargs: next(answers)\n+    agent._handle_max_iterations = MagicMock(return_value=\"replacement summary\")\n+    monkeypatch.setenv(\"HERMES_VERIFY_ON_STOP\", \"1\")\n+\n+    # Three nudges, then None (so the third candidate is the final response).\n+    nudge_side_effects = [\"verify it\", \"verify it\", None]\n+\n+    emitted = []\n+    agent.interim_assistant_callback = lambda text, **kw: emitted.append(text)\n+\n+    with (\n+        patch(\n+            \"agent.verification_stop.build_verify_on_stop_nudge\",\n+            side_effect=nudge_side_effects,\n+        ),\n+        patch(\"hermes_cli.plugins.invoke_hook\", return_value=[]),\n+    ):\n+        result = agent.run_conversation(\"edit changed.py\")\n+\n+    # Each candidate was emitted as an interim message, in order.\n+    assert emitted == [\"candidate one\", \"candidate two\"]\n+    # The final response is the last candidate.\n+    assert result[\"final_response\"] == \"candidate three\"\n+    assert result[\"turn_exit_reason\"] == \"text_response(finish_reason=stop)\"\n+    assert result[\"completed\"] is True\n+    agent._handle_max_iterations.assert_not_called()\n+\n+\n+def test_verification_false_finalizes_candidate_once(agent, monkeypatch):\n+    \"\"\"When verification returns false/exception, the candidate is finalized once.\"\"\"\n+    agent._interruptible_api_call = lambda _kwargs: _response(\"the answer\")\n+    agent._handle_max_iterations = MagicMock(return_value=\"replacement summary\")\n+    monkeypatch.setenv(\"HERMES_VERIFY_ON_STOP\", \"1\")\n+\n+    emitted = []\n+    agent.interim_assistant_callback = lambda text, **kw: emitted.append(text)\n+\n+    with (\n+        # build_verify_on_stop_nudge raises — simulates verification check failure\n+        patch(\n+            \"agent.verification_stop.build_verify_on_stop_nudge\",\n+            side_effect=RuntimeError(\"verify check crashed\"),\n+        ),\n+        patch(\"hermes_cli.plugins.invoke_hook\", return_value=[]),\n+    ):\n+        result = agent.run_conversation(\"edit changed.py\")\n+\n+    # No interim emission because verification did not run (exception path\n+    # sets _verify_nudge = None, so the candidate becomes the final response\n+    # without an interim emission).\n+    assert result[\"final_response\"] == \"the answer\"\n+    assert result[\"completed\"] is True\n+    agent._handle_max_iterations.assert_not_called()\n+\n+\n+def test_verify_on_stop_emits_interim_response_to_ui(agent, monkeypatch):\n+    \"\"\"The verify-on-stop path must emit the full response to the UI callback.\n+\n+    With no streaming set up in this test, _interim_content_was_streamed\n+    returns False, so already_streamed is False — the callback reports\n+    content the UI has not seen yet.\n+    \"\"\"\n+    agent._interruptible_api_call = lambda _kwargs: _response(\"composed report\")\n+    agent._handle_max_iterations = MagicMock(return_value=\"replacement summary\")\n+    monkeypatch.setenv(\"HERMES_VERIFY_ON_STOP\", \"1\")\n+\n+    callback_calls = []\n+\n+    def capture_callback(text, *, already_streamed=None):\n+        callback_calls.append({\"text\": text, \"already_streamed\": already_streamed})\n+\n+    agent.interim_assistant_callback = capture_callback\n+\n+    with (\n+        patch(\"agent.verification_stop.build_verify_on_stop_nudge\", return_value=\"verify it\"),\n+        patch(\"hermes_cli.plugins.invoke_hook\", return_value=[]),\n+    ):\n+        result = agent.run_conversation(\"edit changed.py\")\n+\n+    # The callback was called with the full response text and already_streamed=False\n+    assert len(callback_calls) == 1\n+    assert callback_calls[0][\"text\"] == \"composed report\"\n+    assert callback_calls[0][\"already_streamed\"] is False\n+\n+    # The candidate persists as the final response.\n+    assert result[\"final_response\"] == \"composed report\"\n+\n+\n+def test_streamed_interim_then_different_summary_not_marked_previewed(agent, monkeypatch):\n+    \"\"\"Ordinary interim narration followed by a different non-streamed summary.\n+\n+    The model streams \"I'll inspect the files now\" as an intermediate ack.\n+    _emit_interim_assistant_message is called for this ordinary narration,\n+    which must NOT set _response_was_previewed. Then _handle_max_iterations\n+    produces a different summary through the non-streaming Chat Completions\n+    path. The final result must NOT be marked as previewed — the interim was\n+    unrelated mid-turn commentary, not the final response — so the CLI renders\n+    the summary instead of suppressing it. (#65919 review: response-loss blocker)\n+    \"\"\"\n+    agent.valid_tool_names = [\"web_search\"]\n+    agent._intent_ack_continuation = True\n+    agent._looks_like_codex_intermediate_ack = MagicMock(return_value=True)\n+    agent._interruptible_api_call = lambda _kwargs: _response(\"I'll inspect the files now\")\n+    agent._handle_max_iterations = MagicMock(return_value=\"Here is the summary of what I found.\")\n+    monkeypatch.setenv(\"HERMES_VERIFY_ON_STOP\", \"0\")\n+\n+    emitted = []\n+    agent.interim_assistant_callback = lambda text, **kw: emitted.append(text)\n+\n+    with (\n+        patch(\"hermes_cli.plugins.has_hook\", return_value=False),\n+        patch(\"hermes_cli.plugins.invoke_hook\", return_value=[]),\n+    ):\n+        result = agent.run_conversation(\"inspect /tmp/project\")\n+\n+    # The final response is the different summary from _handle_max_iterations.\n+    assert result[\"final_response\"] == \"Here is the summary of what I found.\"\n+    # CRITICAL: response_previewed must be False — the interim narration was\n+    # NOT the final response, so the CLI must render the summary.\n+    assert result[\"response_previewed\"] is False\n+\n+\n+def test_streamed_verification_candidate_reused_marked_previewed(agent, monkeypatch):\n+    \"\"\"Verification candidate reused at budget exhaustion is marked previewed.\n+\n+    The model streams a verification candidate that is already streamed as\n+    interim content. The continuation budget is exhausted, so the finalizer\n+    reuses the pending verification candidate as the final response. The result\n+    must be marked as previewed so the CLI/desktop settle it once instead of\n+    duplicating. (#65919 review)\n+    \"\"\"\n+    agent._interruptible_api_call = lambda _kwargs: _response(\"composed report\")\n+    agent._handle_max_iterations = MagicMock(return_value=\"replacement summary\")\n+    monkeypatch.setenv(\"HERMES_VERIFY_ON_STOP\", \"1\")\n+\n+    agent._turn_file_mutation_paths = {\"changed.py\"}\n+\n+    callback_calls = []\n+\n+    def capture_callback(text, *, already_streamed=None):\n+        callback_calls.append({\"text\": text, \"already_streamed\": already_streamed})\n+\n+    agent.interim_assistant_callback = capture_callback\n+\n+    # Simulate that the candidate text was already streamed. The streaming\n+    # buffer is cleared after the response is processed, so mock the check\n+    # directly — this is the condition the test validates: when the candidate\n+    # was streamed, the previewed flag propagates to the finalizer.\n+    with (\n+        patch.object(agent, \"_interim_content_was_streamed\", return_value=True),\n+        patch(\"agent.verification_stop.build_verify_on_stop_nudge\", return_value=\"verify it\"),\n+        patch(\"hermes_cli.plugins.invoke_hook\", return_value=[]),\n+    ):\n+        result = agent.run_conversation(\"edit changed.py\")\n+\n+    # The candidate was already streamed, so the callback reports already_streamed=True.\n+    assert len(callback_calls) == 1\n+    assert callback_calls[0][\"already_streamed\"] is True\n+    # The candidate is reused as the final response.\n+    assert result[\"final_response\"] == \"composed report\"\n+    # CRITICAL: response_previewed must be True — the reused candidate was\n+    # streamed as interim content, so the CLI/desktop settle it once.\n+    assert result[\"response_previewed\"] is True"]},{"file":"tests/tui_gateway/test_interim_assistant_callback.py","hunks":["@@ -0,0 +1,105 @@\n+\"\"\"Tests for the interim_assistant_callback config gating in tui_gateway.\n+\n+These tests exercise the real _agent_cbs() wiring rather than a local\n+imitation, so a break in the production callback registration is caught.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from unittest.mock import patch\n+\n+\n+def test_load_interim_assistant_messages_defaults_true():\n+    from tui_gateway.server import _load_interim_assistant_messages\n+\n+    with patch(\"tui_gateway.server._load_cfg\", return_value={}):\n+        assert _load_interim_assistant_messages() is True\n+\n+\n+def test_load_interim_assistant_messages_explicit_true():\n+    from tui_gateway.server import _load_interim_assistant_messages\n+\n+    with patch(\"tui_gateway.server._load_cfg\", return_value={\"display\": {\"interim_assistant_messages\": True}}):\n+        assert _load_interim_assistant_messages() is True\n+\n+\n+def test_load_interim_assistant_messages_explicit_false():\n+    from tui_gateway.server import _load_interim_assistant_messages\n+\n+    with patch(\"tui_gateway.server._load_cfg\", return_value={\"display\": {\"interim_assistant_messages\": False}}):\n+        assert _load_interim_assistant_messages() is False\n+\n+\n+def test_load_interim_assistant_messages_string_off():\n+    from tui_gateway.server import _load_interim_assistant_messages\n+\n+    with patch(\"tui_gateway.server._load_cfg\", return_value={\"display\": {\"interim_assistant_messages\": \"off\"}}):\n+        assert _load_interim_assistant_messages() is False\n+\n+\n+def test_agent_cbs_includes_interim_callback_when_enabled():\n+    \"\"\"_agent_cbs() includes interim_assistant_callback when the config is on.\n+\n+    Exercises the real _agent_cbs() wiring: the callback must be present in\n+    the returned dict and, when invoked, must emit a message.interim event\n+    with the text and already_streamed flag passed through.\n+    \"\"\"\n+    from tui_gateway.server import _agent_cbs\n+\n+    emitted: list[tuple] = []\n+\n+    def fake_emit(event_type, sid, payload=None):\n+        emitted.append((event_type, sid, payload))\n+\n+    with patch(\"tui_gateway.server._load_cfg\", return_value={}), \\\n+         patch(\"tui_gateway.server._emit\", side_effect=fake_emit):\n+        cbs = _agent_cbs(\"test-session\")\n+\n+        assert \"interim_assistant_callback\" in cbs\n+        cb = cbs[\"interim_assistant_callback\"]\n+        assert callable(cb)\n+\n+        # Invoke the real callback inside the patch context — the lambda\n+        # resolves _emit by name at call time, so it must be called while\n+        # the patch is active.\n+        cb(\"hello world\", already_streamed=True)\n+\n+    assert len(emitted) == 1\n+    assert emitted[0][0] == \"message.interim\"\n+    assert emitted[0][1] == \"test-session\"\n+    assert emitted[0][2][\"text\"] == \"hello world\"\n+    assert emitted[0][2][\"already_streamed\"] is True\n+\n+\n+def test_agent_cbs_omits_interim_callback_when_disabled():\n+    \"\"\"_agent_cbs() omits interim_assistant_callback when the config is off.\n+\n+    Exercises the real _agent_cbs() wiring: the callback must NOT be present\n+    in the returned dict when display.interim_assistant_messages is false.\n+    \"\"\"\n+    from tui_gateway.server import _agent_cbs\n+\n+    with patch(\"tui_gateway.server._load_cfg\", return_value={\"display\": {\"interim_assistant_messages\": False}}):\n+        cbs = _agent_cbs(\"test-session\")\n+\n+    assert \"interim_assistant_callback\" not in cbs\n+\n+\n+def test_agent_cbs_interim_callback_passes_already_streamed_false():\n+    \"\"\"The real callback passes already_streamed=False by default.\"\"\"\n+    from tui_gateway.server import _agent_cbs\n+\n+    emitted: list[tuple] = []\n+\n+    def fake_emit(event_type, sid, payload=None):\n+        emitted.append((event_type, sid, payload))\n+\n+    with patch(\"tui_gateway.server._load_cfg\", return_value={}), \\\n+         patch(\"tui_gateway.server._emit\", side_effect=fake_emit):\n+        cbs = _agent_cbs(\"test-session\")\n+\n+        cb = cbs[\"interim_assistant_callback\"]\n+        cb(\"interim text\")\n+\n+    assert emitted[0][2][\"already_streamed\"] is False\n+    assert emitted[0][2][\"text\"] == \"interim text\""]},{"file":"tui_gateway/server.py","hunks":["@@ -430,6 +430,20 @@ def _load_busy_input_mode() -> str:\n     return raw if raw in {\"queue\", \"steer\", \"interrupt\"} else \"interrupt\"\n \n \n+def _load_interim_assistant_messages() -> bool:\n+    \"\"\"Return whether interim assistant commentary should be surfaced to UIs.\n+\n+    Honors ``display.interim_assistant_messages`` (default true). When false,\n+    the tui_gateway does not install ``interim_assistant_callback``, so\n+    interim text from tool-call turns and verify-on-stop candidates is never\n+    emitted as ``message.interim`` — mirroring the messaging gateway's gating.\n+    \"\"\"\n+    display = _load_cfg().get(\"display\")\n+    if not isinstance(display, dict):\n+        return True\n+    return is_truthy_value(display.get(\"interim_assistant_messages\", True))\n+\n+\n def _notify_session_boundary(\n     event_type: str, session_id: str | None, platform: str | None = None\n ) -> None:","@@ -4307,7 +4321,7 @@ def _mirror_subagent_to_child(event_type: str, payload: dict) -> None:\n \n \n def _agent_cbs(sid: str) -> dict:\n-    return {\n+    callbacks = {\n         \"tool_start_callback\": lambda tc_id, name, args: _on_tool_start(\n             sid, tc_id, name, args\n         ),","@@ -4362,6 +4376,22 @@ def _agent_cbs(sid: str) -> dict:\n         ),\n     }\n \n+    # Interim assistant commentary (text alongside tool calls, or the attempted\n+    # final answer before a verify-on-stop nudge). Gated on\n+    # display.interim_assistant_messages (default true). Also set per-turn in\n+    # _run_prompt_submit as defense-in-depth — the per-turn set overwrites\n+    # this, and the finally block clears it so a stale closure can't fire.\n+    if _load_interim_assistant_messages():\n+        callbacks[\"interim_assistant_callback\"] = (\n+            lambda text, *, already_streamed=False: _emit(\n+                \"message.interim\",\n+                sid,\n+                {\"text\": str(text), \"already_streamed\": bool(already_streamed)},\n+            )\n+        )\n+\n+    return callbacks\n+\n \n def _apply_project_workspace(task_id: str, path: str, _name: str = \"\") -> None:\n     \"\"\"Intentional workspace move from the project_* tools: re-anchor the live","@@ -10034,6 +10064,22 @@ def _stream(delta):\n                     payload[\"rendered\"] = r\n                 _emit(\"message.delta\", sid, payload)\n \n+            # Surface interim assistant text (commentary emitted alongside\n+            # tool calls, or the attempted final answer before a verify-on-stop\n+            # nudge) so the desktop can seal it as its own segment instead of\n+            # losing it when message.complete replaces the streaming buffer.\n+            # Gated on display.interim_assistant_messages (default true).\n+            if _load_interim_assistant_messages():\n+                def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None:\n+                    _emit(\"message.interim\", sid, {\n+                        \"text\": text,\n+                        \"already_streamed\": already_streamed,\n+                    })\n+\n+                agent.interim_assistant_callback = _interim_assistant_cb\n+            else:\n+                agent.interim_assistant_callback = None\n+\n             run_kwargs = {\n                 \"conversation_history\": list(history),\n                 \"stream_callback\": _stream,","@@ -10155,6 +10201,8 @@ def _stream(delta):\n                 payload[\"reasoning\"] = last_reasoning\n             if status_note:\n                 payload[\"warning\"] = status_note\n+            if result.get(\"response_previewed\"):\n+                payload[\"response_previewed\"] = True\n             rendered = render_message(raw, cols)\n             if rendered:\n                 payload[\"rendered\"] = rendered","@@ -10338,6 +10386,9 @@ def _stream(delta):\n             if home_token is not None:\n                 reset_hermes_home_override(home_token)\n             _clear_session_context(session_tokens)\n+            # Clear the per-turn interim callback so a stale closure from\n+            # this turn can't fire during a later turn on the same agent.\n+            agent.interim_assistant_callback = None\n             with session[\"history_lock\"]:\n                 session[\"running\"] = False\n                 session[\"last_active\"] = time.time()"]},{"file":"ui-tui/src/__tests__/createGatewayEventHandler.test.ts","hunks":["@@ -1683,4 +1683,81 @@ describe('createGatewayEventHandler', () => {\n       expect(openExternalUrlMock).not.toHaveBeenCalled()\n     })\n   })\n+\n+  describe('message.interim', () => {\n+    it('finalizes an interim segment without settling the turn', () => {\n+      const appended: Msg[] = []\n+      const onEvent = createGatewayEventHandler(buildCtx(appended))\n+\n+      onEvent({ payload: {}, type: 'message.start' } as any)\n+      onEvent({ payload: { text: 'streaming text' }, type: 'message.delta' } as any)\n+      onEvent({ payload: { already_streamed: true, text: 'streaming text' }, type: 'message.interim' } as any)\n+\n+      // Turn is still active — busy stays true, no completion messages appended\n+      expect(getUiState().busy).toBe(true)\n+      expect(appended).toHaveLength(0)\n+    })\n+\n+    it('keeps identical interim and terminal replies as separate messages without response_previewed', () => {\n+      const appended: Msg[] = []\n+      const onEvent = createGatewayEventHandler(buildCtx(appended))\n+\n+      onEvent({ payload: {}, type: 'message.start' } as any)\n+      onEvent({ payload: { already_streamed: true, text: 'same reply' }, type: 'message.interim' } as any)\n+      onEvent({ payload: { text: 'same reply' }, type: 'message.complete' } as any)\n+\n+      const assistantMsgs = appended.filter(m => m.role === 'assistant' && m.text)\n+      expect(assistantMsgs).toHaveLength(2)\n+    })\n+\n+    it('settles identical terminal reply onto interim when response_previewed', () => {\n+      const appended: Msg[] = []\n+      const onEvent = createGatewayEventHandler(buildCtx(appended))\n+\n+      onEvent({ payload: {}, type: 'message.start' } as any)\n+      onEvent({ payload: { already_streamed: true, text: 'same reply' }, type: 'message.interim' } as any)\n+      onEvent({ payload: { response_previewed: true, text: 'same reply' }, type: 'message.complete' } as any)\n+\n+      // With response_previewed, the terminal reply is the same model\n+      // response that was published provisionally — settle onto the\n+      // interim instead of duplicating. (#65919 review)\n+      const assistantMsgs = appended.filter(m => m.role === 'assistant' && m.text)\n+      expect(assistantMsgs).toHaveLength(1)\n+      expect(assistantMsgs[0]?.text).toBe('same reply')\n+    })\n+\n+    it('deduplicates flushed chunks within the terminal message after an interim boundary', () => {\n+      const appended: Msg[] = []\n+      const onEvent = createGatewayEventHandler(buildCtx(appended))\n+\n+      onEvent({ payload: {}, type: 'message.start' } as any)\n+      // Interim seals the first segment\n+      onEvent({ payload: { already_streamed: true, text: 'interim answer' }, type: 'message.interim' } as any)\n+      // Post-interim deltas that match the final text — these get deduped\n+      onEvent({ payload: { text: 'final answer' }, type: 'message.delta' } as any)\n+      onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any)\n+\n+      const texts = appended.filter(m => m.role === 'assistant' && m.text).map(m => m.text)\n+      // interim + final, no duplication of the final\n+      expect(texts).toContain('interim answer')\n+      expect(texts.filter(t => t === 'final answer')).toHaveLength(1)\n+    })\n+\n+    it('ignores malformed message.interim payload', () => {\n+      const appended: Msg[] = []\n+      const onEvent = createGatewayEventHandler(buildCtx(appended))\n+\n+      onEvent({ payload: {}, type: 'message.start' } as any)\n+      // No payload at all\n+      onEvent({ type: 'message.interim' } as any)\n+      // Empty text\n+      onEvent({ payload: { text: '' }, type: 'message.interim' } as any)\n+      // Undefined text\n+      onEvent({ payload: { text: undefined }, type: 'message.interim' } as any)\n+\n+      // Turn continues without finalizing or throwing\n+      expect(getUiState().busy).toBe(true)\n+      expect(appended).toHaveLength(0)\n+    })\n+  })\n })"]},{"file":"ui-tui/src/app/createGatewayEventHandler.ts","hunks":["@@ -946,6 +946,14 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:\n         turnController.recordMessageDelta(ev.payload ?? {})\n \n         return\n+      case 'message.interim': {\n+        const text = ev.payload?.text\n+        if (typeof text === 'string' && text.trim()) {\n+          turnController.recordInterimMessage(text)\n+        }\n+\n+        return\n+      }\n       case 'message.complete': {\n         const { finalMessages, finalText, wasInterrupted } = turnController.recordMessageComplete(ev.payload ?? {})\n "]},{"file":"ui-tui/src/app/turnController.ts","hunks":["@@ -126,6 +126,7 @@ class TurnController {\n   private activeTools: ActiveTool[] = []\n   private activeReasoningText = ''\n   private reasoningSegmentIndex: null | number = null\n+  private interimBoundaryIndex: null | number = null\n   private activityId = 0\n   private reasoningStreamingTimer: Timer = null\n   private reasoningTimer: Timer = null","@@ -554,7 +555,7 @@ class TurnController {\n     this.flushPendingNotice()\n   }\n \n-  recordMessageComplete(payload: { rendered?: string; reasoning?: string; text?: string }) {\n+  recordMessageComplete(payload: { rendered?: string; reasoning?: string; response_previewed?: boolean; text?: string }) {\n     this.closeReasoningSegment()\n \n     // Ink renders markdown via <Md>; the gateway's Rich-rendered ANSI","@@ -565,7 +566,15 @@ class TurnController {\n     // only when the gateway elected not to send any (#16391).\n     const rawText = (payload.text ?? payload.rendered ?? this.bufRef).trimStart()\n     const split = splitReasoning(rawText)\n-    const finalText = finalTail(split.text, this.segmentMessages)\n+    // Only dedupe segments AFTER the interim boundary — interim-sealed\n+    // segments are preserved even if the final text includes them.\n+    // Exception: when response_previewed is true, the final text is the\n+    // same model response that was published provisionally as an interim\n+    // message. Dedupe against ALL segments (including sealed interims) so\n+    // the identical text doesn't render as a duplicate message. (#65919\n+    // review: duplicate-message blocker)\n+    const dedupeStart = payload.response_previewed ? 0 : (this.interimBoundaryIndex ?? 0)\n+    const finalText = finalTail(split.text, this.segmentMessages.slice(dedupeStart))\n     const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()\n     const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\\n\\n')\n     const savedToolTokens = this.toolTokenAcc","@@ -672,6 +681,31 @@ class TurnController {\n     }\n   }\n \n+  recordInterimMessage(text: string) {\n+    if (this.interrupted) {\n+      return\n+    }\n+\n+    const authoritativeText = text.trimStart()\n+    if (!authoritativeText) {\n+      return\n+    }\n+\n+    // If the streaming buffer hasn't caught up to the authoritative interim\n+    // text (e.g. the backend didn't stream every token), sync it so the\n+    // sealed segment matches what the user should see.\n+    if (this.bufRef.trimStart() !== authoritativeText) {\n+      this.bufRef = authoritativeText\n+    }\n+\n+    // Flush the current streaming buffer into a sealed segment — this is the\n+    // TUI equivalent of the desktop's finalizeInterimAssistantMessage. The\n+    // segment survives message.complete's finalTail dedupe because\n+    // interimBoundaryIndex marks it as interim-sealed.\n+    this.flushStreamingSegment()\n+    this.interimBoundaryIndex = this.segmentMessages.length\n+  }\n+\n   recordReasoningAvailable(text: string, force = false) {\n     if (this.interrupted || (!force && !getUiState().showReasoning)) {\n       return","@@ -885,6 +919,7 @@ class TurnController {\n     this.pendingSegmentTools = []\n     this.protocolWarned = false\n     this.reasoningSegmentIndex = null\n+    this.interimBoundaryIndex = null\n     this.segmentMessages = []\n     this.turnTools = []\n     this.toolTokenAcc = 0","@@ -941,6 +976,7 @@ class TurnController {\n     this.activeTools = []\n     this.activeReasoningText = ''\n     this.reasoningSegmentIndex = null\n+    this.interimBoundaryIndex = null\n     this.turnTools = []\n     this.toolTokenAcc = 0\n     this.interrupted = false"]},{"file":"ui-tui/src/gatewayTypes.ts","hunks":["@@ -641,7 +641,12 @@ export type GatewayEvent =\n   | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.complete' }\n   | { payload: { rendered?: string; text?: string }; session_id?: string; type: 'message.delta' }\n   | {\n-      payload?: { reasoning?: string; rendered?: string; text?: string; usage?: Usage }\n+      payload: { already_streamed?: boolean; text: string }\n+      session_id?: string\n+      type: 'message.interim'\n+    }\n+  | {\n+      payload?: { reasoning?: string; rendered?: string; response_previewed?: boolean; text?: string; usage?: Usage }\n       session_id?: string\n       type: 'message.complete'\n     }\n"]}]},{"kind":"pr","number":107386,"title":"fix(desktop): preserve interim messages across history and tool boundaries","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/history_commentary.py","agent/stream_delivery.py","apps/desktop/src/app/session/hooks/use-message-stream/interim-history.test.tsx","apps/desktop/src/lib/chat-messages.codex-commentary-reprojection.test.ts","apps/desktop/src/lib/chat-messages.codex-json-sidecar.test.ts","apps/desktop/src/lib/chat-messages.codex-sidecar-hydration.test.ts","apps/desktop/src/lib/chat-messages.interim-preservation.test.ts","apps/desktop/src/lib/chat-messages.test.ts","apps/desktop/src/lib/chat-messages/hydration.ts","apps/desktop/src/lib/chat-messages/parts.ts","apps/desktop/src/types/hermes.ts","hermes_cli/web_routers/sessions.py","tests/hermes_cli/test_history_commentary_display.py","tui_gateway/compute_host.py","tui_gateway/methods_session.py","tui_gateway/methods_slash.py","tui_gateway/server.py","tui_gateway/session_history.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}