diff --git a/apps/desktop/src/app/chat/current-plan.test.tsx b/apps/desktop/src/app/chat/current-plan.test.tsx new file mode 100644 index 0000000000000..40e2729f51b75 --- /dev/null +++ b/apps/desktop/src/app/chat/current-plan.test.tsx @@ -0,0 +1,347 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { atom } from 'nanostores' +import { MemoryRouter } from 'react-router' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { I18nProvider } from '@/i18n' +import { type ChatMessage, textPart } from '@/lib/chat-messages' +import { createClientSessionState } from '@/lib/chat-runtime' +import type { TodoItem } from '@/lib/todos' +import { $activeSessionId } from '@/store/session' +import { $sessionStates } from '@/store/session-states' +import { $todosBySession, clearSessionTodos, setSessionTodos } from '@/store/todos' + +import { CurrentPlanPanel, CurrentPlanSurface, RoutedCurrentPlanSurface } from './current-plan' +import { type SessionView, SessionViewProvider } from './session-view' + +const todos: TodoItem[] = [ + { content: 'Inspect persisted history', id: 'inspect', status: 'completed' }, + { content: 'Do not claim liveness', id: 'liveness', status: 'in_progress' }, + { content: 'Keep future work visible', id: 'future', status: 'pending' }, + { content: 'Skip obsolete path', id: 'skip', status: 'cancelled' } +] + +const planPart = (items: TodoItem[], updatedAt = 20) => ({ + type: 'tool-call' as const, + toolCallId: 'todo-1', + toolName: 'todo', + args: { todos: items } as never, + result: { todos: items }, + todoUpdatedAt: updatedAt +}) + +const transcript = (label: string, items = todos): ChatMessage[] => [ + { id: `${label}-user`, parts: [textPart(`Request ${label}`)], role: 'user', timestamp: 10 }, + { id: `${label}-assistant`, parts: [planPart(items)], role: 'assistant', timestamp: 25 } +] + +function viewFor(messages: ChatMessage[], options: { busy?: boolean; runtimeId?: string | null; storedId?: string } = {}): SessionView { + const runtimeId = options.runtimeId === undefined ? 'runtime-1' : options.runtimeId + + return { + kind: 'primary', + $awaitingResponse: atom(false), + $busy: atom(Boolean(options.busy)), + $cwd: atom(''), + $fast: atom(false), + $lastVisibleIsUser: atom(false), + $messages: atom(messages), + $messagesEmpty: atom(messages.length === 0), + $model: atom(''), + $provider: atom(''), + $reasoningEffort: atom(''), + $runtimeId: atom(runtimeId), + $storedId: atom(options.storedId ?? 'stored-1') + } +} + +afterEach(() => { + cleanup() + $activeSessionId.set(null) + $sessionStates.set({}) +}) + +beforeEach(() => { + $activeSessionId.set(null) + $sessionStates.set({}) + $todosBySession.set({}) +}) + +describe('CurrentPlanPanel', () => { + it('is collapsed by default and expands into a read-only checklist with provenance and liveness warning', () => { + render( + + ) + + const toggle = screen.getByRole('button', { name: /current plan/i }) + + expect(toggle.getAttribute('aria-expanded')).toBe('false') + const controls = toggle.getAttribute('aria-controls') + expect(controls).not.toBeNull() + expect(toggle.getAttribute('aria-label')).toMatch(/Paused.*1\/4 complete/) + expect(screen.getByText('Paused')).not.toBeNull() + expect(screen.getByText('1/4 complete')).not.toBeNull() + expect(screen.queryByText('Inspect persisted history')).toBeNull() + + fireEvent.click(toggle) + + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(toggle.ownerDocument.getElementById(controls!)).not.toBeNull() + expect(screen.getByText('Inspect persisted history')).not.toBeNull() + expect(screen.getByText('Previously in progress')).not.toBeNull() + expect(screen.getByText(/does not prove that a worker, process, or delegation is running/i)).not.toBeNull() + expect(screen.getByText(/Turn 1/)).not.toBeNull() + expect(screen.getByText(/stored-1/)).not.toBeNull() + + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + }) + + it('provides a visible non-ring keyboard focus treatment for the disclosure', () => { + render( + + ) + + const toggle = screen.getByRole('button', { name: /current plan/i }) + + expect(toggle.className).toContain('focus-visible:bg-(--ui-control-active-background)') + expect(toggle.className).toContain('focus-visible:text-foreground') + }) + + it('localizes disclosure accessibility, status, turn, and item-state copy in Arabic', () => { + render( + + + + ) + + const toggle = screen.getByRole('button', { name: /توسيع الخطة الحالية، متوقفة مؤقتا، اكتمل 1\/4/ }) + fireEvent.click(toggle) + + expect(screen.getByText(/الدور 1/)).not.toBeNull() + expect(screen.getByText('كانت قيد التنفيذ')).not.toBeNull() + expect(toggle.getAttribute('aria-label')).toMatch(/طي الخطة الحالية/) + }) +}) + +describe('CurrentPlanSurface', () => { + it('does not duplicate the checklist while a turn is active', () => { + render( + + + + ) + + expect(screen.queryByRole('button', { name: /current plan/i })).toBeNull() + }) + + it.each([ + ['runtime A', createClientSessionState('stored-a')], + ['an absent runtime binding', null] + ])('suppresses the previous plan when route B and selection B coexist with %s', (_label, runtimeState) => { + $activeSessionId.set('runtime-a') + $sessionStates.set(runtimeState ? { 'runtime-a': runtimeState } : {}) + + render( + + + + + + ) + + expect(screen.queryByRole('button', { name: /current plan/i })).toBeNull() + }) + + it('suppresses a stale selected plan on the New Chat route', () => { + $activeSessionId.set('runtime-a') + $sessionStates.set({ 'runtime-a': createClientSessionState('stored-a') }) + + render( + + + + + + ) + + expect(screen.queryByRole('button', { name: /current plan/i })).toBeNull() + }) + + it('renders through the route-aware composition once route, selection, and runtime agree', () => { + $activeSessionId.set('runtime-b') + $sessionStates.set({ 'runtime-b': createClientSessionState('stored-b') }) + + render( + + + + + + ) + + expect(screen.getByRole('button', { name: /current plan/i })).not.toBeNull() + }) + + it('waits for the finished active-panel linger to clear before showing persisted history', () => { + const view = viewFor(transcript('finished', [{ content: 'Done', id: 'done', status: 'completed' }])) + setSessionTodos('runtime-1', [{ content: 'Done', id: 'done', status: 'completed' }]) + + const { rerender } = render( + + + + ) + + expect(screen.queryByRole('button', { name: /current plan/i })).toBeNull() + + act(() => clearSessionTodos('runtime-1')) + rerender( + + + + ) + + expect(screen.getByRole('button', { name: /current plan/i })).not.toBeNull() + expect(screen.getByText('Completed')).not.toBeNull() + }) + + it('does not reveal older persisted history during an explicit empty live clear', () => { + const view = viewFor(transcript('older', [{ content: 'Older plan', id: 'older', status: 'completed' }])) + setSessionTodos('runtime-1', []) + + render( + + + + ) + + expect(screen.queryByRole('button', { name: /current plan/i })).toBeNull() + }) + + it('constrains long expanded plans and scrolls the item list inside the panel', () => { + const longPlan = Array.from({ length: 40 }, (_, index) => ({ + content: `Plan item ${index + 1}`, + id: `item-${index + 1}`, + status: 'pending' as const + })) + + render( + + + + ) + + fireEvent.click(screen.getByRole('button', { name: /current plan/i })) + + const details = screen.getByText('Plan item 1').closest('[data-slot="current-plan-details"]') + const items = screen.getByText('Plan item 1').closest('[data-slot="current-plan-items"]') + + expect(details?.className).toContain('max-h-') + expect(details?.className).toContain('overflow-hidden') + expect(items?.className).toContain('overflow-y-auto') + expect(screen.getByRole('button', { name: /collapse current plan/i })).not.toBeNull() + }) + + it('shows nothing for a session without todo history', () => { + render( + + + + ) + + expect(screen.queryByRole('button', { name: /current plan/i })).toBeNull() + }) + + it('re-derives the plan when switching sessions', () => { + const { rerender } = render( + + + + ) + + fireEvent.click(screen.getByRole('button', { name: /current plan/i })) + expect(screen.getByText('First session')).not.toBeNull() + + rerender( + + + + ) + + expect(screen.getByRole('button', { name: /current plan/i }).getAttribute('aria-expanded')).toBe('false') + expect(screen.getByText('Paused')).not.toBeNull() + fireEvent.click(screen.getByRole('button', { name: /current plan/i })) + expect(screen.getByText('Second session')).not.toBeNull() + expect(screen.queryByText('First session')).toBeNull() + }) + + it('flags a newer turn without a todo update as superseding the visible plan', () => { + const messages = [ + ...transcript('old', [{ content: 'Old plan', id: 'old', status: 'completed' }]), + { id: 'new-user', parts: [textPart('New request')], role: 'user' as const, timestamp: 30 }, + { id: 'new-assistant', parts: [textPart('No plan update')], role: 'assistant' as const, timestamp: 40 } + ] + + render( + + + + ) + + expect(screen.getByText('Superseded')).not.toBeNull() + fireEvent.click(screen.getByRole('button', { name: /current plan/i })) + expect(screen.getByText(/newer turn exists without a todo update/i)).not.toBeNull() + }) +}) diff --git a/apps/desktop/src/app/chat/current-plan.tsx b/apps/desktop/src/app/chat/current-plan.tsx new file mode 100644 index 0000000000000..76fa5d21ecb75 --- /dev/null +++ b/apps/desktop/src/app/chat/current-plan.tsx @@ -0,0 +1,189 @@ +import { useStore } from '@nanostores/react' +import { useId, useMemo, useState } from 'react' +import { useLocation } from 'react-router' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { useI18n } from '@/i18n' +import { ChevronDown } from '@/lib/icons' +import { + type CurrentPlanSnapshot, + type CurrentPlanStatus, + latestSessionPlan, + type TodoStatus +} from '@/lib/todos' +import { cn } from '@/lib/utils' +import { $todosBySession } from '@/store/todos' + +import { isNewChatRoute, routeSessionId } from '../routes' + +import { $primaryRuntimeStoredId, routeSessionIdentityMismatch, useSessionView } from './session-view' + +const PLAN_STATUS_BADGE: Record = { + active: 'default', + paused: 'warn', + completed: 'default', + superseded: 'muted', + historical: 'muted' +} + +const ITEM_GLYPH: Record = { + pending: { icon: 'circle-large-outline', tone: 'text-muted-foreground/65' }, + in_progress: { icon: 'debug-pause', tone: 'text-amber-500/80' }, + completed: { icon: 'pass-filled', tone: 'text-emerald-500/80' }, + cancelled: { icon: 'circle-slash', tone: 'text-muted-foreground/50' } +} + +function formatUpdatedAt(timestamp: number | null, locale: string): string | null { + if (!timestamp) { + return null + } + + return new Intl.DateTimeFormat(locale, { + dateStyle: 'medium', + timeStyle: 'short' + }).format(new Date(timestamp * 1000)) +} + +export interface CurrentPlanPanelProps { + plan: CurrentPlanSnapshot + sessionId: string +} + +/** Read-only disclosure for the latest persisted todo snapshot. It is a + * conversation-history surface, not the live composer todo panel. */ +export function CurrentPlanPanel({ plan, sessionId }: CurrentPlanPanelProps) { + const { locale, t } = useI18n() + const [expanded, setExpanded] = useState(false) + const detailsId = useId() + const copy = t.currentPlan + const completion = copy.completion(plan.completedCount, plan.totalCount) + const status = copy.statuses[plan.status] + const updatedAt = formatUpdatedAt(plan.updatedAt, locale) + const turn = plan.turnNumber ? copy.turn(plan.turnNumber) : copy.unknownTurn + + return ( +
+ + + {expanded && ( +
+
+ {copy.provenance(turn, sessionId)} + · + {updatedAt ? copy.updated(updatedAt) : copy.unknownUpdateTime} +
+ + {plan.hasNewerTurnWithoutTodo && ( +

{copy.newerTurn}

+ )} + +
    + {plan.items.map(item => { + const glyph = ITEM_GLYPH[item.status] + + return ( +
  • + + {item.content} + + {copy.itemStatuses[item.status]} + +
  • + ) + })} +
+ +

{copy.livenessNotice}

+
+ )} +
+ ) +} + +function SettledCurrentPlan({ hasRuntime, sessionId }: { hasRuntime: boolean; sessionId: string }) { + const messages = useStore(useSessionView().$messages) + + const plan = useMemo( + () => latestSessionPlan(messages, { busy: false, hasRuntime }), + [hasRuntime, messages] + ) + + return plan ? : null +} + +/** + * Mounts the persisted plan only when the live todo surface is absent. + * + * A live turn or the finished todo panel's four-second linger owns checklist + * presentation exclusively. Once that transient state clears, this component + * re-derives the latest snapshot from hydrated message history. It never writes + * to or restores `$todosBySession`. + */ +export function CurrentPlanSurface({ suppressed = false }: { suppressed?: boolean } = {}) { + const view = useSessionView() + const busy = useStore(view.$busy) + const runtimeId = useStore(view.$runtimeId) + const storedId = useStore(view.$storedId) + const todosBySession = useStore($todosBySession) + const transientTodos = runtimeId ? todosBySession[runtimeId] : undefined + + if (suppressed || busy || transientTodos !== undefined || !storedId) { + return null + } + + return +} + +/** Route-aware composition used by ChatView. It owns the identity gate so the + * persisted surface cannot be wired without route/selection/runtime agreement. */ +export function RoutedCurrentPlanSurface() { + const view = useSessionView() + const location = useLocation() + const selectedStoredId = useStore(view.$storedId) + const runtimeStoredId = useStore($primaryRuntimeStoredId) + const routedStoredId = view.kind === 'primary' ? routeSessionId(location.pathname) : selectedStoredId + + const suppressed = + view.kind === 'primary' && + (isNewChatRoute(location.pathname) || + (Boolean(routedStoredId) && routeSessionIdentityMismatch(routedStoredId, selectedStoredId, runtimeStoredId))) + + return +} diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 3d72bf3410885..65e9d9b554c26 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -55,12 +55,13 @@ import { requestComposerInsert } from './composer/focus' import { droppedFileInlineRefs } from './composer/inline-refs' import { useComposerScope } from './composer/scope' import type { ChatBarState } from './composer/types' +import { RoutedCurrentPlanSurface } from './current-plan' import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions' import { type DragKind, useFileDropZone } from './hooks/use-file-drop-zone' import { ProfileTag } from './profile-tag' import { useRuntimeMessageRepository } from './runtime-repository' import { ScrollToBottomButton } from './scroll-to-bottom-button' -import { useSessionView } from './session-view' +import { $primaryRuntimeStoredId, routeSessionIdentityMismatch, useSessionView } from './session-view' import { SessionActionsMenu } from './sidebar/session-actions-menu' import { threadLoadingState } from './thread-loading' @@ -276,6 +277,7 @@ export function ChatView({ const composerScope = useComposerScope() const isPrimary = view.kind === 'primary' const activeSessionId = useStore(view.$runtimeId) + const primaryRuntimeStoredId = useStore($primaryRuntimeStoredId) const storedId = useStore(view.$storedId) // Dock anchor for a session drop onto this surface: the workspace pane for the // primary, this tile's pane id for a tile. Read by the session-drop bridge. @@ -356,11 +358,13 @@ export function ChatView({ const routedSessionId = isPrimary ? routeSessionId(location.pathname) : selectedSessionId const isRoutedSessionView = Boolean(routedSessionId) - // The URL points at a session the store hasn't loaded yet (sidebar / cmd-K / - // direct nav). Derived in render so the swap reads instantly: the same frame - // the id changes we drop the old transcript and show the loader, instead of - // waiting for the resume effect (which paints a frame later) to clear them. - const routeSessionMismatch = isRoutedSessionView && routedSessionId !== selectedSessionId + // The URL, selected durable session, and active runtime must identify the same + // conversation. Navigation selects B before its async runtime resume replaces + // A, so comparing route and selection alone can paint A beneath B's header. + const routeSessionMismatch = + isPrimary && + isRoutedSessionView && + routeSessionIdentityMismatch(routedSessionId, selectedSessionId, primaryRuntimeStoredId) // The compact new-session pop-out skips the wordmark/tagline intro — it's a // scratch window, not the full-height empty state. @@ -492,6 +496,7 @@ export function ChatView({ selectedSessionId={selectedSessionId} /> )} + {/* Mounted for the primary AND every tile, each scoped to its own session so a tiled/background session's blocking prompt surfaces instead of diff --git a/apps/desktop/src/app/chat/session-view.test.ts b/apps/desktop/src/app/chat/session-view.test.ts index 366af2327bcbc..194e1d59e29d8 100644 --- a/apps/desktop/src/app/chat/session-view.test.ts +++ b/apps/desktop/src/app/chat/session-view.test.ts @@ -2,10 +2,14 @@ import { cleanup } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { createClientSessionState } from '@/lib/chat-runtime' -import { $activeSessionId, $busy, $messages } from '@/store/session' +import { $activeSessionId, $busy, $messages, $selectedStoredSessionId } from '@/store/session' import { $sessionStates, dropSessionState, publishSessionState } from '@/store/session-states' -import { PRIMARY_SESSION_VIEW } from './session-view' +import { + $primaryRuntimeStoredId, + PRIMARY_SESSION_VIEW, + routeSessionIdentityMismatch +} from './session-view' const message = (id: string, text: string) => ({ id, @@ -34,6 +38,7 @@ describe('primary session view reads its own session slice', () => { $activeSessionId.set(null) $messages.set([]) $busy.set(false) + $selectedStoredSessionId.set(null) }) afterEach(cleanup) @@ -48,6 +53,23 @@ describe('primary session view reads its own session slice', () => { expect(PRIMARY_SESSION_VIEW.$busy.get()).toBe(false) }) + it('detects warm route, selection, and runtime identity skew', () => { + publishSessionState('runtime-a', stateWith('runtime-a', 'session A turn', false)) + $activeSessionId.set('runtime-a') + + // Navigation selects B before the async resume switches the active runtime. + $selectedStoredSessionId.set('stored-runtime-b') + + expect(PRIMARY_SESSION_VIEW.$runtimeId.get()).toBe('runtime-a') + expect(PRIMARY_SESSION_VIEW.$storedId.get()).toBe('stored-runtime-b') + expect($primaryRuntimeStoredId.get()).toBe('stored-runtime-a') + expect(routeSessionIdentityMismatch('stored-runtime-b', 'stored-runtime-b', $primaryRuntimeStoredId.get())).toBe( + true + ) + expect(routeSessionIdentityMismatch('stored-runtime-a', 'stored-runtime-a', 'stored-runtime-a')).toBe(false) + expect(PRIMARY_SESSION_VIEW.$messages.get()).toEqual([message('runtime-a-msg', 'session A turn')]) + }) + it('ignores a background session that keeps streaming after the user switches away', () => { publishSessionState('runtime-a', stateWith('runtime-a', 'session A turn', true)) $activeSessionId.set('runtime-b') diff --git a/apps/desktop/src/app/chat/session-view.tsx b/apps/desktop/src/app/chat/session-view.tsx index 97ec1d0a376c4..b48e2a1fc9259 100644 --- a/apps/desktop/src/app/chat/session-view.tsx +++ b/apps/desktop/src/app/chat/session-view.tsx @@ -76,6 +76,22 @@ function primaryField(select: (state: ClientSessionState) => T, $draft: Reada const $primaryMessages = primaryField(state => state.messages, $messages) +export const $primaryRuntimeStoredId = computed( + [$activeSessionId, $sessionStates], + (activeSessionId, states) => (activeSessionId ? (states[activeSessionId]?.storedSessionId ?? null) : null) +) + +export function routeSessionIdentityMismatch( + routedSessionId: string | null, + selectedStoredSessionId: string | null, + runtimeStoredSessionId: string | null +): boolean { + return Boolean( + routedSessionId && + (routedSessionId !== selectedStoredSessionId || routedSessionId !== runtimeStoredSessionId) + ) +} + export const PRIMARY_SESSION_VIEW: SessionView = { kind: 'primary', $awaitingResponse: primaryField(state => state.awaitingResponse, $awaitingResponse), diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index e5802d0517996..096247be6af2f 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -25,7 +25,13 @@ import { FloatingPet } from '@/components/pet/floating-pet' import { RemoteDisplayBanner } from '@/components/remote-display-banner' import { emitGatewayEvent } from '@/contrib/events' import { getSessionMessages, triggerCronJob } from '@/hermes' -import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' +import { + type ChatMessage, + chatMessageText, + mergePersistedTodoProvenance, + preserveLocalAssistantErrors, + toChatMessages +} from '@/lib/chat-messages' import { sessionMessagesSignature } from '@/lib/session-signatures' import { isMessagingSource } from '@/lib/session-source' import { latestSessionTodos } from '@/lib/todos' @@ -316,7 +322,8 @@ export function ContribWiring({ children }: { children: ReactNode }) { async ( attempts = 1, storedSessionId = selectedStoredSessionIdRef.current, - runtimeSessionId = activeSessionIdRef.current + runtimeSessionId = activeSessionIdRef.current, + options: { preserveLocalScrollback?: boolean } = {} ) => { if (!storedSessionId || !runtimeSessionId) { return @@ -328,12 +335,32 @@ export function ContribWiring({ children }: { children: ReactNode }) { try { const latest = await getSessionMessages(storedSessionId, storedProfile) const messages = toChatMessages(latest.messages) + let provenanceMerged = !options.preserveLocalScrollback + updateSessionState( runtimeSessionId, - state => ({ ...state, messages: preserveLocalAssistantErrors(messages, state.messages) }), + state => { + const nextMessages = options.preserveLocalScrollback + ? mergePersistedTodoProvenance(state.messages, messages) + : preserveLocalAssistantErrors(messages, state.messages) + + provenanceMerged = provenanceMerged || nextMessages !== state.messages + + return { ...state, messages: nextMessages } + }, storedSessionId ) + if (!provenanceMerged) { + if (index < attempts - 1) { + await new Promise(resolve => window.setTimeout(resolve, 250)) + + continue + } + + return + } + const restored = todosForHydration(latestSessionTodos(messages)) if (restored) { 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 7aa89a8cf14f8..a7431d94a11f0 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 @@ -20,7 +20,7 @@ import { generatedImageEchoSources, stripGeneratedImageEchoes } from '@/lib/generated-images' -import { parseTodos } from '@/lib/todos' +import { parsePersistedTodos, parseTodos } from '@/lib/todos' import { dispatchNativeNotification } from '@/store/native-notifications' import { broadcastSessionsChanged } from '@/store/session-sync' import { upsertSubagent } from '@/store/subagents' @@ -37,7 +37,8 @@ interface MessageStreamOptions { hydrateFromStoredSession: ( attempts?: number, storedSessionId?: string | null, - runtimeSessionId?: string | null + runtimeSessionId?: string | null, + options?: { preserveLocalScrollback?: boolean } ) => Promise queryClient: QueryClient refreshHermesConfig: () => Promise @@ -62,6 +63,22 @@ let streamMessageSeq = 0 const nextStreamMessageId = (prefix: string) => `${prefix}-${Date.now()}-${++streamMessageSeq}` +function currentTurnHasCompletedTodoResult(messages: readonly ChatMessage[]): boolean { + const lastUserIndex = messages.findLastIndex(message => message.role === 'user' && !message.hidden) + + return messages.slice(lastUserIndex + 1).some( + message => + !message.hidden && + message.parts.some( + part => + part.type === 'tool-call' && + part.toolName === 'todo' && + 'result' in part && + parsePersistedTodos(part.result) !== null + ) + ) +} + export function useMessageStream({ activeGatewayProfile = 'default', activeSessionIdRef, @@ -188,7 +205,8 @@ export function useMessageStream({ // flush floor in scheduleDeltaFlush so multi-stream load yields to input. const lastFlushCostRef = useRef(0) const nativeSubagentSessionsRef = useRef>(new Set()) - // Turns that auto-compacted: skip post-turn hydrate so live scrollback survives. + // Turns that auto-compacted: avoid replacing live scrollback at turn end. + // A completed todo still fetches persisted provenance and merges only that metadata. const compactedTurnRef = useRef>(new Set()) // Last session we applied a session.info cwd for — lets us tell an agent // relocating the SAME session (follow it) from a session switch (don't yank). @@ -465,6 +483,8 @@ export function useMessageStream({ const completeAssistantMessage = useCallback( (sessionId: string, text: string, responsePreviewed?: boolean, failure?: { error: string; partial: boolean }) => { let shouldHydrate = false + let requiresTodoHydration = false + let preserveLocalScrollback = false const completedState = updateSessionState(sessionId, state => { // Late completion from an already-cancelled turn: cancelRun has @@ -472,6 +492,14 @@ export function useMessageStream({ // empty). Re-running the dedupe below would replace the partial with // the just-cancelled full text, so we settle and bail instead. if (state.interrupted) { + // cancelRun can remove a tool-only pending bubble before this late + // terminal frame arrives, so absence from local messages is not proof + // that the persisted turn had no todo result. Probe persisted history + // while preserving the already-finalized local scrollback. + requiresTodoHydration = true + shouldHydrate = true + preserveLocalScrollback = true + return { ...state, awaitingResponse: false, @@ -580,11 +608,22 @@ export function useMessageStream({ } } - const hasInlineError = nextMessages.some(m => m.role === 'assistant' && m.error && !m.hidden) const lastVisible = [...nextMessages].reverse().find(m => !m.hidden) const unresolvedUserTail = lastVisible?.role === 'user' + const lastUserIndex = nextMessages.findLastIndex(message => message.role === 'user' && !message.hidden) + const currentTurnMessages = nextMessages.slice(lastUserIndex + 1) + + const hasInlineError = currentTurnMessages.some( + message => message.role === 'assistant' && message.error && !message.hidden + ) + + const hasCompletedTodoResult = currentTurnHasCompletedTodoResult(nextMessages) + + requiresTodoHydration = hasCompletedTodoResult shouldHydrate = - !completionError && !hasInlineError && !unresolvedUserTail && (!state.sawAssistantPayload || !finalText) + !unresolvedUserTail && + (hasCompletedTodoResult || + (!completionError && !hasInlineError && (!state.sawAssistantPayload || !finalText))) return { ...state, @@ -601,12 +640,20 @@ export function useMessageStream({ scheduleSessionsRefresh() - if (compactedTurnRef.current.delete(sessionId)) { + const compactedTurn = compactedTurnRef.current.delete(sessionId) + + if (compactedTurn && !requiresTodoHydration) { shouldHydrate = false } if (shouldHydrate) { - void hydrateFromStoredSession(3, completedState.storedSessionId, sessionId) + if ((compactedTurn || preserveLocalScrollback) && requiresTodoHydration) { + void hydrateFromStoredSession(3, completedState.storedSessionId, sessionId, { + preserveLocalScrollback: true + }) + } else { + void hydrateFromStoredSession(3, completedState.storedSessionId, sessionId) + } } dispatchNativeNotification({ @@ -621,6 +668,9 @@ export function useMessageStream({ const failAssistantMessage = useCallback( (sessionId: string, errorMessage: string) => { + let shouldHydrateTodos = false + let storedSessionId: null | string = null + updateSessionState(sessionId, state => { const streamId = state.streamId ?? `assistant-error-${Date.now()}` const groupId = state.pendingBranchGroup ?? undefined @@ -649,6 +699,9 @@ export function useMessageStream({ } ] + shouldHydrateTodos = currentTurnHasCompletedTodoResult(nextMessages) + storedSessionId = state.storedSessionId + return { ...state, messages: nextMessages, @@ -662,8 +715,12 @@ export function useMessageStream({ turnStartedAt: null } }) + + if (shouldHydrateTodos && storedSessionId) { + void hydrateFromStoredSession(3, storedSessionId, sessionId) + } }, - [updateSessionState] + [hydrateFromStoredSession, updateSessionState] ) const handleGatewayEvent = useGatewayEventHandler({ 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 6b676976e5d15..9a69471a659c5 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 @@ -9,12 +9,17 @@ import type { TodoItem } from '@/lib/todos' import { $todosBySession, clearSessionTodos, setSessionTodos } from '@/store/todos' import type { RpcEvent } from '@/types/hermes' +import { finalizeInterruptedMessages } from '../use-prompt-actions/rewind' + import { useMessageStream } from './index' const SID = 'session-1' const todo = (id: string, status: TodoItem['status']): TodoItem => ({ content: `task ${id}`, id, status }) let handleEvent: ((event: RpcEvent) => void) | null = null +let appendUserMessage: ((text: string) => void) | null = null +let interruptTurn: (() => void) | null = null +const hydrateFromStoredSession = vi.fn(async () => undefined) function Harness() { const activeSessionIdRef = useRef(SID) @@ -23,13 +28,13 @@ function Harness() { const stream = useMessageStream({ activeSessionIdRef, - hydrateFromStoredSession: vi.fn(async () => undefined), + hydrateFromStoredSession, queryClient: queryClientRef.current, refreshHermesConfig: vi.fn(async () => undefined), refreshSessions: vi.fn(async () => undefined), sessionStateByRuntimeIdRef, updateSessionState: (sessionId, updater) => { - const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState() + const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState('stored-1') const next = updater(current) sessionStateByRuntimeIdRef.current.set(sessionId, next) @@ -37,6 +42,28 @@ function Harness() { } }) + appendUserMessage = text => { + const current = sessionStateByRuntimeIdRef.current.get(SID) ?? createClientSessionState('stored-1') + sessionStateByRuntimeIdRef.current.set(SID, { + ...current, + messages: [ + ...current.messages, + { id: `user-${current.messages.length}`, parts: [{ text, type: 'text' }], role: 'user' } + ] + }) + } + + interruptTurn = () => { + const current = sessionStateByRuntimeIdRef.current.get(SID) ?? createClientSessionState('stored-1') + sessionStateByRuntimeIdRef.current.set(SID, { + ...current, + busy: false, + interrupted: true, + messages: finalizeInterruptedMessages(current.messages, current.streamId), + streamId: null + }) + } + useEffect(() => { handleEvent = stream.handleGatewayEvent }, [stream.handleGatewayEvent]) @@ -49,11 +76,15 @@ async function mountStream() { await waitFor(() => expect(handleEvent).not.toBeNull()) } -const complete = () => act(() => handleEvent!({ payload: { text: 'done' }, session_id: SID, type: 'message.complete' })) +const complete = (payload: Record = { text: 'done' }) => + act(() => handleEvent!({ payload, session_id: SID, type: 'message.complete' })) describe('useMessageStream turn-end todo cleanup', () => { beforeEach(() => { handleEvent = null + appendUserMessage = null + interruptTurn = null + hydrateFromStoredSession.mockClear() clearSessionTodos(SID) }) @@ -82,6 +113,142 @@ describe('useMessageStream turn-end todo cleanup', () => { expect($todosBySession.get()[SID]).toHaveLength(1) }) + it('rehydrates after a completed todo result so persisted provenance replaces live state', async () => { + await mountStream() + + act(() => + handleEvent!({ + payload: { name: 'todo', result: { todos: [todo('a', 'completed')] }, tool_id: 'todo-1' }, + session_id: SID, + type: 'tool.complete' + }) + ) + complete() + + await waitFor(() => expect(hydrateFromStoredSession).toHaveBeenCalledWith(3, 'stored-1', SID)) + }) + + it('rehydrates todo provenance when a late completion arrives after cancellation', async () => { + await mountStream() + + act(() => appendUserMessage!('Run and then cancel')) + act(() => handleEvent!({ payload: {}, session_id: SID, type: 'message.start' })) + act(() => + handleEvent!({ + payload: { name: 'todo', result: { todos: [todo('before-cancel', 'completed')] }, tool_id: 'todo-before-cancel' }, + session_id: SID, + type: 'tool.complete' + }) + ) + act(() => interruptTurn!()) + complete() + + await waitFor(() => + expect(hydrateFromStoredSession).toHaveBeenCalledWith(3, 'stored-1', SID, { preserveLocalScrollback: true }) + ) + }) + + it('rehydrates when a completed todo precedes later bubbles in the same turn', async () => { + await mountStream() + + act(() => appendUserMessage!('Run the multi-step turn')) + act(() => handleEvent!({ payload: {}, session_id: SID, type: 'message.start' })) + act(() => + handleEvent!({ + payload: { name: 'todo', result: { todos: [todo('early', 'completed')] }, tool_id: 'todo-early' }, + session_id: SID, + type: 'tool.complete' + }) + ) + act(() => + handleEvent!({ payload: { text: 'Todo finished; checking one more thing.' }, session_id: SID, type: 'message.interim' }) + ) + act(() => + handleEvent!({ + payload: { name: 'terminal', result: 'ok', tool_id: 'terminal-later' }, + session_id: SID, + type: 'tool.complete' + }) + ) + complete() + + await waitFor(() => expect(hydrateFromStoredSession).toHaveBeenCalledWith(3, 'stored-1', SID)) + }) + + it('rehydrates a successful todo turn when an older visible assistant error exists', async () => { + await mountStream() + + act(() => + handleEvent!({ payload: { message: 'Earlier failure' }, session_id: SID, type: 'error' }) + ) + act(() => appendUserMessage!('Try again')) + act(() => handleEvent!({ payload: {}, session_id: SID, type: 'message.start' })) + act(() => + handleEvent!({ + payload: { name: 'todo', result: { todos: [todo('later', 'completed')] }, tool_id: 'todo-later' }, + session_id: SID, + type: 'tool.complete' + }) + ) + complete() + + await waitFor(() => expect(hydrateFromStoredSession).toHaveBeenCalledWith(3, 'stored-1', SID)) + }) + + it('rehydrates a completed todo result when the terminal frame reports an error', async () => { + await mountStream() + + act(() => appendUserMessage!('Persist the plan before failure')) + act(() => handleEvent!({ payload: {}, session_id: SID, type: 'message.start' })) + act(() => + handleEvent!({ + payload: { name: 'todo', result: { todos: [todo('before-error', 'completed')] }, tool_id: 'todo-before-error' }, + session_id: SID, + type: 'tool.complete' + }) + ) + complete({ error: 'provider failed after the todo result', partial: true, status: 'error', text: '' }) + + await waitFor(() => expect(hydrateFromStoredSession).toHaveBeenCalledWith(3, 'stored-1', SID)) + }) + + it('rehydrates a completed todo result when a standalone error event settles the turn', async () => { + await mountStream() + + act(() => appendUserMessage!('Persist the plan before a standalone error')) + act(() => handleEvent!({ payload: {}, session_id: SID, type: 'message.start' })) + act(() => + handleEvent!({ + payload: { name: 'todo', result: { todos: [todo('before-standalone-error', 'completed')] }, tool_id: 'todo-before-error' }, + session_id: SID, + type: 'tool.complete' + }) + ) + act(() => handleEvent!({ payload: { message: 'provider failed after the todo result' }, session_id: SID, type: 'error' })) + + await waitFor(() => expect(hydrateFromStoredSession).toHaveBeenCalledWith(3, 'stored-1', SID)) + }) + + it('requests provenance-only hydration after a compacted todo turn', async () => { + await mountStream() + + act(() => appendUserMessage!('Compact this turn')) + act(() => handleEvent!({ payload: {}, session_id: SID, type: 'message.start' })) + act(() => handleEvent!({ payload: { kind: 'compacting' }, session_id: SID, type: 'status.update' })) + act(() => + handleEvent!({ + payload: { name: 'todo', result: { todos: [todo('compacted', 'completed')] }, tool_id: 'todo-compacted' }, + session_id: SID, + type: 'tool.complete' + }) + ) + complete() + + await waitFor(() => + expect(hydrateFromStoredSession).toHaveBeenCalledWith(3, 'stored-1', SID, { preserveLocalScrollback: true }) + ) + }) + it('drops a still-active task list when the turn errors out', async () => { await mountStream() setSessionTodos(SID, [todo('a', 'in_progress')]) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.test.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.test.ts index 23d9c979815dc..93c4e1fdfa9ef 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.test.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' -import { truncateSubmitParams } from './rewind' +import { type ChatMessage, mergePersistedTodoProvenance } from '@/lib/chat-messages' +import { latestSessionPlan } from '@/lib/todos' + +import { finalizeInterruptedMessages, truncateSubmitParams } from './rewind' describe('truncateSubmitParams', () => { it('omits truncation fields when no ordinal is set', () => { @@ -17,3 +20,110 @@ describe('truncateSubmitParams', () => { }) }) }) + +describe('finalizeInterruptedMessages', () => { + it('preserves a completed tool-only todo result for provenance hydration', () => { + const messages: ChatMessage[] = [ + { + id: 'assistant-stream', + parts: [ + { + result: { todos: [] }, + toolCallId: 'todo-clear', + toolName: 'todo', + type: 'tool-call' + } + ], + pending: true, + role: 'assistant' + } + ] + + expect(finalizeInterruptedMessages(messages, 'assistant-stream')).toEqual([ + expect.objectContaining({ id: 'assistant-stream', pending: false }) + ]) + }) + + it('retains the exact persisted result and timestamp through cancellation hydration', () => { + const items = [{ content: 'Persist me', id: 'persist', status: 'completed' as const }] + + const local: ChatMessage[] = [ + { id: 'user-1', parts: [{ text: 'Make a plan', type: 'text' }], role: 'user' }, + { + id: 'assistant-stream', + parts: [{ result: { todos: items }, toolCallId: 'todo-1', toolName: 'todo', type: 'tool-call' }], + pending: true, + role: 'assistant' + } + ] + + const persisted: ChatMessage[] = [ + { + id: 'stored-assistant', + parts: [ + { + result: { todos: items }, + todoUpdatedAt: 456, + toolCallId: 'todo-1', + toolName: 'todo', + type: 'tool-call' + } as ChatMessage['parts'][number] & { todoUpdatedAt: number } + ], + role: 'assistant' + } + ] + + const finalized = finalizeInterruptedMessages(local, 'assistant-stream') + const hydrated = mergePersistedTodoProvenance(finalized, persisted) + + expect(latestSessionPlan(hydrated, { busy: false, hasRuntime: true })).toMatchObject({ + items, + updatedAt: 456 + }) + }) + + it('retains an explicit empty persisted clear through cancellation hydration', () => { + const local: ChatMessage[] = [ + { + id: 'older-plan', + parts: [ + { + result: { todos: [{ content: 'Old', id: 'old', status: 'completed' }] }, + todoUpdatedAt: 100, + toolCallId: 'todo-old', + toolName: 'todo', + type: 'tool-call' + } as ChatMessage['parts'][number] & { todoUpdatedAt: number } + ], + role: 'assistant' + }, + { + id: 'assistant-stream', + parts: [{ result: { todos: [] }, toolCallId: 'todo-clear', toolName: 'todo', type: 'tool-call' }], + pending: true, + role: 'assistant' + } + ] + + const persisted: ChatMessage[] = [ + { + id: 'stored-clear', + parts: [ + { + result: { todos: [] }, + todoUpdatedAt: 789, + toolCallId: 'todo-clear', + toolName: 'todo', + type: 'tool-call' + } as ChatMessage['parts'][number] & { todoUpdatedAt: number } + ], + role: 'assistant' + } + ] + + const finalized = finalizeInterruptedMessages(local, 'assistant-stream') + const hydrated = mergePersistedTodoProvenance(finalized, persisted) + + expect(latestSessionPlan(hydrated, { busy: false, hasRuntime: true })).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts index 32dc25c1ebf76..4ad3aec709303 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts @@ -14,6 +14,7 @@ import type { AppendMessage, ThreadMessage } from '@assistant-ui/react' import type { ClientSessionState } from '@/app/types' import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes' import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages' +import { messageHasValidTodoResult } from '@/lib/todos' import { appendText, @@ -94,7 +95,14 @@ export async function runRewindSubmit( /** Cancel/stop finalize: drop empty pending/stream placeholders, un-pend the rest. */ export function finalizeInterruptedMessages(messages: ChatMessage[], streamId?: null | string): ChatMessage[] { return messages - .filter(message => !((message.pending || message.id === streamId) && !chatMessageText(message).trim())) + .filter( + message => + !( + (message.pending || message.id === streamId) && + !chatMessageText(message).trim() && + !messageHasValidTodoResult(message) + ) + ) .map(message => (message.pending || message.id === streamId ? { ...message, pending: false } : message)) } diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 0f37ba6d104eb..a1fdc7a32e667 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -1816,6 +1816,32 @@ export const ar = defineLocale({ worktrees: 'أشجار العمل' } }, + currentPlan: { + title: 'الخطة الحالية', + toggle: (expanded, status, completion) => + `${expanded ? 'طي' : 'توسيع'} الخطة الحالية، ${status}، ${completion}`, + completion: (done, total) => `اكتمل ${done}/${total}`, + turn: number => `الدور ${number}`, + statuses: { + active: 'نشطة', + paused: 'متوقفة مؤقتا', + completed: 'مكتملة', + superseded: 'استُبدلت', + historical: 'تاريخية' + }, + itemStatuses: { + pending: 'معلقة', + in_progress: 'كانت قيد التنفيذ', + completed: 'مكتملة', + cancelled: 'ملغاة' + }, + provenance: (turn, session) => `${turn} · الجلسة ${session}`, + unknownTurn: 'دور غير معروف', + updated: time => `حُدّثت ${time}`, + unknownUpdateTime: 'وقت التحديث غير متاح', + newerTurn: 'يوجد دور أحدث دون تحديث لقائمة المهام. قد لا تصف هذه الخطة الطلب الحالي.', + livenessNotice: 'سجل جلسة للقراءة فقط. لا تثبت قائمة التحقق هذه أن عاملا أو عملية أو تفويضا قيد التشغيل.' + }, updates: { stages: { idle: 'جار التحضير...', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 6733eb49a5d05..282769ba8711d 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2143,6 +2143,34 @@ export const en: Translations = { } }, + currentPlan: { + title: 'Current Plan', + toggle: (expanded, status, completion) => + `${expanded ? 'Collapse' : 'Expand'} Current Plan, ${status}, ${completion}`, + completion: (done, total) => `${done}/${total} complete`, + turn: number => `Turn ${number}`, + statuses: { + active: 'Active', + paused: 'Paused', + completed: 'Completed', + superseded: 'Superseded', + historical: 'Historical' + }, + itemStatuses: { + pending: 'Pending', + in_progress: 'Previously in progress', + completed: 'Completed', + cancelled: 'Cancelled' + }, + provenance: (turn, session) => `${turn} · Session ${session}`, + unknownTurn: 'Unknown turn', + updated: time => `Updated ${time}`, + unknownUpdateTime: 'Update time unavailable', + newerTurn: 'A newer turn exists without a todo update. This plan may no longer describe the current request.', + livenessNotice: + 'Read-only session history. This checklist does not prove that a worker, process, or delegation is running.' + }, + updates: { stages: { idle: 'Getting ready…', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index fab8e071bf78b..3eb971f037cc8 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1980,6 +1980,34 @@ export const ja = defineLocale({ } }, + currentPlan: { + title: '現在のプラン', + toggle: (expanded, status, completion) => + `現在のプランを${expanded ? '折りたたむ' : '展開する'}、${status}、${completion}`, + completion: (done, total) => `${total} 件中 ${done} 件完了`, + turn: number => `ターン ${number}`, + statuses: { + active: 'アクティブ', + paused: '一時停止', + completed: '完了', + superseded: '更新済み', + historical: '履歴' + }, + itemStatuses: { + pending: '未着手', + in_progress: '以前は進行中', + completed: '完了', + cancelled: 'キャンセル済み' + }, + provenance: (turn, session) => `${turn} · セッション ${session}`, + unknownTurn: '不明なターン', + updated: time => `更新 ${time}`, + unknownUpdateTime: '更新時刻を取得できません', + newerTurn: 'todo の更新がない新しいターンがあります。このプランは現在の依頼を反映していない可能性があります。', + livenessNotice: + '読み取り専用のセッション履歴です。このチェックリストは、ワーカー、プロセス、委任が実行中であることを証明しません。' + }, + updates: { stages: { idle: '準備中…', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 88f3ffdf95a5c..0a6be793f493a 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1793,6 +1793,32 @@ export interface Translations { } } + currentPlan: { + title: string + toggle: (expanded: boolean, status: string, completion: string) => string + completion: (done: number, total: number) => string + turn: (number: number) => string + statuses: { + active: string + paused: string + completed: string + superseded: string + historical: string + } + itemStatuses: { + pending: string + in_progress: string + completed: string + cancelled: string + } + provenance: (turn: string, session: string) => string + unknownTurn: string + updated: (time: string) => string + unknownUpdateTime: string + newerTurn: string + livenessNotice: string + } + updates: { stages: Record checking: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 8a808dffec85d..6161bab68c7e6 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1920,6 +1920,32 @@ export const zhHant = defineLocale({ } }, + currentPlan: { + title: '目前計畫', + toggle: (expanded, status, completion) => `${expanded ? '收合' : '展開'}目前計畫,${status},${completion}`, + completion: (done, total) => `已完成 ${done}/${total}`, + turn: number => `第 ${number} 輪`, + statuses: { + active: '進行中', + paused: '已暫停', + completed: '已完成', + superseded: '已被取代', + historical: '歷史記錄' + }, + itemStatuses: { + pending: '待處理', + in_progress: '先前進行中', + completed: '已完成', + cancelled: '已取消' + }, + provenance: (turn, session) => `${turn} · 工作階段 ${session}`, + unknownTurn: '未知輪次', + updated: time => `更新於 ${time}`, + unknownUpdateTime: '無法取得更新時間', + newerTurn: '已有較新的輪次,但沒有 todo 更新。此計畫可能已不再描述目前的要求。', + livenessNotice: '唯讀工作階段歷史記錄。此檢查清單不能證明任何工作程序、處理程序或委派正在執行。' + }, + updates: { stages: { idle: '準備中…', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index c34d811344e3b..1eda46440763a 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2335,6 +2335,32 @@ export const zh: Translations = { } }, + currentPlan: { + title: '当前计划', + toggle: (expanded, status, completion) => `${expanded ? '收起' : '展开'}当前计划,${status},${completion}`, + completion: (done, total) => `已完成 ${done}/${total}`, + turn: number => `第 ${number} 轮`, + statuses: { + active: '进行中', + paused: '已暂停', + completed: '已完成', + superseded: '已被取代', + historical: '历史记录' + }, + itemStatuses: { + pending: '待处理', + in_progress: '此前进行中', + completed: '已完成', + cancelled: '已取消' + }, + provenance: (turn, session) => `${turn} · 会话 ${session}`, + unknownTurn: '未知轮次', + updated: time => `更新于 ${time}`, + unknownUpdateTime: '更新时间不可用', + newerTurn: '存在更新的对话轮次,但没有新的待办更新。此计划可能已不再描述当前请求。', + livenessNotice: '只读会话历史。此清单不能证明工作进程、任务进程或委派当前仍在运行。' + }, + updates: { stages: { idle: '准备中…', diff --git a/apps/desktop/src/lib/chat-messages.test.ts b/apps/desktop/src/lib/chat-messages.test.ts index 00b2bd78962da..175fca70e1560 100644 --- a/apps/desktop/src/lib/chat-messages.test.ts +++ b/apps/desktop/src/lib/chat-messages.test.ts @@ -9,6 +9,7 @@ import { chatMessageText, collectUnspokenTurnSpeech, mergeFinalAssistantText, + mergePersistedTodoProvenance, preserveLocalAssistantErrors, reasoningPart, renderMediaTags, @@ -34,6 +35,116 @@ describe('toChatMessages', () => { expect(chatMessageText(messages[0])).toBe('Planning.Done.') }) + it('preserves the exact persisted todo-result timestamp for Current Plan provenance', () => { + const messages = toChatMessages([ + { role: 'user', content: 'make a plan', timestamp: 1 }, + { + role: 'assistant', + content: '', + timestamp: 2, + tool_calls: [ + { + id: 'todo-1', + function: { + name: 'todo', + arguments: '{"todos":[{"id":"a","content":"First","status":"in_progress"}]}' + } + } + ] + }, + { + role: 'tool', + tool_call_id: 'todo-1', + tool_name: 'todo', + content: '{"todos":[{"id":"a","content":"First","status":"completed"}]}', + timestamp: 3 + }, + { role: 'assistant', content: 'Done.', timestamp: 4 } + ]) + + const todo = messages + .flatMap(message => message.parts) + .find(part => part.type === 'tool-call' && part.toolName === 'todo') as + | (Extract & { todoUpdatedAt?: number }) + | undefined + + expect(todo?.todoUpdatedAt).toBe(3) + }) + + it.each([ + ['missing', undefined], + ['mismatched', 'todo-other'] + ])('does not establish Current Plan provenance from a %s tool-call id', (_label, resultId) => { + const toolResult = { + role: 'tool' as const, + ...(resultId ? { tool_call_id: resultId } : {}), + tool_name: 'todo', + content: '{"todos":[{"id":"a","content":"First","status":"completed"}]}', + timestamp: 3 + } + + const messages = toChatMessages([ + { role: 'user', content: 'make a plan', timestamp: 1 }, + { + role: 'assistant', + content: '', + timestamp: 2, + tool_calls: [ + { + id: 'todo-expected', + function: { + name: 'todo', + arguments: '{"todos":[{"id":"a","content":"First","status":"in_progress"}]}' + } + } + ] + }, + toolResult + ]) + + const todoParts = messages + .flatMap(message => message.parts) + .filter(part => part.type === 'tool-call' && part.toolName === 'todo') as Array< + Extract & { todoUpdatedAt?: number } + > + + expect(todoParts.length).toBeGreaterThan(0) + expect(todoParts.every(part => part.todoUpdatedAt === undefined)).toBe(true) + }) + + it('clears earlier provenance when a later missing-id todo result overwrites the same call by name', () => { + const messages = toChatMessages([ + { role: 'user', content: 'make a plan', timestamp: 1 }, + { + role: 'assistant', + content: '', + timestamp: 2, + tool_calls: [{ id: 'todo-1', function: { name: 'todo', arguments: '{"todos":[]}' } }] + }, + { + role: 'tool', + tool_call_id: 'todo-1', + tool_name: 'todo', + content: '{"todos":[{"id":"a","content":"Exact","status":"completed"}]}', + timestamp: 3 + }, + { + role: 'tool', + tool_name: 'todo', + content: '{"todos":[{"id":"b","content":"Ambiguous","status":"completed"}]}', + timestamp: 4 + } + ]) + + const todoPart = messages + .flatMap(message => message.parts) + .find(part => part.type === 'tool-call' && part.toolCallId === 'todo-1') as + | (Extract & { todoUpdatedAt?: number }) + | undefined + + expect(todoPart?.todoUpdatedAt).toBeUndefined() + }) + it('keeps assistant tool-call iterations in one loaded assistant bubble', () => { const messages = toChatMessages([ { role: 'user', content: 'check this repo', timestamp: 1 }, @@ -569,6 +680,105 @@ describe('preserveLocalAssistantErrors', () => { }) }) +describe('mergePersistedTodoProvenance', () => { + it('adds exact hydrated todo provenance without replacing compacted live scrollback', () => { + const todo = { + type: 'tool-call' as const, + toolCallId: 'todo-1', + toolName: 'todo', + args: { todos: [{ content: 'Done', id: 'done', status: 'completed' }] } as never, + result: { todos: [{ content: 'Done', id: 'done', status: 'completed' }] } + } + + const local: ChatMessage[] = [ + { id: 'scrollback', parts: [{ type: 'text', text: 'Keep this live scrollback' }], role: 'assistant' }, + { id: 'live-plan', parts: [todo], role: 'assistant' } + ] + + const persisted: ChatMessage[] = [ + { + id: 'stored-plan', + parts: [{ ...todo, todoUpdatedAt: 123 } as ChatMessagePart & { todoUpdatedAt: number }], + role: 'assistant' + } + ] + + const merged = mergePersistedTodoProvenance(local, persisted) + const mergedTodo = merged[1]?.parts[0] as ChatMessagePart & { todoUpdatedAt?: number } + + expect(merged).toHaveLength(2) + expect(chatMessageText(merged[0]!)).toBe('Keep this live scrollback') + expect(mergedTodo.todoUpdatedAt).toBe(123) + }) + + it('rejects an older identical persisted plan with a different tool-call identity', () => { + const result = { todos: [{ content: 'Same plan', id: 'same', status: 'completed' }] } + + const local: ChatMessage[] = [ + { + id: 'live-newer', + parts: [{ result, toolCallId: 'todo-newer', toolName: 'todo', type: 'tool-call' }], + role: 'assistant' + } + ] + + const persisted: ChatMessage[] = [ + { + id: 'stored-older', + parts: [ + { + result, + toolCallId: 'todo-older', + toolName: 'todo', + todoUpdatedAt: 99, + type: 'tool-call' + } as ChatMessagePart & { todoUpdatedAt: number } + ], + role: 'assistant' + } + ] + + expect(mergePersistedTodoProvenance(local, persisted)).toBe(local) + expect((local[0]?.parts[0] as ChatMessagePart & { todoUpdatedAt?: number }).todoUpdatedAt).toBeUndefined() + }) + + it('rejects malformed persisted todo rows instead of replacing valid local bytes', () => { + const localResult = { todos: [{ content: 'Keep local', id: 'local', status: 'completed' }] } + + const local: ChatMessage[] = [ + { + id: 'live-plan', + parts: [{ result: localResult, toolCallId: 'todo-1', toolName: 'todo', type: 'tool-call' }], + role: 'assistant' + } + ] + + const persisted: ChatMessage[] = [ + { + id: 'stored-plan', + parts: [ + { + result: { + todos: [ + { content: 'Keep local', id: 'local', status: 'completed' }, + { content: { malformed: true }, id: 7, status: 'pending' } + ] + }, + toolCallId: 'todo-1', + toolName: 'todo', + todoUpdatedAt: 321, + type: 'tool-call' + } as ChatMessagePart & { todoUpdatedAt: number } + ], + role: 'assistant' + } + ] + + expect(mergePersistedTodoProvenance(local, persisted)).toBe(local) + expect((local[0]?.parts[0] as ChatMessagePart & { todoUpdatedAt?: number }).todoUpdatedAt).toBeUndefined() + }) +}) + describe('upsertToolPart', () => { it('preserves inline diffs from tool completion events', () => { const parts = upsertToolPart( @@ -652,6 +862,7 @@ describe('upsertToolPart', () => { const clearedResult = cleared[0] && 'result' in cleared[0] ? (cleared[0].result as Record) : {} expect(completedResult.todos).toEqual([{ content: 'Boil water', id: 'boil', status: 'in_progress' }]) + expect((completed[0] as { todoUpdatedAt?: number }).todoUpdatedAt).toBeUndefined() expect(clearedResult.todos).toEqual([]) }) diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index f50f4ed550807..e2fc90c518a8f 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -5,7 +5,7 @@ import { extractImageRefs } from '@/lib/embedded-images' import { dedupeGeneratedImageEchoesInParts } from '@/lib/generated-images' import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media' import { normalize } from '@/lib/text' -import { parseTodos } from '@/lib/todos' +import { parsePersistedTodos, parseTodos } from '@/lib/todos' import type { MessageReaction, SessionMessage, UsageStats } from '@/types/hermes' export type ChatMessagePart = Exclude[number] @@ -817,9 +817,20 @@ function applyStoredToolResult(messages: ChatMessage[], toolMessage: SessionMess const parts = [...message.parts] const existing = parts[partIndex] + + const hasExactTodoProvenance = + toolName === 'todo' && + Boolean(toolMessage.timestamp) && + Boolean(toolCallId) && + existing.type === 'tool-call' && + existing.toolCallId === toolCallId + parts[partIndex] = { ...existing, result: parseStoredToolResult(content), + ...(toolName === 'todo' + ? { todoUpdatedAt: hasExactTodoProvenance ? toolMessage.timestamp : undefined } + : {}), isError: false } as ChatMessagePart messages[i] = { ...message, parts } @@ -847,9 +858,20 @@ function applyStoredToolResultToParts(parts: ChatMessagePart[], toolMessage: Ses const next = [...parts] const existing = next[partIndex] + + const hasExactTodoProvenance = + toolName === 'todo' && + Boolean(toolMessage.timestamp) && + Boolean(toolCallId) && + existing.type === 'tool-call' && + existing.toolCallId === toolCallId + next[partIndex] = { ...existing, result: parseStoredToolResult(content), + ...(toolName === 'todo' + ? { todoUpdatedAt: hasExactTodoProvenance ? toolMessage.timestamp : undefined } + : {}), isError: false } as ChatMessagePart @@ -869,7 +891,7 @@ function storedToolMessagePart(toolMessage: SessionMessage, fallbackIndex: numbe argsText: Object.keys(args).length ? JSON.stringify(args) : '', result: context ? { context } : {}, isError: false - } + } as ChatMessagePart } function withUniqueToolCallIds(messages: ChatMessage[]): ChatMessage[] { @@ -1097,6 +1119,86 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] { ) } +export function mergePersistedTodoProvenance( + localMessages: ChatMessage[], + persistedMessages: ChatMessage[] +): ChatMessage[] { + type TodoPartWithProvenance = Extract & { todoUpdatedAt?: number } + + let persistedPart: TodoPartWithProvenance | null = null + + for (const message of persistedMessages) { + if (message.hidden) { + continue + } + + for (const part of message.parts) { + if (part.type !== 'tool-call' || part.toolName !== 'todo' || !('result' in part)) { + continue + } + + const candidate = part as TodoPartWithProvenance + + if (parsePersistedTodos(candidate.result) !== null && Number.isFinite(candidate.todoUpdatedAt)) { + persistedPart = candidate + } + } + } + + if (!persistedPart) { + return localMessages + } + + let match: { messageIndex: number; partIndex: number } | null = null + + if (!persistedPart.toolCallId) { + return localMessages + } + + for (let messageIndex = localMessages.length - 1; messageIndex >= 0; messageIndex -= 1) { + const message = localMessages[messageIndex] + + if (message.hidden) { + continue + } + + for (let partIndex = message.parts.length - 1; partIndex >= 0; partIndex -= 1) { + const part = message.parts[partIndex] + + if (part.type !== 'tool-call' || part.toolName !== 'todo' || !('result' in part)) { + continue + } + + if (part.toolCallId === persistedPart.toolCallId) { + match = { messageIndex, partIndex } + messageIndex = -1 + + break + } + } + } + + if (!match) { + return localMessages + } + + const nextMessages = [...localMessages] + const localMessage = nextMessages[match.messageIndex]! + const nextParts = [...localMessage.parts] + const localPart = nextParts[match.partIndex] + + const updatedPart: TodoPartWithProvenance = { + ...(localPart as Extract), + result: persistedPart.result, + todoUpdatedAt: persistedPart.todoUpdatedAt + } + + nextParts[match.partIndex] = updatedPart + nextMessages[match.messageIndex] = { ...localMessage, parts: nextParts } + + return nextMessages +} + export function preserveLocalAssistantErrors( nextMessages: ChatMessage[], currentMessages: ChatMessage[] diff --git a/apps/desktop/src/lib/todos.test.ts b/apps/desktop/src/lib/todos.test.ts index a19752c7372ff..f0bb1625c3d8f 100644 --- a/apps/desktop/src/lib/todos.test.ts +++ b/apps/desktop/src/lib/todos.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { latestSessionTodos, parseTodos } from './todos' +import { latestSessionPlan, latestSessionTodos, parseTodos } from './todos' describe('parseTodos', () => { it('parses todo arrays with valid ids, content, and statuses', () => { @@ -77,4 +77,195 @@ describe('latestSessionTodos', () => { expect(latestSessionTodos([{ parts: [{ type: 'text', text: 'hi' }] }])).toBeNull() expect(latestSessionTodos([])).toBeNull() }) + + describe('latestSessionPlan', () => { + const user = (id: string, timestamp: number) => ({ id, parts: [{ type: 'text', text: id }], role: 'user', timestamp }) + + const plan = (id: string, todos: unknown, timestamp: number, todoUpdatedAt = timestamp) => ({ + id, + role: 'assistant', + timestamp, + parts: [todoPart(todos, { result: { todos }, todoUpdatedAt })] + }) + + it('identifies the producing turn, exact todo update time, and completion count', () => { + const snapshot = latestSessionPlan( + [ + user('u1', 10), + plan( + 'a1', + [ + { content: 'Done', id: 'done', status: 'completed' }, + { content: 'Skipped', id: 'skip', status: 'cancelled' } + ], + 30, + 22 + ) + ], + { busy: false, hasRuntime: true } + ) + + expect(snapshot).toMatchObject({ + completedCount: 1, + hasNewerTurnWithoutTodo: false, + sourceMessageId: 'a1', + status: 'paused', + totalCount: 2, + turnNumber: 1, + updatedAt: 22 + }) + }) + + it('labels unfinished settled work paused without treating in_progress as liveness', () => { + const snapshot = latestSessionPlan( + [user('u1', 10), plan('a1', [{ content: 'Was running', id: 'run', status: 'in_progress' }], 20)], + { busy: false, hasRuntime: true } + ) + + expect(snapshot?.status).toBe('paused') + }) + + it('labels a plan active only from explicit live-turn state', () => { + const messages = [user('u1', 10), plan('a1', [{ content: 'Running', id: 'run', status: 'in_progress' }], 20)] + + expect(latestSessionPlan(messages, { busy: true, hasRuntime: true })?.status).toBe('active') + expect(latestSessionPlan(messages, { busy: false, hasRuntime: true })?.status).toBe('paused') + }) + + it('labels the latest plan superseded when a newer user turn has no todo update', () => { + const snapshot = latestSessionPlan( + [ + user('u1', 10), + plan('a1', [{ content: 'Old plan', id: 'old', status: 'completed' }], 20), + user('u2', 30), + { id: 'a2', parts: [{ type: 'text', text: 'No new plan' }], role: 'assistant', timestamp: 40 } + ], + { busy: false, hasRuntime: true } + ) + + expect(snapshot).toMatchObject({ hasNewerTurnWithoutTodo: true, status: 'superseded', turnNumber: 1 }) + }) + + it('uses only the newest todo result and marks an unbound stored session historical', () => { + const snapshot = latestSessionPlan( + [ + user('u1', 10), + plan('a1', [{ content: 'Old', id: 'old', status: 'completed' }], 20), + user('u2', 30), + plan('a2', [{ content: 'New', id: 'new', status: 'pending' }], 40) + ], + { busy: false, hasRuntime: false } + ) + + expect(snapshot).toMatchObject({ sourceMessageId: 'a2', status: 'historical', turnNumber: 2 }) + expect(snapshot?.items).toEqual([{ content: 'New', id: 'new', status: 'pending' }]) + }) + + it('ignores hidden branch results and reports the newer visible turn', () => { + const snapshot = latestSessionPlan( + [ + user('u1', 10), + plan('a1', [{ content: 'Kept', id: 'kept', status: 'completed' }], 20), + user('u2', 30), + { ...plan('hidden-a2', [{ content: 'Discarded', id: 'discarded', status: 'completed' }], 40), hidden: true }, + { id: 'retry-a2', parts: [{ type: 'text', text: 'Retried without a plan' }], role: 'assistant', timestamp: 50 } + ], + { busy: false, hasRuntime: true } + ) + + expect(snapshot).toMatchObject({ + hasNewerTurnWithoutTodo: true, + sourceMessageId: 'a1', + status: 'superseded', + turnNumber: 1 + }) + expect(snapshot?.items).toEqual([{ content: 'Kept', id: 'kept', status: 'completed' }]) + }) + + it('ignores attempted args and result payloads without durable update provenance', () => { + const attempted = { + id: 'attempted', + parts: [todoPart([{ content: 'Attempted', id: 'attempted', status: 'pending' }])], + role: 'assistant', + timestamp: 20 + } + + const resultWithoutTimestamp = { + id: 'unproven', + parts: [todoPart([], { result: { todos: [{ content: 'Unproven', id: 'unproven', status: 'completed' }] } })], + role: 'assistant', + timestamp: 30 + } + + expect(latestSessionPlan([user('u1', 10), attempted, resultWithoutTimestamp], { busy: false, hasRuntime: true })).toBeNull() + }) + + it.each([ + ['a newer todo result', [{ content: 'New plan', id: 'new', status: 'completed' }]], + ['an explicit empty clear', []] + ])('does not fall back to older persisted history while %s awaits provenance', (_label, items) => { + const older = plan('older', [{ content: 'Older plan', id: 'older', status: 'pending' }], 20) + + const unproven = { + id: 'unproven-newer', + parts: [todoPart([], { result: { todos: items } })], + role: 'assistant', + timestamp: 40 + } + + expect(latestSessionPlan([user('u1', 10), older, user('u2', 30), unproven], { busy: false, hasRuntime: true })).toBeNull() + }) + + it('labels only fully completed non-empty lists completed', () => { + const completed = latestSessionPlan( + [user('u1', 10), plan('done', [{ content: 'Done', id: 'done', status: 'completed' }], 20)], + { busy: false, hasRuntime: true } + ) + + const cancelled = latestSessionPlan( + [user('u1', 10), plan('cancelled', [{ content: 'Cancelled', id: 'cancelled', status: 'cancelled' }], 20)], + { busy: false, hasRuntime: true } + ) + + const empty = latestSessionPlan([user('u1', 10), plan('empty', [], 20)], { busy: false, hasRuntime: true }) + + expect(completed?.status).toBe('completed') + expect(cancelled).toMatchObject({ completedCount: 0, status: 'paused', totalCount: 1 }) + expect(empty).toBeNull() + }) + + it('ignores a newer malformed persisted result instead of clearing the last valid plan', () => { + const older = plan('valid', [{ content: 'Keep this plan', id: 'kept', status: 'pending' }], 20) + const malformed = plan('malformed', [{ content: 'Broken', id: 'broken', status: 'invalid' }], 30) + + const snapshot = latestSessionPlan([user('u1', 10), older, user('u2', 25), malformed], { + busy: false, + hasRuntime: true + }) + + expect(snapshot).toMatchObject({ + hasNewerTurnWithoutTodo: true, + sourceMessageId: 'valid', + status: 'superseded' + }) + expect(snapshot?.items).toEqual([{ content: 'Keep this plan', id: 'kept', status: 'pending' }]) + }) + + it('rejects persisted entries whose id or content is not a string', () => { + const older = plan('valid', [{ content: 'Keep this plan', id: 'kept', status: 'pending' }], 20) + const malformed = plan('malformed-scalars', [{ content: { nested: true }, id: 7, status: 'pending' }], 30) + + const snapshot = latestSessionPlan([user('u1', 10), older, user('u2', 25), malformed], { + busy: false, + hasRuntime: true + }) + + expect(snapshot).toMatchObject({ sourceMessageId: 'valid', status: 'superseded' }) + expect(snapshot?.items).toEqual([{ content: 'Keep this plan', id: 'kept', status: 'pending' }]) + }) + + it('returns null for sessions with no todo history', () => { + expect(latestSessionPlan([user('u1', 10)], { busy: false, hasRuntime: true })).toBeNull() + }) + }) }) diff --git a/apps/desktop/src/lib/todos.ts b/apps/desktop/src/lib/todos.ts index 6a5d8eea06d90..1d0eb470b7307 100644 --- a/apps/desktop/src/lib/todos.ts +++ b/apps/desktop/src/lib/todos.ts @@ -1,4 +1,5 @@ export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled' +export type CurrentPlanStatus = 'active' | 'paused' | 'completed' | 'superseded' | 'historical' export interface TodoItem { content: string @@ -6,49 +7,108 @@ export interface TodoItem { status: TodoStatus } +export interface CurrentPlanSnapshot { + completedCount: number + hasNewerTurnWithoutTodo: boolean + items: TodoItem[] + sourceMessageId: string | null + status: CurrentPlanStatus + totalCount: number + turnNumber: number | null + updatedAt: number | null +} + +export interface CurrentPlanRuntimeState { + busy: boolean + hasRuntime: boolean +} + const STATUSES: readonly TodoStatus[] = ['pending', 'in_progress', 'completed', 'cancelled'] 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) -function parseArray(value: unknown[]): TodoItem[] { - return value.flatMap(item => { +function parseArray(value: unknown[], strict: boolean): null | TodoItem[] { + const parsed: TodoItem[] = [] + + for (const item of value) { if (!isRecord(item) || !isStatus(item.status)) { - return [] + if (strict) { + return null + } + + continue + } + + if (strict && (typeof item.id !== 'string' || typeof item.content !== 'string')) { + return null } const id = String(item.id ?? '').trim() const content = String(item.content ?? '').trim() - return id && content ? [{ content, id, status: item.status }] : [] - }) + if (!id || !content) { + if (strict) { + return null + } + + continue + } + + parsed.push({ content, id, status: item.status }) + } + + return parsed } -function parse(value: unknown, depth: number): null | TodoItem[] { +function parse(value: unknown, depth: number, strict: boolean): null | TodoItem[] { if (depth > 2) { return null } if (Array.isArray(value)) { - return parseArray(value) + return parseArray(value, strict) } if (typeof value === 'string' && value.trim()) { try { - return parse(JSON.parse(value), depth + 1) + return parse(JSON.parse(value), depth + 1, strict) } catch { return null } } if (isRecord(value) && Object.hasOwn(value, 'todos')) { - return parse(value.todos, depth + 1) + return parse(value.todos, depth + 1, strict) } return null } -export const parseTodos = (value: unknown): null | TodoItem[] => parse(value, 0) +export const parseTodos = (value: unknown): null | TodoItem[] => parse(value, 0, false) + +/** Parse an authoritative persisted result without silently dropping malformed + * entries. Empty arrays remain valid clears; any malformed item makes the whole + * snapshot unusable so older valid history can remain authoritative. */ +export const parsePersistedTodos = (value: unknown): null | TodoItem[] => parse(value, 0, true) + +/** Whether a rendered message contains a completed, strictly valid todo result. + * Empty arrays are valid clears and must survive interruption until persisted + * provenance can be merged back onto the exact tool-call identity. */ +export function messageHasValidTodoResult(message: { hidden?: unknown; parts?: unknown }): boolean { + if (message.hidden || !Array.isArray(message.parts)) { + return false + } + + return message.parts.some( + part => + isRecord(part) && + part.type === 'tool-call' && + part.toolName === 'todo' && + Object.hasOwn(part, 'result') && + parsePersistedTodos(part.result) !== null + ) +} /** Latest parseable todo list from one message's aui content parts (tool-call * parts named `todo`; live parts carry `todos`, hydrated ones args/result). */ @@ -86,3 +146,117 @@ export function latestSessionTodos(messages: readonly { parts?: unknown }[]): nu return null } + +interface PlanMessage { + hidden?: unknown + id?: unknown + parts?: unknown + role?: unknown + timestamp?: unknown +} + +interface TodoSnapshotInMessage { + items: TodoItem[] + updatedAt: number | null +} + +function finiteTimestamp(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null +} + +/** Latest strictly valid todo result within one rendered message. The timestamp + * remains null until hydration proves persisted provenance; that unproven result + * still blocks fallback to older history. */ +function todoSnapshotFromMessage(message: PlanMessage): TodoSnapshotInMessage | null { + if (message.hidden || !Array.isArray(message.parts)) { + return null + } + + let snapshot: TodoSnapshotInMessage | null = null + + for (const part of message.parts) { + if (!isRecord(part) || part.type !== 'tool-call' || part.toolName !== 'todo') { + continue + } + + const items = parsePersistedTodos(part.result) + const updatedAt = finiteTimestamp(part.todoUpdatedAt) + + if (items !== null) { + snapshot = { items, updatedAt } + } + } + + return snapshot +} + +const planIsCompleted = (items: readonly TodoItem[]) => + items.length > 0 && items.every(item => item.status === 'completed') + +/** + * Derive the persistent, read-only plan view from hydrated session history. + * + * This deliberately does not read the live todo nanostore: message history is + * the durable source of truth, while explicit runtime state is the only input + * allowed to produce an `active` label. A stale `in_progress` item alone can + * therefore never imply liveness. + */ +export function latestSessionPlan( + messages: readonly PlanMessage[], + runtime: CurrentPlanRuntimeState +): CurrentPlanSnapshot | null { + let sourceIndex = -1 + let latest: TodoSnapshotInMessage | null = null + + for (let index = messages.length - 1; index >= 0; index -= 1) { + const snapshot = todoSnapshotFromMessage(messages[index] ?? {}) + + if (snapshot) { + // A newer completed result is authoritative enough to invalidate older + // history, but not yet proven enough to display until hydration supplies + // its persisted timestamp. + if (snapshot.updatedAt === null) { + return null + } + + sourceIndex = index + latest = snapshot + + break + } + } + + if (!latest || sourceIndex < 0 || latest.items.length === 0) { + return null + } + + const visibleThroughSource = messages.slice(0, sourceIndex + 1).filter(message => !message.hidden) + const turnNumber = visibleThroughSource.filter(message => message.role === 'user').length || null + + const hasNewerTurnWithoutTodo = messages + .slice(sourceIndex + 1) + .some(message => !message.hidden && message.role === 'user') + + const status: CurrentPlanStatus = hasNewerTurnWithoutTodo + ? 'superseded' + : runtime.busy + ? 'active' + : !runtime.hasRuntime + ? 'historical' + : planIsCompleted(latest.items) + ? 'completed' + : 'paused' + + const sourceMessageId = messages[sourceIndex]?.id + + return { + completedCount: latest.items.filter(item => item.status === 'completed').length, + hasNewerTurnWithoutTodo, + items: latest.items, + sourceMessageId: typeof sourceMessageId === 'string' ? sourceMessageId : null, + status, + totalCount: latest.items.length, + turnNumber, + updatedAt: latest.updatedAt + } +}