fix: stop stale pinned todos after terminal runs (#2321)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
kami 2026-05-20 11:13:20 +08:00 committed by GitHub
parent 8b16d21785
commit dea07840f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 232 additions and 7 deletions

View file

@ -10,7 +10,7 @@ import {
DESIGN_SYSTEM_WORKSPACE_DISPLAY_TITLE,
isDesignSystemWorkspacePrompt,
} from '../design-system-auto-prompt';
import { latestTodoWriteInputFromMessages } from '../runtime/todos';
import { latestTodoWriteInputForPinnedCard } from '../runtime/todos';
import { TodoCard } from './ToolCard';
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ChatMessage, ChatMessageFeedbackChange, Conversation, PreviewComment, ProjectFile, ProjectMetadata, SkillSummary } from '../types';
import { dayKey, dayLabel, exactDateTime, messageTime, relativeTimeLong } from '../utils/chatTime';
@ -915,7 +915,7 @@ function PinnedTodoSlot({
// the slot tears down. Without it React would unmount immediately and
// the card would pop out without animation.
const [exiting, setExiting] = useState(false);
const input = latestTodoWriteInputFromMessages(messages);
const input = latestTodoWriteInputForPinnedCard(messages);
if (input == null) return null;
let snapshotKey: string;
try {

View file

@ -1872,9 +1872,10 @@ function workspaceActivityProgress(
return 18;
}
function todoStatusClass(status: 'pending' | 'in_progress' | 'completed'): 'pending' | 'running' | 'succeeded' {
function todoStatusClass(status: ReturnType<typeof latestTodosFromEvents>[number]['status']): 'pending' | 'running' | 'succeeded' | 'failed' {
if (status === 'completed') return 'succeeded';
if (status === 'in_progress') return 'running';
if (status === 'stopped') return 'failed';
return 'pending';
}

View file

@ -389,7 +389,7 @@ export function TodoCard({ input, runStreaming, runSucceeded, onDismiss }: { inp
// The user can flip it manually via the header button — that local
// override sticks for the lifetime of this card.
const hasInProgress = todos.some((todo) => todo.status === 'in_progress');
const hasPending = todos.some((todo) => todo.status !== 'completed');
const hasPending = todos.some((todo) => todo.status === 'pending' || todo.status === 'in_progress');
const defaultExpanded = todos.length > 0 && (hasInProgress || hasPending || runStreaming);
const [overrideExpanded, setOverrideExpanded] = useState<boolean | null>(null);
const expanded = overrideExpanded ?? defaultExpanded;
@ -449,7 +449,13 @@ export function TodoCard({ input, runStreaming, runSucceeded, onDismiss }: { inp
{todos.map((todo, i) => (
<li key={i} className={`todo-item todo-${todo.status}`}>
<span className="todo-check" aria-hidden>
{todo.status === 'completed' ? '✓' : todo.status === 'in_progress' ? '◐' : '○'}
{todo.status === 'completed'
? '✓'
: todo.status === 'in_progress'
? '◐'
: todo.status === 'stopped'
? '!'
: '○'}
</span>
<span className="todo-text">
{todo.status === 'in_progress' && todo.activeForm ? todo.activeForm : todo.content}

View file

@ -13590,6 +13590,14 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) {
color: var(--text-muted);
}
/* Stopped — terminal fallback for a run that ended mid-task */
.todo-stopped {
background: color-mix(in srgb, var(--red) 8%, var(--bg-panel));
border-color: color-mix(in srgb, var(--red) 24%, transparent);
}
.todo-stopped .todo-check { color: var(--red); font-weight: 700; }
.todo-stopped .todo-text { color: var(--text); }
/* Composer extras */
.composer.drag-active {
outline: 2px dashed var(--accent);

View file

@ -538,6 +538,7 @@ async function consumeDaemonRun({
serverDeclaredSuccess = status.status === 'succeeded';
onRunStatus?.(endStatus);
} else {
onRunStatus?.('failed');
handlers.onError(new Error('daemon stream disconnected before run completed'));
return;
}

View file

@ -1,6 +1,6 @@
import type { AgentEvent } from '../types';
export type TodoStatus = 'pending' | 'in_progress' | 'completed';
export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'stopped';
export interface TodoItem {
content: string;
@ -19,7 +19,7 @@ export function parseTodoWriteInput(input: unknown): TodoItem[] {
const content = typeof record.content === 'string' ? record.content : '';
if (!content) return null;
const status =
record.status === 'completed' || record.status === 'in_progress'
record.status === 'completed' || record.status === 'in_progress' || record.status === 'stopped'
? record.status
: 'pending';
return {
@ -66,6 +66,63 @@ export function latestTodoWriteInputFromMessages(
return null;
}
export function latestTodoWriteInputForPinnedCard<
T extends {
events?: AgentEvent[] | undefined;
runStatus?: 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled' | undefined;
endedAt?: number | undefined;
},
>(
messages: ReadonlyArray<T> | undefined,
): unknown | null {
if (!messages || messages.length === 0) return null;
for (let mi = messages.length - 1; mi >= 0; mi -= 1) {
const message = messages[mi];
const events = message?.events;
if (!events || events.length === 0) continue;
for (let ei = events.length - 1; ei >= 0; ei -= 1) {
const event = events[ei];
if (event?.kind !== 'tool_use') continue;
if (!isTodoWriteToolName(event.name)) continue;
if (!hasTerminalRunEnded(message.runStatus, message.endedAt)) {
return event.input;
}
return stoppedTodoWriteInput(event.input);
}
}
return null;
}
function isTodoWriteToolName(name: string): boolean {
return name === 'TodoWrite' || name === 'todowrite';
}
function hasTerminalRunEnded(
runStatus: 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled' | undefined,
endedAt: number | undefined,
): boolean {
return (
runStatus === 'succeeded' ||
runStatus === 'failed' ||
runStatus === 'canceled' ||
(runStatus === undefined && endedAt !== undefined)
);
}
function stoppedTodoWriteInput(input: unknown): unknown {
if (!input || typeof input !== 'object') return input;
const obj = input as { todos?: unknown };
if (!Array.isArray(obj.todos)) return input;
return {
...(input as Record<string, unknown>),
todos: obj.todos.map((todo) => {
if (!todo || typeof todo !== 'object') return todo;
const record = todo as Record<string, unknown>;
if (record.status !== 'in_progress') return todo;
return {
...record,
status: 'stopped',
};
}),
};
}

View file

@ -131,6 +131,60 @@ Expected output:
expect(screen.getByTestId('composer-streaming').textContent).toBe('idle');
expect(screen.getByTestId('assistant-streaming-assistant-1').textContent).toBe('streaming');
});
it('renders a stopped pinned todo after a terminal run without a final TodoWrite', () => {
const messages: ChatMessage[] = [
{
id: 'assistant-1',
role: 'assistant',
content: '',
createdAt: 1,
startedAt: 1,
endedAt: 2,
runStatus: 'failed',
events: [
{
kind: 'tool_use',
id: 'todo-1',
name: 'TodoWrite',
input: {
todos: [
{
content: 'Build prototype',
status: 'in_progress',
activeForm: 'Building prototype',
},
{ content: 'Run QA', status: 'pending' },
],
},
},
],
},
];
const { container } = render(
<ChatPane
messages={messages}
streaming={false}
error={null}
projectId="project-1"
projectFiles={[]}
onEnsureProject={async () => 'project-1'}
onSend={vi.fn()}
onStop={vi.fn()}
conversations={conversations}
activeConversationId="conv-1"
onSelectConversation={vi.fn()}
onDeleteConversation={vi.fn()}
projectMetadata={projectMetadata}
/>,
);
expect(screen.getByText('0/2')).toBeTruthy();
expect(container.querySelector('.todo-stopped')?.textContent).toContain('Build prototype');
expect(container.querySelector('.todo-in_progress')).toBeNull();
expect(container.querySelector('.op-todo-current')).toBeNull();
});
});
const conversations: Conversation[] = [

View file

@ -732,6 +732,45 @@ describe('streamViaDaemon', () => {
expect(handlers.onDone).not.toHaveBeenCalled();
});
it('marks a daemon run failed when the SSE stream closes silently and status is still active', async () => {
const handlers = createDaemonHandlers();
const onRunStatus = vi.fn();
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/runs') return jsonResponse({ runId: 'run-1' });
if (url === '/api/runs/run-1/events') return sseResponse('');
if (url === '/api/runs/run-1') {
return new Response(
JSON.stringify({
id: 'run-1',
status: 'running',
createdAt: 1,
updatedAt: 2,
exitCode: null,
signal: null,
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
await streamViaDaemon({
agentId: 'mock',
history: [{ id: '1', role: 'user', content: 'hello' }],
systemPrompt: '',
signal: new AbortController().signal,
handlers,
onRunStatus,
});
expect(fetchMock.mock.calls.some(([input]) => String(input) === '/api/runs/run-1')).toBe(true);
expect(onRunStatus).toHaveBeenCalledWith('failed');
expect(handlers.onError).toHaveBeenCalledWith(new Error('daemon stream disconnected before run completed'));
expect(handlers.onDone).not.toHaveBeenCalled();
});
it('includes selected preview comments without requiring visible draft text', async () => {
const handlers = createDaemonHandlers();
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
latestTodosFromEvents,
latestTodoWriteInputForPinnedCard,
parseTodoWriteInput,
unfinishedTodosFromEvents,
} from '../../src/runtime/todos';
@ -117,4 +118,62 @@ describe('todo event helpers', () => {
},
]);
});
it('marks the active todo as stopped when a failed run ended without a final TodoWrite', () => {
const input = latestTodoWriteInputForPinnedCard([
{
id: 'assistant-1',
role: 'assistant',
content: '',
runStatus: 'failed',
events: [
{
kind: 'tool_use',
id: 'todo-1',
name: 'TodoWrite',
input: {
todos: [
{ content: 'Draft layout', status: 'completed' },
{ content: 'Build components', status: 'in_progress', activeForm: 'Building components' },
{ content: 'Run QA', status: 'pending' },
],
},
},
],
},
]);
expect(parseTodoWriteInput(input)).toEqual([
{ content: 'Draft layout', status: 'completed', activeForm: undefined },
{ content: 'Build components', status: 'stopped', activeForm: 'Building components' },
{ content: 'Run QA', status: 'pending', activeForm: undefined },
]);
});
it('marks the active todo as stopped when a nominally successful run ended with stale progress', () => {
const input = latestTodoWriteInputForPinnedCard([
{
runStatus: 'succeeded',
endedAt: 3_000,
events: [
{
kind: 'tool_use',
id: 'todo-1',
name: 'TodoWrite',
input: {
todos: [
{ content: 'Generate HTML', status: 'in_progress' },
{ content: 'Self-check', status: 'pending' },
],
},
},
],
},
]);
expect(parseTodoWriteInput(input)).toEqual([
{ content: 'Generate HTML', status: 'stopped', activeForm: undefined },
{ content: 'Self-check', status: 'pending', activeForm: undefined },
]);
});
});