mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
fix: stop stale pinned todos after terminal runs (#2321)
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
parent
8b16d21785
commit
dea07840f3
9 changed files with 232 additions and 7 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue