From 610c869ac6da3cca14199b5b2a22b5bc5107d45b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:19:31 -0700 Subject: [PATCH] fix(desktop): task panel follows the todo_list wire name The core-tool rename shipped `todo_list` on the wire (legacy alias `todo` kept for old transcripts), but the Desktop renderer still matched the tool by the literal `todo` in seven places: the live tool.start/tool.complete mirror into the composer status stack, the todo-stream router, args carry-over, the transcript hoist, the silent-tool class, the count noun, and stored-history hydration. Every live task update therefore went into the transcript as an ordinary tool row while the task panel stayed empty, and reopening a chat never restored a finished list. One predicate (`isTodoToolName`) now owns the wire/legacy name pair and every site reads it. --- .../session/hooks/use-message-stream/index.ts | 4 +-- .../use-message-stream/todo-cleanup.test.tsx | 28 +++++++++++++++++++ .../hooks/use-message-stream/utils.test.ts | 3 +- .../session/hooks/use-message-stream/utils.ts | 5 ++-- .../assistant-ui/thread/message-parts.tsx | 3 +- .../assistant-ui/tool/fallback-model/index.ts | 1 + .../src/lib/chat-messages/tool-parts.ts | 4 +-- apps/desktop/src/lib/todos.ts | 6 +++- apps/desktop/src/lib/tool-render-class.ts | 2 +- 9 files changed, 46 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index 01738aff65..dda959f508 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -23,7 +23,7 @@ import { generatedImageEchoSources, stripGeneratedImageEchoes } from '@/lib/generated-images' -import { nextTodosFromToolEvent, parseTodoRevision } from '@/lib/todos' +import { isTodoToolName, nextTodosFromToolEvent, parseTodoRevision } from '@/lib/todos' import { dispatchNativeNotification } from '@/store/native-notifications' import { isDiskFullErrorMessage, notifyError } from '@/store/notifications' import { broadcastSessionsChanged } from '@/store/session-sync' @@ -461,7 +461,7 @@ export function useMessageStream({ // The composer status stack owns todo display now (no inline panel) — // mirror every todo state the tool reports into its session store. - if (payload?.name === 'todo') { + if (payload && isTodoToolName(payload.name)) { const todos = nextTodosFromToolEvent($todosBySession.get()[sessionId] ?? [], payload) if (todos) { diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/todo-cleanup.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/todo-cleanup.test.tsx index 5922fc5bde..3d2a3a56a4 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/todo-cleanup.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/todo-cleanup.test.tsx @@ -71,3 +71,31 @@ describe('useMessageStream turn-end todo cleanup', () => { expect($todosBySession.get()[SID]?.[0]?.id).toBe('live') }) }) + +describe('useMessageStream todo tool naming', () => { + afterEach(() => { + cleanup() + clearSessionTodos(SID) + }) + + // The backend renamed the tool to `todo_list` (core-tool deferral); the + // composer status stack must follow the wire name, not only the legacy alias. + it('feeds the task panel from a tool.complete named todo_list', () => { + mountStream() + + act(() => + stream.handleEvent({ + payload: { + args: {}, + name: 'todo_list', + result: { revision: 2, todos: [todo('wire', 'in_progress')] }, + tool_id: 'c1' + }, + session_id: SID, + type: 'tool.complete' + }) + ) + + expect($todosBySession.get()[SID]?.[0]?.id).toBe('wire') + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/utils.test.ts b/apps/desktop/src/app/session/hooks/use-message-stream/utils.test.ts index e56561a9b0..7e3239f1cc 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/utils.test.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/utils.test.ts @@ -25,7 +25,8 @@ describe('completionErrorText', () => { describe('toTodoPayload', () => { it('routes named todo and anonymous todos-bearing events to the todo stream', () => { expect(toTodoPayload(payload({ name: 'todo' }))?.tool_id).toBe('todo-live') - expect(toTodoPayload(payload({ todos: [] }))?.name).toBe('todo') + expect(toTodoPayload(payload({ todos: [] }))?.name).toBe('todo_list') + expect(toTodoPayload(payload({ name: 'todo_list' }))?.tool_id).toBe('todo-live') expect(toTodoPayload(payload({ name: 'web_search' }))).toBeUndefined() expect(toTodoPayload(undefined)).toBeUndefined() }) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/utils.ts b/apps/desktop/src/app/session/hooks/use-message-stream/utils.ts index 391e329305..d93508c8ca 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/utils.ts @@ -1,5 +1,6 @@ import type { GatewayEventPayload } from '@/lib/chat-messages' import { normalizePersonalityValue } from '@/lib/chat-runtime' +import { isTodoToolName } from '@/lib/todos' import type { ClientSessionState } from '../../../types' @@ -142,9 +143,9 @@ export function toTodoPayload(payload: GatewayEventPayload | undefined): Gateway return undefined } - const isTodo = payload.name === 'todo' || (!payload.name && Object.hasOwn(payload, 'todos')) + const isTodo = isTodoToolName(payload.name) || (!payload.name && Object.hasOwn(payload, 'todos')) - return isTodo ? { ...payload, name: 'todo', tool_id: payload.tool_id || 'todo-live' } : undefined + return isTodo ? { ...payload, name: 'todo_list', tool_id: payload.tool_id || 'todo-live' } : undefined } function asRecord(value: unknown): Record { diff --git a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx index 5fbd46eedd..4c40389a6f 100644 --- a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx @@ -22,6 +22,7 @@ import { SCAFFOLD_LABEL_CLASS, SCAFFOLD_META_CLASS, ScaffoldRow } from '@/compon import { useI18n } from '@/i18n' import { generatedImageFromResult } from '@/lib/generated-images' import { separateGluedReasoningBlocks } from '@/lib/reasoning-blocks' +import { isTodoToolName } from '@/lib/todos' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' import { $reasoningCollapsedByDefault } from '@/store/reasoning-disclosure' @@ -64,7 +65,7 @@ const DelegateToolPart: FC = props => { const ChainToolFallback: FC = props => { // todo parts are hoisted to a dedicated panel above the message content. - if (props.toolName === 'todo') { + if (isTodoToolName(props.toolName)) { return null } diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts index 79dc89cd63..e3b0059582 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts @@ -348,6 +348,7 @@ const DEFAULT_COUNT_NOUN_BY_TOOL: Record = { search_files: 'result', session_search_recall: 'result', todo: 'todo', + todo_list: 'todo', web_search: 'result' } diff --git a/apps/desktop/src/lib/chat-messages/tool-parts.ts b/apps/desktop/src/lib/chat-messages/tool-parts.ts index de523aaa0a..881a564c8a 100644 --- a/apps/desktop/src/lib/chat-messages/tool-parts.ts +++ b/apps/desktop/src/lib/chat-messages/tool-parts.ts @@ -1,5 +1,5 @@ import { firstStringField, normalize } from '@/lib/text' -import { parseTodos } from '@/lib/todos' +import { isTodoToolName, parseTodos } from '@/lib/todos' import type { SessionMessage } from '@/types/hermes' import type { ChatMessage, ChatMessagePart, GatewayEventPayload } from './types' @@ -231,7 +231,7 @@ function carryTodos(payload: GatewayEventPayload | undefined, ...prev: unknown[] return next === null ? undefined : { todos: next } } - if (payload?.name !== 'todo') { + if (!isTodoToolName(payload?.name)) { return undefined } diff --git a/apps/desktop/src/lib/todos.ts b/apps/desktop/src/lib/todos.ts index 97aa7308d4..61beaf2b4d 100644 --- a/apps/desktop/src/lib/todos.ts +++ b/apps/desktop/src/lib/todos.ts @@ -18,6 +18,10 @@ export interface TodoPatch { const STATUSES: readonly TodoStatus[] = ['pending', 'in_progress', 'completed', 'cancelled'] +/** The task tool is `todo_list` on the wire since the core-tool rename; `todo` + * survives as the legacy alias in stored transcripts and older backends. */ +export const isTodoToolName = (name: unknown): boolean => name === 'todo_list' || name === 'todo' + const isRecord = (v: unknown): v is Record => Boolean(v && typeof v === 'object' && !Array.isArray(v)) const isStatus = (v: unknown): v is TodoStatus => (STATUSES as readonly string[]).includes(v as string) @@ -240,7 +244,7 @@ export function todosFromMessageContent(content: unknown): null | TodoItem[] { let latest: null | TodoItem[] = null for (const part of content) { - if (!isRecord(part) || part.type !== 'tool-call' || part.toolName !== 'todo') { + if (!isRecord(part) || part.type !== 'tool-call' || !isTodoToolName(part.toolName)) { continue } diff --git a/apps/desktop/src/lib/tool-render-class.ts b/apps/desktop/src/lib/tool-render-class.ts index a972d5f38b..f10a1af862 100644 --- a/apps/desktop/src/lib/tool-render-class.ts +++ b/apps/desktop/src/lib/tool-render-class.ts @@ -39,7 +39,7 @@ export function isCardTool(toolName: string): boolean { // dedicated panel above the message content, and a reaction's UI is the emoji // landing on the bubble. Both still render when they FAIL, which is a bounded // error row either way. -const SILENT_TOOL_NAMES = new Set(['react_to_message', 'todo']) +const SILENT_TOOL_NAMES = new Set(['react_to_message', 'todo', 'todo_list']) export function isSilentTool(toolName: string): boolean { return SILENT_TOOL_NAMES.has(toolName)