mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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)
119 lines
4.0 KiB
TypeScript
119 lines
4.0 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import {
|
|
closeDatabase,
|
|
insertConversation,
|
|
insertProject,
|
|
listLatestProjectRunStatuses,
|
|
openDatabase,
|
|
upsertMessage,
|
|
} from '../src/db.js';
|
|
|
|
// #1247 / #1060 — the project-status projection must read a terminal `succeeded`
|
|
// run that ended with unfinished declared work as `incomplete`, never as
|
|
// `succeeded`, and it must survive reload because it derives the verdict from the
|
|
// persisted `events_json` (the same TodoWrite snapshots the chat footer reads).
|
|
|
|
function todoWriteEvent(todos: Array<{ content: string; status: string }>) {
|
|
return [{ kind: 'tool_use', id: 'tw-1', name: 'TodoWrite', input: { todos } }];
|
|
}
|
|
|
|
describe('listLatestProjectRunStatuses incomplete derivation (#1247)', () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = mkdtempSync(path.join(os.tmpdir(), 'od-project-status-incomplete-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
closeDatabase();
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function seed(
|
|
db: ReturnType<typeof openDatabase>,
|
|
projectId: string,
|
|
runStatus: string,
|
|
events: unknown[],
|
|
) {
|
|
const now = Date.now();
|
|
insertProject(db, { id: projectId, name: projectId, createdAt: now, updatedAt: now });
|
|
insertConversation(db, {
|
|
id: `${projectId}-conv`,
|
|
projectId,
|
|
title: projectId,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
upsertMessage(db, `${projectId}-conv`, {
|
|
id: `${projectId}-assistant`,
|
|
role: 'assistant',
|
|
content: '',
|
|
runId: `${projectId}-run`,
|
|
runStatus,
|
|
events,
|
|
startedAt: now,
|
|
endedAt: now,
|
|
});
|
|
}
|
|
|
|
it('projects a succeeded run with a pending TodoWrite as incomplete', () => {
|
|
const db = openDatabase(tempDir, { dataDir: tempDir });
|
|
seed(db, 'unfinished', 'succeeded', todoWriteEvent([
|
|
{ content: 'Draft layout', status: 'completed' },
|
|
{ content: 'Build components', status: 'pending' },
|
|
]));
|
|
|
|
expect(listLatestProjectRunStatuses(db).get('unfinished')?.value).toBe('incomplete');
|
|
});
|
|
|
|
it('projects a succeeded run whose only task is stopped as incomplete', () => {
|
|
const db = openDatabase(tempDir, { dataDir: tempDir });
|
|
seed(db, 'stopped-only', 'succeeded', todoWriteEvent([
|
|
{ content: 'Build components', status: 'stopped' },
|
|
]));
|
|
|
|
expect(listLatestProjectRunStatuses(db).get('stopped-only')?.value).toBe('incomplete');
|
|
});
|
|
|
|
it('leaves an all-completed TodoWrite run as succeeded', () => {
|
|
const db = openDatabase(tempDir, { dataDir: tempDir });
|
|
seed(db, 'finished', 'succeeded', todoWriteEvent([
|
|
{ content: 'Draft layout', status: 'completed' },
|
|
{ content: 'Build components', status: 'completed' },
|
|
]));
|
|
|
|
expect(listLatestProjectRunStatuses(db).get('finished')?.value).toBe('succeeded');
|
|
});
|
|
|
|
it('leaves a text-only succeeded run (no TodoWrite) as succeeded', () => {
|
|
const db = openDatabase(tempDir, { dataDir: tempDir });
|
|
seed(db, 'text-only', 'succeeded', [{ kind: 'text', text: 'Here is your answer.' }]);
|
|
|
|
expect(listLatestProjectRunStatuses(db).get('text-only')?.value).toBe('succeeded');
|
|
});
|
|
|
|
it('does not reclassify a failed run with unfinished todos (stays failed)', () => {
|
|
const db = openDatabase(tempDir, { dataDir: tempDir });
|
|
seed(db, 'failed', 'failed', todoWriteEvent([
|
|
{ content: 'Build components', status: 'in_progress' },
|
|
]));
|
|
|
|
expect(listLatestProjectRunStatuses(db).get('failed')?.value).toBe('failed');
|
|
});
|
|
|
|
it('survives reload: re-opening the database still derives incomplete', () => {
|
|
const first = openDatabase(tempDir, { dataDir: tempDir });
|
|
seed(first, 'reload', 'succeeded', todoWriteEvent([
|
|
{ content: 'Build components', status: 'in_progress' },
|
|
]));
|
|
closeDatabase();
|
|
|
|
const reopened = openDatabase(tempDir, { dataDir: tempDir });
|
|
expect(listLatestProjectRunStatuses(reopened).get('reload')?.value).toBe('incomplete');
|
|
});
|
|
});
|