diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 431f555c2a..3818248047 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1040,15 +1040,9 @@ def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict): pass session.setdefault("tool_started_at", {})[tool_call_id] = time.time() if _tool_progress_enabled(sid): - # Don't echo args.todos on tool.start — for merge=true (or partial - # replacement) it's only the items being updated, not the full list, - # and would flicker the live count. tool.complete is the source of - # truth (always returns the full list from the tool result). - _emit( - "tool.start", - sid, - {"tool_id": tool_call_id, "name": name, "context": _tool_ctx(name, args)}, - ) + # tool.complete is the source of truth for todos (full list from the + # tool result). args.todos here may be a partial merge update. + _emit("tool.start", sid, {"tool_id": tool_call_id, "name": name, "context": _tool_ctx(name, args)}) def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result: str): diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 5ed17cbf7a..267bf8c166 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -11,7 +11,6 @@ import { applyDelegationStatus, getDelegationState } from './delegationStore.js' import type { GatewayEventHandlerContext } from './interfaces.js' import { patchOverlayState } from './overlayStore.js' import { turnController } from './turnController.js' -import { archiveTodosAtTurnEnd } from './turnStore.js' import { getUiState, patchUiState } from './uiStore.js' const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i @@ -537,12 +536,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: const { finalMessages, finalText, wasInterrupted } = turnController.recordMessageComplete(ev.payload ?? {}) if (!wasInterrupted) { - // Defensive: turnController.recordMessageComplete already prepends - // the archive at the head of finalMessages. This is a no-op in the - // normal path (state.todos is empty) but covers any edge where - // todos linger past the controller archive. - archiveTodosAtTurnEnd().forEach(appendMessage) - const msgs: Msg[] = finalMessages.length ? finalMessages : [{ role: 'assistant', text: finalText }] msgs.forEach(appendMessage) diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index c63ab2ce06..49a7fd7d67 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -469,12 +469,13 @@ class TurnController { ...(tools.length && { tools }) } - // Archive todos FIRST so the trail msg sits right after the user prompt, - // not between thinking/tools and the final assistant text. Keeps the - // panel visually anchored where it lived during streaming. - const archived = archiveDoneTodos() - const body = hasDetails(finalDetails) ? [...segments, finalDetails] : segments - const finalMessages: Msg[] = [...archived, ...body] + // Archive prepended so the trail msg anchors under the user prompt, + // not between thinking/tools and final assistant text. + const finalMessages: Msg[] = [ + ...archiveDoneTodos(), + ...segments, + ...(hasDetails(finalDetails) ? [finalDetails] : []) + ] if (finalText) { finalMessages.push({ role: 'assistant', text: finalText })