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.
This commit is contained in:
Teknium
2026-09-07 09:19:31 -07:00
parent e9bccc90a5
commit 610c869ac6
9 changed files with 46 additions and 10 deletions

View File

@@ -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) {

View File

@@ -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')
})
})

View File

@@ -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()
})

View File

@@ -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<string, unknown> {

View File

@@ -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<TimelineToolCallProps> = props => {
const ChainToolFallback: FC<TimelineToolCallProps> = props => {
// todo parts are hoisted to a dedicated panel above the message content.
if (props.toolName === 'todo') {
if (isTodoToolName(props.toolName)) {
return null
}

View File

@@ -348,6 +348,7 @@ const DEFAULT_COUNT_NOUN_BY_TOOL: Record<string, string> = {
search_files: 'result',
session_search_recall: 'result',
todo: 'todo',
todo_list: 'todo',
web_search: 'result'
}

View File

@@ -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
}

View File

@@ -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<string, unknown> => 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
}

View File

@@ -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)