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:
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user