mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
bc5b6f0588
A design-generation run that ended its turn with TodoWrite tasks still pending / in_progress / stopped — or was truncated by max_tokens — was stamped `succeeded` and read "Completed" on the Pet task center and the project pill, while the chat footer simultaneously read "Stopped with unfinished work". Users trusted "Completed", assumed the work was done, and re-requested, creating duplicate generations and eroding trust in the run-state UI (#1247 / #1060). Root cause: the daemon equates "the turn/process ended" with "succeeded" and never consults the agent's own declared work; multiple web surfaces then map `succeeded` -> "Completed" without any completeness signal, even though the chat footer already distinguishes unfinished work. Fix — one canonical signal, consumed everywhere so the surfaces cannot drift: - packages/contracts/api/run-completeness.ts defines the canonical "unfinished declared work" predicate (status !== 'completed', so pending / in_progress / stopped all count), matching the web footer's unfinishedTodosFromEvents exactly. - Daemon folds the last TodoWrite snapshot + a max_tokens truncation off the agent event stream (every runtime, via the single emitAgentEvent choke point) and derives run.endedWithUnfinishedWork once at finish(). - The project-status projection derives `incomplete` from the already persisted events_json — no schema migration — so it survives reload/reattach instead of flashing "Completed" once the in-memory run ages out. - Contracts carry endedWithUnfinishedWork on ChatRunStatusResponse and the SSE end payload (backward compatible: absent = finished), plus a new `incomplete` ProjectDisplayStatus. - Web surfaces (Pet task center, project pill, DesignsTab) render `incomplete` as amber needs-attention, never the success color; the footer now sources the same shared predicate. Truncation uses the display-only option (spec Option A): the run stays `succeeded` but is flagged incomplete, avoiding the code===0 / stdin-close hazards of reclassifying a truncated turn to failed. Regression coverage, red on main / green here: an HTTP-boundary e2e (e2e/tests/dialog/task-completeness.test.ts) drives the interactive chat path for unfinished / stopped / truncated / finished runs; daemon + contracts + web unit tests pin the projection and the canonical predicate. Ref: https://plane.powerformer.net/open-design/projects/49832a02-3158-4faf-bf2f-d0e39c40c7e6/issues/011f6f51-7a25-4043-ae94-182e0b8b0931 Generated-By: looper 0.0.0-dev (runner=worker, agent=claude-code)
309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
latestTodosFromEvents,
|
|
latestTodoWriteInputForPinnedCard,
|
|
parseTodoWriteInput,
|
|
unfinishedTodosFromEvents,
|
|
} from '../../src/runtime/todos';
|
|
import type { AgentEvent } from '../../src/types';
|
|
|
|
const firstTodoInput = {
|
|
todos: [
|
|
{ content: 'Draft layout', status: 'completed' },
|
|
{ content: 'Build components', status: 'in_progress', activeForm: 'Building components' },
|
|
{ content: 'Run QA', status: 'pending' },
|
|
{ content: '', status: 'pending' },
|
|
{ content: 'Unknown status defaults pending', status: 'blocked' },
|
|
null,
|
|
],
|
|
};
|
|
|
|
describe('todo event helpers', () => {
|
|
it('normalizes TodoWrite input and ignores malformed items', () => {
|
|
expect(parseTodoWriteInput(firstTodoInput)).toEqual([
|
|
{ content: 'Draft layout', status: 'completed', activeForm: undefined },
|
|
{
|
|
content: 'Build components',
|
|
status: 'in_progress',
|
|
activeForm: 'Building components',
|
|
},
|
|
{ content: 'Run QA', status: 'pending', activeForm: undefined },
|
|
{
|
|
content: 'Unknown status defaults pending',
|
|
status: 'pending',
|
|
activeForm: undefined,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('uses the latest TodoWrite event as the current todo truth', () => {
|
|
const events: AgentEvent[] = [
|
|
{ kind: 'tool_use', id: 'todo-1', name: 'TodoWrite', input: firstTodoInput },
|
|
{ kind: 'text', text: 'Working...' },
|
|
{ kind: 'tool_use', id: 'todo-empty', name: 'TodoWrite', input: { todos: [] } },
|
|
{
|
|
kind: 'tool_use',
|
|
id: 'todo-2',
|
|
name: 'TodoWrite',
|
|
input: { todos: [{ content: 'Final polish', status: 'pending' }] },
|
|
},
|
|
];
|
|
|
|
expect(latestTodosFromEvents(events)).toEqual([
|
|
{ content: 'Final polish', status: 'pending', activeForm: undefined },
|
|
]);
|
|
});
|
|
|
|
it('recognizes lowercase OpenCode todowrite events', () => {
|
|
const events: AgentEvent[] = [
|
|
{
|
|
kind: 'tool_use',
|
|
id: 'todo-1',
|
|
name: 'todowrite',
|
|
input: {
|
|
todos: [
|
|
{ content: 'Self-check template', status: 'completed' },
|
|
{ content: 'Emit single artifact', status: 'pending' },
|
|
],
|
|
},
|
|
},
|
|
];
|
|
|
|
expect(unfinishedTodosFromEvents(events)).toEqual([
|
|
{ content: 'Emit single artifact', status: 'pending', activeForm: undefined },
|
|
]);
|
|
});
|
|
|
|
it('normalizes Codex update_plan input as the current task queue', () => {
|
|
const events: AgentEvent[] = [
|
|
{
|
|
kind: 'tool_use',
|
|
id: 'plan-1',
|
|
name: 'update_plan',
|
|
input: {
|
|
plan: [
|
|
{ step: 'Inspect chat rendering', status: 'completed' },
|
|
{ step: 'Add annotation card', status: 'in_progress' },
|
|
{ step: 'Run focused tests', status: 'pending' },
|
|
],
|
|
},
|
|
},
|
|
];
|
|
|
|
expect(latestTodosFromEvents(events)).toEqual([
|
|
{ content: 'Inspect chat rendering', status: 'completed', activeForm: undefined },
|
|
{ content: 'Add annotation card', status: 'in_progress', activeForm: undefined },
|
|
{ content: 'Run focused tests', status: 'pending', activeForm: undefined },
|
|
]);
|
|
expect(unfinishedTodosFromEvents(events)).toEqual([
|
|
{ content: 'Add annotation card', status: 'in_progress', activeForm: undefined },
|
|
{ content: 'Run focused tests', status: 'pending', activeForm: undefined },
|
|
]);
|
|
});
|
|
|
|
it('recognizes snake_case todo_write events', () => {
|
|
const input = latestTodoWriteInputForPinnedCard([
|
|
{
|
|
runStatus: 'running',
|
|
events: [
|
|
{
|
|
kind: 'tool_use',
|
|
id: 'todo-1',
|
|
name: 'todo_write',
|
|
input: { todos: [{ content: 'Port task queue card', status: 'pending' }] },
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
expect(parseTodoWriteInput(input)).toEqual([
|
|
{ content: 'Port task queue card', status: 'pending', activeForm: undefined },
|
|
]);
|
|
});
|
|
|
|
it('accepts native task item text aliases used by different agents', () => {
|
|
expect(parseTodoWriteInput({
|
|
todos: [
|
|
{ description: 'Inspect plan mode output', status: 'completed' },
|
|
{ label: 'Render todo card', status: 'in_progress' },
|
|
{ text: 'Run focused tests', status: 'pending' },
|
|
],
|
|
})).toEqual([
|
|
{ content: 'Inspect plan mode output', status: 'completed', activeForm: undefined },
|
|
{ content: 'Render todo card', status: 'in_progress', activeForm: undefined },
|
|
{ content: 'Run focused tests', status: 'pending', activeForm: undefined },
|
|
]);
|
|
});
|
|
|
|
it('uses lowercase todowrite as the latest todo truth over older TodoWrite events', () => {
|
|
const events: AgentEvent[] = [
|
|
{ kind: 'tool_use', id: 'todo-1', name: 'TodoWrite', input: firstTodoInput },
|
|
{
|
|
kind: 'tool_use',
|
|
id: 'todo-2',
|
|
name: 'todowrite',
|
|
input: { todos: [{ content: 'Emit single artifact', status: 'pending' }] },
|
|
},
|
|
];
|
|
|
|
expect(latestTodosFromEvents(events)).toEqual([
|
|
{ content: 'Emit single artifact', status: 'pending', activeForm: undefined },
|
|
]);
|
|
});
|
|
|
|
it('treats an empty latest TodoWrite event as authoritative', () => {
|
|
const events: AgentEvent[] = [
|
|
{ kind: 'tool_use', id: 'todo-1', name: 'TodoWrite', input: firstTodoInput },
|
|
{ kind: 'text', text: 'All done.' },
|
|
{ kind: 'tool_use', id: 'todo-empty', name: 'TodoWrite', input: { todos: [] } },
|
|
];
|
|
|
|
expect(latestTodosFromEvents(events)).toEqual([]);
|
|
expect(unfinishedTodosFromEvents(events)).toEqual([]);
|
|
});
|
|
|
|
it('returns only pending and in-progress todos as unfinished', () => {
|
|
expect(unfinishedTodosFromEvents([
|
|
{ kind: 'tool_use', id: 'todo-1', name: 'TodoWrite', input: firstTodoInput },
|
|
])).toEqual([
|
|
{
|
|
content: 'Build components',
|
|
status: 'in_progress',
|
|
activeForm: 'Building components',
|
|
},
|
|
{ content: 'Run QA', status: 'pending', activeForm: undefined },
|
|
{
|
|
content: 'Unknown status defaults pending',
|
|
status: 'pending',
|
|
activeForm: undefined,
|
|
},
|
|
]);
|
|
});
|
|
|
|
// #1247 / #1060 — locks the canonical predicate: `stopped` counts as unfinished
|
|
// (status !== 'completed'), so the footer and the daemon's endedWithUnfinishedWork
|
|
// flag agree. Narrowing to "pending/in_progress only" would reintroduce the drift.
|
|
it('counts a stopped task as unfinished, matching the daemon predicate', () => {
|
|
const events: AgentEvent[] = [
|
|
{
|
|
kind: 'tool_use',
|
|
id: 'todo-1',
|
|
name: 'TodoWrite',
|
|
input: {
|
|
todos: [
|
|
{ content: 'Draft layout', status: 'completed' },
|
|
{ content: 'Build components', status: 'stopped' },
|
|
],
|
|
},
|
|
},
|
|
];
|
|
expect(unfinishedTodosFromEvents(events)).toEqual([
|
|
{ content: 'Build components', status: 'stopped', activeForm: undefined },
|
|
]);
|
|
});
|
|
|
|
it('treats an all-completed TodoWrite as finished (no unfinished work)', () => {
|
|
const events: AgentEvent[] = [
|
|
{
|
|
kind: 'tool_use',
|
|
id: 'todo-1',
|
|
name: 'TodoWrite',
|
|
input: {
|
|
todos: [
|
|
{ content: 'Draft layout', status: 'completed' },
|
|
{ content: 'Build components', status: 'completed' },
|
|
],
|
|
},
|
|
},
|
|
];
|
|
expect(unfinishedTodosFromEvents(events)).toEqual([]);
|
|
});
|
|
|
|
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 },
|
|
]);
|
|
});
|
|
|
|
it('marks update_plan items as stopped when a terminal run ends with stale progress', () => {
|
|
const input = latestTodoWriteInputForPinnedCard([
|
|
{
|
|
runStatus: 'succeeded',
|
|
endedAt: 3_000,
|
|
events: [
|
|
{
|
|
kind: 'tool_use',
|
|
id: 'plan-1',
|
|
name: 'update_plan',
|
|
input: {
|
|
plan: [
|
|
{ step: 'Inspect chat rendering', status: 'completed' },
|
|
{ step: 'Add annotation card', status: 'in_progress' },
|
|
{ step: 'Run focused tests', status: 'pending' },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
expect(parseTodoWriteInput(input)).toEqual([
|
|
{ content: 'Inspect chat rendering', status: 'completed', activeForm: undefined },
|
|
{ content: 'Add annotation card', status: 'stopped', activeForm: undefined },
|
|
{ content: 'Run focused tests', status: 'pending', activeForm: undefined },
|
|
]);
|
|
});
|
|
});
|