Files
open-design/apps/web/tests/components/ProjectView.artifact-recovery-host-message.test.tsx
CheemsandGitHub 0450945f2d fix(daemon,web): keep a cleanly exited run succeeded when its task is blocked (#8350)
* fix(daemon): keep a cleanly exited run succeeded when its task is blocked

A Run records how its process ended; the strategy task records its own
verdict. The run settlement used to rewrite a clean exit to `failed`,
send an `OD_NEXT_TASK_BLOCKED` error frame and mark the message failed
whenever the task ended `blocked` at the production stage. That put a
red failure card and a "Run failed" execution record under a turn whose
files are in the project and whose preview renders — a turn that edited
a page other than the entry, or wrote the entry and answered in prose
without the machine block — because the card for that error code is the
same one a crashed process gets.

Remove the rewrite. A Run whose process exits 0 stays `succeeded` at
every stage; the terminal event still carries the task projection with
`outcome: blocked` and its reason codes, and the client decides from
those and the deliverable on disk what the turn produced: a project with
a usable entry keeps "Done", a turn that produced nothing still gets a
failure card drawn by the client from the reason code. A non-zero exit,
a signal, a timeout and an unavailable upstream fail the Run as before.

Tests: the production-block server case now asserts the Run ends
`succeeded` with exit code 0, no error frame, `strategyTask.outcome:
blocked` at `inputStage: production`, the message `runStatus` succeeded
and `run_finished` reporting `result: success` with the primary reason
code. The refused-planning-turn case is unchanged, and the
`OD_NEXT_TASK_BLOCKED` classifier case keeps covering Runs recorded by
earlier versions.

* fix(web): keep a succeeded run Done beside a blocked task

The shared rule `canRetainSuccessfulRunForBlockedStrategy` decided
whether a Run that succeeded kept its success on screen when the
strategy task ended `blocked`: only a Run that wrote the entry, a
project that already held one beside a reply, a refusal before
production with a reply, or a block the agent declared and explained
did; everything else was marked failed on the client and given a card
keyed on the reason code. With the daemon no longer failing such Runs,
that client-side failure was the one thing still turning a clean exit
into a failed turn — and on the live stream it left a generic card under
a turn whose folded footer read Done, while a reload rebuilt the same
turn as failed with the reason-specific card.

Make the rule the Run's own result: a succeeded Run keeps its success at
every stage, whatever the task recorded and whatever the agent said. The
verdict — outcome, reason codes, visible text — still reaches the message
through the settled task projection for the diagnostics; a failure card
is drawn only for a Run that did not succeed. The signature and the three
call sites (live settlement, cold history load, artifact recovery) are
unchanged, so each still hands over the facts it resolves.

Tests: the cases that expected a run error or a restored failure for a
succeeded Run beside a blocked task now expect Done, no error event and
the verdict on the settled projection (`sse.test.ts`,
`strategy-blocked-verdict.test.ts`,
`ProjectView.blocked-history-hydration.test.tsx`,
`ProjectView.artifact-recovery-verdict.test.tsx`). The artifact-recovery
suites that used a blocked task as the way into the failed-turn recovery
path now end their Run with a non-zero exit, so they keep guarding that
path; every case that asserts a card for a failed Run is unchanged.
2026-09-22 09:04:16 +00:00

568 lines
31 KiB
TypeScript

// @vitest-environment jsdom
import { useEffect } from 'react';
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ProjectView } from '../../src/components/ProjectView';
import { streamViaDaemon } from '../../src/providers/daemon';
import { buildWorkspacePermissions, buildWorkspaceSeatSummary, type WorkspaceCollabContext, type MemoryExtractionRecord, type MemoryEntrySummary } from '@open-design/contracts';
import { I18nProvider } from '../../src/i18n';
import { createProject } from '../../src/state/projects';
import type { AppConfig, ChatMessage, Project, ProjectFile } from '../../src/types';
vi.mock('../../src/router', () => ({ navigate: vi.fn(), registerNavigationGuard: vi.fn(() => () => {}) }));
vi.mock('../../src/providers/anthropic', () => ({ streamMessage: vi.fn() }));
vi.mock('../../src/providers/daemon', async () => {
const actual = await vi.importActual<typeof import('../../src/providers/daemon')>('../../src/providers/daemon');
return {
...actual,
fetchAmrWalletSnapshot: vi.fn().mockResolvedValue(null),
listActiveChatRuns: vi.fn().mockResolvedValue([]),
listProjectRuns: vi.fn().mockResolvedValue([]),
publishDaemonRunFinishedEvent: vi.fn(),
// Real POST /runs, SSE decoder, strategy verdict and callback ordering.
streamViaDaemon: vi.fn(actual.streamViaDaemon),
};
});
vi.mock('../../src/providers/project-events', () => ({ useProjectFileEvents: vi.fn() }));
vi.mock('../../src/providers/registry', async () => {
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
'../../src/providers/registry',
);
return {
...actual,
fetchDesignSystem: vi.fn().mockResolvedValue(null),
fetchProjectDesignSystemPackageAudit: vi.fn().mockResolvedValue(null),
fetchLiveArtifacts: vi.fn().mockResolvedValue([]),
fetchProjectFilePreview: vi.fn().mockResolvedValue(null),
fetchProjectFileText: vi.fn().mockImplementation(async (_projectId: string, name: string) => fileContents.get(name) ?? null),
fetchProjectFolders: vi.fn().mockResolvedValue([]),
fetchPreviewComments: vi.fn().mockResolvedValue([]),
fetchSkill: vi.fn().mockResolvedValue(null),
// Keep real fetchProjectFiles and writeProjectTextFile. A user's POST and
// completion's fresh GET exercise their normal HTTP/cache boundaries.
};
});
vi.mock('../../src/state/projects', async () => {
const actual = await vi.importActual<typeof import('../../src/state/projects')>(
'../../src/state/projects',
);
return {
...actual,
createConversation: vi.fn(),
listPlugins: vi.fn().mockResolvedValue([]),
getTemplate: vi.fn().mockResolvedValue(null),
loadTabs: vi.fn().mockResolvedValue({ tabs: [], active: null }),
patchConversation: vi.fn(),
patchProject: vi.fn(),
saveTabs: vi.fn(),
};
});
vi.mock('../../src/components/AppChromeHeader', () => ({ AppChromeHeader: () => null, APP_CHROME_FILE_ACTIONS_ID: 'test-file-actions' }));
vi.mock('../../src/components/AvatarMenu', () => ({ AvatarMenu: () => null }));
vi.mock('../../src/components/Loading', () => ({ CenteredLoader: () => null }));
vi.mock('../../src/components/ChatPane', () => ({
ChatPane: ({ messages, onSend, viewerOnly, sendDisabled, error, activeConversationId, streaming, loading, initialDraft, queuedItems }: {
messages: ChatMessage[];
onSend: (prompt: string, attachments: [], comments: []) => void;
viewerOnly?: boolean;
sendDisabled?: boolean;
error?: string | null;
activeConversationId?: string | null;
streaming?: boolean;
loading?: boolean;
initialDraft?: string;
queuedItems?: unknown[];
}) => (
<section>
<output data-testid="home-setup-state">{JSON.stringify({ viewerOnly, sendDisabled, error, activeConversationId, streaming, loading, initialDraft, messageCount: messages.length, roles: messages.map((m) => m.role), queuedCount: queuedItems?.length })}</output>
<button disabled={viewerOnly} onClick={() => onSend('Create the requested deliverable.', [], [])}>
Send owner test
</button>
<output data-testid="assistant-produced-files">{JSON.stringify(
messages.filter((message) => message.role === 'assistant').map((message) => ({
id: message.id,
runId: message.runId,
content: message.content,
produced: message.producedFiles?.map((file) => file.name),
trace: message.traceObjectFiles?.map((file) => file.name),
})),
)}</output>
</section>
),
}));
// Observe the file list without replacing FileWorkspace or its create handler.
// The actual launcher -> createMarkdownDocument -> POST -> refresh path runs.
vi.mock('../../src/components/FileWorkspace', async () => {
const actual = await vi.importActual<typeof import('../../src/components/FileWorkspace')>(
'../../src/components/FileWorkspace',
);
const Real = actual.FileWorkspace;
return {
...actual,
FileWorkspace: (props: Parameters<typeof Real>[0]) => {
useEffect(() => {
committedOpenRequests.push({ sequence: committedOpenRequests.length + 1,
phase: observationPhase, projectId: props.projectId,
name: props.openRequest?.name ?? null, nonce: props.openRequest?.nonce ?? null });
}, [props.projectId, props.openRequest]);
return <>
<Real {...props} />
<output data-testid="workspace-open-request">{JSON.stringify({ projectId: props.projectId, openRequest: props.openRequest })}</output>
<output data-testid="project-files">{JSON.stringify(props.files.map((file) => file.name))}</output>
</>;
},
};
});
// Keep the real Markdown editor/autosave; only unrelated HTML iframe work is omitted.
vi.mock('../../src/components/FileViewer', async () => {
const actual = await vi.importActual<typeof import('../../src/components/FileViewer')>(
'../../src/components/FileViewer',
);
const Real = actual.FileViewer;
return {
...actual,
// Markdown rendering and save effects are real; unrelated HTML preview is not mounted.
FileViewer: (props: Parameters<typeof Real>[0]) => props.file.name.endsWith('.md')
? <Real {...props} /> : null,
};
});
vi.mock('../../src/components/workspace/TerminalViewer', () => ({ TerminalViewer: () => null }));
// The HTTP fixture models the daemon-owned physical status and message store.
// It does not import daemon private code, intercept ownership callbacks, reset
// the registry cache, or put an Agent output in the file list before its POST.
const config: AppConfig = {
// AppConfig.model is always a string, even when a local CLI has no model pick.
mode: 'daemon', apiProtocol: 'openai', apiKey: '', baseUrl: '', model: '',
agentId: 'opencode', skillId: null, designSystemId: null,
};
const html = '<!doctype html><html><body><h1>Agent output</h1><p>Fresh artifact produced during this run.</p></body></html>';
const artifact = '<artifact identifier="agent-output" type="text/html" title="Agent output">' + html + '</artifact>';
const manualText = '# Manual autosave revision';
function deferredGate() {
let release!: () => void;
const promise = new Promise<void>((resolve) => { release = resolve; });
return { promise, release };
}
let controllersByRun: Map<string, ReadableStreamDefaultController<Uint8Array>>;
let observationPhase: string;
let committedOpenRequests: Array<{ sequence: number; phase: string; projectId: string; name: string | null; nonce: number | null }>;
let openBoundaries: Array<{ phase: string; committedCount: number; workspace: string | null; view: string | null }>;
let renderedProject: ReturnType<typeof render>;
let navigationProject: Project | null;
let accessDenied: boolean;
let runCount: number;
let htmlResponseReturned: ReturnType<typeof deferredGate>;
let delayedMdPutResponse: ReturnType<typeof deferredGate>;
let reverseHttpCompletion: boolean;
let memoryExtractionReady: boolean;
let firstTerminalReadGate: ReturnType<typeof deferredGate>;
let htmlResponseGate: ReturnType<typeof deferredGate>;
let firstTerminalReadArrived: boolean;
let htmlPostStored: boolean;
let causalOrder: string[];
let messageWrites: ChatMessage[];
const agentNotes = '# Agent notes written successfully during this run';
let homeStorageReads: Array<{ key: string; value: string | null; source: string[] }>;
let setupErrors: string[];
let restoreDiagnostics: (() => void) | undefined;
let onUnhandledRejection: ((event: PromiseRejectionEvent) => void) | undefined;
let sequence = 0;
let project: Project;
let files: ProjectFile[];
let fileContents: Map<string, string>;
let persisted: Map<string, ChatMessage>;
let requests: Array<{ method: string; path: string; role: string | null; names: string[]; clock: number }>;
let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;
let physicalStatus: 'running' | 'failed';
let runStartedAt: number;
let accumulatedText: string;
let eventId: number;
let terminal: boolean;
let ambient: WorkspaceCollabContext;
let member: WorkspaceCollabContext;
let runRequest: { assistantMessageId: string; userMessageId: string } | undefined;
function context(role: 'owner' | 'member'): WorkspaceCollabContext {
return {
workspaceId: 'owner-qa-workspace', workspaceType: 'personal', workspaceMemberId: 'owner-qa-member',
role, memberStatus: 'active', lifecycleState: 'active', billingState: 'active', planId: null,
providerMode: 'platform_credits', seatSummary: buildWorkspaceSeatSummary({ seatLimit: 0, usedSeats: 0 }),
permissions: buildWorkspacePermissions({ role, lifecycleState: 'active' }),
};
}
function strategyTask() {
return {
// A production gate refused this turn: its plan was frozen, the build ran,
// and the output landed in the chat instead of on disk.
activeRunId: `run-${project.id}`, executionMode: 'simple', inputStage: 'production', route: 'full_plan',
outcome: 'blocked', terminal: true, taskExecutionId: `task-${project.id}`,
strategy: { id: 'od-next-strategy', version: '2.0.4', packageHash: 'fixture', snapshotId: 'fixture' },
blockedContext: { reasonCodes: ['od_next_protocol_runtime_state_missing'], visibleText: accumulatedText },
};
}
function frame(event: string, data: Record<string, unknown>, runId?: string) {
const controller = runId ? controllersByRun.get(runId) : streamController;
if (!controller) throw new Error(`The real provider has not opened the HTTP SSE response for ${runId ?? 'the initial run'}`);
controller.enqueue(new TextEncoder().encode(`id: ${++eventId}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
}
function textFrame(delta: string, runId?: string) {
accumulatedText += delta;
frame('agent', { type: 'text_delta', delta }, runId);
}
function projectView(overrides: Partial<Parameters<typeof ProjectView>[0]> = {}) {
return (<I18nProvider initial="en"><ProjectView
project={project} initialProjectDetail={{ project, resolvedDir: '/workspace/owner-test' }}
routeConversationId={`conv-${project.id}`} routeFileName={null}
config={config} workspaceContextOverride={ambient}
agents={[{ id: 'opencode', name: 'OpenCode', bin: 'opencode', available: true, models: [] }]}
skills={[]} designTemplates={[]} designSystems={[]} daemonLive
onModeChange={vi.fn()} onAgentChange={vi.fn()} onAgentModelChange={vi.fn()}
onRefreshAgents={vi.fn()} onOpenSettings={vi.fn()} onBack={vi.fn()}
onClearPendingPrompt={vi.fn()} onTouchProject={vi.fn()} onProjectChange={vi.fn()} onProjectsRefresh={vi.fn()}
{...overrides}
/></I18nProvider>);
}
function mountProject() {
renderedProject = render(projectView());
return renderedProject;
}
beforeEach(() => {
vi.clearAllMocks();
homeStorageReads = []; setupErrors = [];
controllersByRun = new Map();
observationPhase = 'initial run'; committedOpenRequests = []; openBoundaries = [];
navigationProject = null; accessDenied = false; runCount = 0;
reverseHttpCompletion = false; memoryExtractionReady = false;
htmlResponseReturned = deferredGate(); delayedMdPutResponse = deferredGate();
firstTerminalReadGate = deferredGate(); htmlResponseGate = deferredGate();
firstTerminalReadArrived = false; htmlPostStored = false;
causalOrder = []; messageWrites = [];
const getItem = Storage.prototype.getItem;
const storageReadSpy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(function (this: Storage, key: string) {
const value = getItem.call(this, key);
if (key.startsWith('od:auto-send-')) {
const source = (new Error().stack ?? '').split('\n').filter((line) => line.includes('ProjectView.tsx'));
if (source.length > 0) homeStorageReads.push({ key, value, source });
}
return value;
});
const consoleError = console.error;
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
setupErrors.push(args.map(String).join(' '));
consoleError(...args);
});
restoreDiagnostics = () => { storageReadSpy.mockRestore(); consoleErrorSpy.mockRestore(); };
onUnhandledRejection = (event) => { setupErrors.push(String(event.reason)); };
window.addEventListener('unhandledrejection', onUnhandledRejection);
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
member = context('member'); ambient = member;
project = {
id: `home-artifact-owner-${++sequence}`, name: 'Home artifact owner', workspaceId: member.workspaceId,
skillId: null, designSystemId: null, pendingPrompt: 'Create a fresh artifact.',
metadata: { kind: 'prototype' }, createdAt: Date.now(), updatedAt: Date.now(),
};
files = []; fileContents = new Map(); persisted = new Map(); requests = [];
physicalStatus = 'running'; runStartedAt = Date.now(); accumulatedText = ''; eventId = 0;
terminal = false; runRequest = undefined; streamController = undefined;
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url, 'http://localhost');
const method = init?.method ?? 'GET';
const role = new Headers(init?.headers).get('x-od-workspace-role');
const body = init?.body ? JSON.parse(String(init.body)) : {};
const request = { method, path: url.pathname, role, names: [] as string[], clock: Date.now() };
requests.push(request);
if (url.pathname === '/api/memory/extractions' && method === 'GET') {
const record: MemoryExtractionRecord = {
id: `memory-extraction-${project.id}`, kind: 'llm', startedAt: Date.now(),
finishedAt: Date.now(), phase: 'success', userMessagePreview: 'Create a fresh artifact.',
writtenCount: 1, writtenIds: ['feedback_concise'],
};
return Response.json({ extractions: memoryExtractionReady ? [record] : [] });
}
if (url.pathname === '/api/memory' && method === 'GET') {
const entry: MemoryEntrySummary = { id: 'feedback_concise', name: 'Keep replies concise',
description: 'Prefer concise replies.', type: 'feedback', updatedAt: Date.now() };
return Response.json({ entries: memoryExtractionReady ? [entry] : [] });
}
if (url.pathname === '/api/projects' && method === 'POST') {
return Response.json({ project, conversationId: `conv-${project.id}` });
}
if (navigationProject && url.pathname.startsWith(`/api/projects/${navigationProject.id}`)) {
if (url.pathname.endsWith('/workspace-scope')) return Response.json({ scope: { kind: 'personal',
projectId: navigationProject.id, workspaceId: member.workspaceId, visibility: 'personal', context: member } });
if (url.pathname.endsWith('/collab/status')) return Response.json({ syncState: 'local_only',
ownerMemberId: member.workspaceMemberId, publishedVersion: null, localVersion: null });
if (url.pathname.endsWith('/conversations') && method === 'GET') return Response.json({ conversations: [{
id: `conv-${navigationProject.id}`, projectId: navigationProject.id, title: null, sessionMode: 'design',
createdAt: runStartedAt, updatedAt: runStartedAt }] });
if (url.pathname.endsWith('/messages') && method === 'GET') return Response.json({ messages: [] });
if (url.pathname.endsWith('/files') && method === 'GET') return Response.json({ files: [] });
if (url.pathname === `/api/projects/${navigationProject.id}`) return Response.json({ project: navigationProject, resolvedDir: '/workspace/other-project' });
if (method === 'PUT' || method === 'POST') {
throw new Error(`An old run must not write into the destination project: ${method} ${url.pathname}`);
}
}
if (url.pathname === `/api/projects/${project.id}/workspace-scope`) {
if (accessDenied) return Response.json({ error: 'Forbidden', code: 'FORBIDDEN' }, { status: 403 });
return Response.json({ scope: { kind: 'personal', projectId: project.id, workspaceId: member.workspaceId, visibility: 'personal', context: member } });
}
if (url.pathname === `/api/projects/${project.id}/collab/status`) {
return Response.json({ syncState: 'local_only', ownerMemberId: member.workspaceMemberId, publishedVersion: null, localVersion: null });
}
if (url.pathname === `/api/projects/${project.id}/files`) {
if (method === 'POST') {
const name: string = body.name;
const content: string = body.content;
expect(name === 'document.md' || name === 'agent-output.html').toBe(true);
if (name.endsWith('.html')) expect(content).toBe(html);
const file: ProjectFile = {
name, path: name, kind: name.endsWith('.html') ? 'html' : 'text',
mime: name.endsWith('.html') ? 'text/html' : 'text/markdown',
size: new TextEncoder().encode(content).length, mtime: Date.now(),
...(body.artifactManifest ? { artifactManifest: body.artifactManifest } : {}),
};
files = [...files.filter((entry) => entry.name !== name), file];
fileContents.set(name, content); request.names = [name];
if (name === 'agent-output.html') {
htmlPostStored = true;
causalOrder.push('html-post-stored');
// The server has committed the file; the response body may arrive later.
// Keep the real registry mutation/invalidation waiting on this HTTP response.
if (!reverseHttpCompletion) await htmlResponseGate.promise;
causalOrder.push('html-post-response-released');
htmlResponseReturned.release();
}
return Response.json({ file });
}
if (terminal && !firstTerminalReadArrived) {
firstTerminalReadArrived = true;
causalOrder.push('terminal-files-get-arrived');
await firstTerminalReadGate.promise;
causalOrder.push('terminal-files-get-released');
}
request.names = files.map((file) => file.name);
return Response.json({ files });
}
if (url.pathname === `/api/projects/${project.id}/conversations` && method === 'GET') {
return Response.json({ conversations: [{ id: `conv-${project.id}`, projectId: project.id,
title: null, sessionMode: 'design', createdAt: runStartedAt, updatedAt: runStartedAt }, {
id: `other-conv-${project.id}`, projectId: project.id, title: 'Another conversation',
sessionMode: 'design', createdAt: runStartedAt, updatedAt: runStartedAt }] });
}
if (url.pathname.endsWith('/messages') && method === 'GET') {
return Response.json({ messages: url.pathname.includes(`/conversations/other-conv-${project.id}/`)
? [] : [...persisted.values()] });
}
if (url.pathname.includes('/messages/') && method === 'PUT') {
const incoming = body as ChatMessage;
// Real conversations.ts preserves the daemon's physical run status even
// when the web reports the blocked logical verdict as failed. Retain
// client-produced metadata; this store is an HTTP fixture, not SQLite.
const saved: ChatMessage = incoming.role === 'assistant' && incoming.runId
? { ...incoming,
...(incoming.runId === `run-${project.id}`
? { createdAt: runStartedAt, startedAt: runStartedAt,
preTurnFileNames: [], runStatus: physicalStatus,
...(terminal ? { endedAt: runStartedAt + 1000 } : {}) }
: { runStatus: 'running' as const }) }
: { ...incoming, createdAt: incoming.createdAt ?? runStartedAt };
persisted.set(saved.id, structuredClone(saved));
messageWrites.push(structuredClone(saved));
if (saved.role === 'assistant' && saved.producedFiles?.some((file) => file.name === 'agent-notes.md')
&& !saved.producedFiles.some((file) => file.name === 'agent-output.html')) {
causalOrder.push('assistant-accepted-existing-agent-notes');
if (reverseHttpCompletion) {
// The daemon has accepted the PUT. Only its response is delayed,
// never rewrite the server row later using a stale snapshot.
await delayedMdPutResponse.promise;
causalOrder.push('earlier-md-put-response-released');
}
}
return Response.json({ message: saved });
}
if (url.pathname === '/api/runs' && method === 'POST') {
runRequest = body;
runCount += 1;
return Response.json({ runId: runCount === 1 ? `run-${project.id}` : `run-${project.id}-${runCount}` }, { status: 202 });
}
if (url.pathname.startsWith(`/api/runs/run-${project.id}`) && url.pathname.endsWith('/events')) {
const responseRunId = url.pathname.split('/').at(-2);
if (!responseRunId) throw new Error('SSE endpoint must contain its physical run ID');
return new Response(new ReadableStream<Uint8Array>({ start(controller) {
streamController = controller;
controllersByRun.set(responseRunId, controller);
} }), { headers: { 'Content-Type': 'text/event-stream' } });
}
if (url.pathname.startsWith(`/api/runs/run-${project.id}-`)) return Response.json({
runId: url.pathname.split('/').at(-1), status: 'running', createdAt: runStartedAt + 2000, updatedAt: runStartedAt + 2000 });
if (url.pathname === `/api/runs/run-${project.id}`) {
return Response.json({ runId: `run-${project.id}`, status: physicalStatus, createdAt: runStartedAt,
updatedAt: runStartedAt + 1000, artifactCount: 0, artifactPaths: [], deliverableValid: false,
...(terminal ? { strategyTask: strategyTask() } : {}) });
}
if (url.pathname === `/api/projects/${project.id}`) return Response.json({ project, resolvedDir: '/workspace/owner-test' });
return Response.json({});
}));
});
afterEach(async () => {
// Release actual HTTP waiters even when a reachability/behavior assertion fails.
firstTerminalReadGate.release(); htmlResponseGate.release(); delayedMdPutResponse.release();
await act(async () => { await Promise.resolve(); });
cleanup();
for (const controller of controllersByRun.values()) {
try { controller.close(); } catch { /* already terminal */ }
}
window.sessionStorage.clear();
restoreDiagnostics?.();
if (onUnhandledRejection) window.removeEventListener('unhandledrejection', onUnhandledRejection);
vi.useRealTimers(); vi.unstubAllGlobals();
});
async function reachPendingArtifactRecovery() {
const created = await createProject({ id: project.id, name: project.name, skillId: null,
designSystemId: null, pendingPrompt: project.pendingPrompt, workspaceContext: ambient });
expect(created.project.id).toBe(project.id);
window.sessionStorage.setItem(`od:auto-send-first:${project.id}`, '1');
window.sessionStorage.setItem(`od:auto-send-prompt:${project.id}`, project.pendingPrompt!);
expect(window.sessionStorage.getItem(`od:auto-send-first:${project.id}`)).toBe('1');
mountProject();
await waitFor(() => {
expect(streamViaDaemon, JSON.stringify({
setup: screen.getByTestId('home-setup-state').textContent,
flag: window.sessionStorage.getItem(`od:auto-send-first:${project.id}`),
homeStorageReads, setupErrors,
requests: requests.map(({ method, path, role }) => ({ method, path, role })),
})).toHaveBeenCalledTimes(1);
expect(streamController).toBeDefined();
expect(runRequest?.assistantMessageId).toMatch(/^home-auto-send-.*-assistant$/);
expect(requests.some((r) => r.path.endsWith('/files') && r.method === 'GET' && r.role === 'member')).toBe(true);
});
await act(async () => { frame('start', { bin: 'opencode' }); textFrame('Owner QA is active.\n'); });
// A fresh Home project has the actual Design Files empty-state action.
// Opening the plus menu as well would create a second New document button.
const newDocument = await screen.findByTestId('design-files-empty-new-document');
expect(newDocument).toHaveAccessibleName('New document');
fireEvent.click(newDocument);
const editor = await screen.findByRole('textbox', { name: /markdown editor/i });
await waitFor(() => expect(editor).toHaveValue(fileContents.get('document.md')));
vi.useFakeTimers();
try {
fireEvent.change(editor, { target: { value: manualText } });
await act(async () => { await vi.advanceTimersByTimeAsync(700); });
} finally { vi.useRealTimers(); }
await waitFor(() => expect(fileContents.get('document.md')).toBe(manualText));
expect(files.map((file) => file.name)).toEqual(['document.md']);
expect(physicalStatus).toBe('running');
// Freeze the real cache TTL clock while timers are advanced explicitly;
// wall-clock CI/React slowness cannot turn this into an accidental cache miss.
vi.useFakeTimers();
await act(async () => {
textFrame(artifact.slice(0, artifact.indexOf('<p>')));
textFrame(artifact.slice(artifact.indexOf('<p>')));
textFrame('\nOwner QA finished.');
});
// The inline HTML is still text only. A different output is now written
// by the simulated daemon-side Agent, after the real run and manual save.
expect(files.map((file) => file.name)).toEqual(['document.md']);
await act(async () => {
frame('agent', { type: 'tool_use', id: 'agent-notes-write', name: 'Write',
input: { file_path: '/workspace/owner-test/agent-notes.md', content: agentNotes } });
files.push({ name: 'agent-notes.md', path: 'agent-notes.md', kind: 'text',
mime: 'text/markdown', size: new TextEncoder().encode(agentNotes).length, mtime: Date.now() });
fileContents.set('agent-notes.md', agentNotes);
frame('agent', { type: 'tool_result', toolUseId: 'agent-notes-write', content: 'Saved', isError: false });
});
expect(fileContents.get('agent-output.html')).toBeUndefined();
await act(async () => {
// The process exits non-zero, so the turn fails and the recovery path
// below runs. A clean exit would keep the turn Done whatever the task
// verdict says, and this suite is about what recovery does for a failed
// turn.
physicalStatus = 'failed'; terminal = true;
frame('end', { code: 1, signal: null, status: 'failed', artifactCount: 0,
artifactPaths: [], strategyTask: strategyTask() });
streamController?.close();
});
await vi.waitFor(() => expect(firstTerminalReadArrived, 'terminal production GET must be reached').toBe(true));
expect(htmlPostStored).toBe(false);
// Recovery's pre-POST read shares this still-pending scope GET. Release it
// before waiting for HTML persistence; reversing that order would deadlock.
await act(async () => { firstTerminalReadGate.release(); await Promise.resolve(); });
await vi.waitFor(() => expect(htmlPostStored, JSON.stringify({ phase: 'recovery POST reachability', causalOrder })).toBe(true));
await vi.waitFor(() => expect(messageWrites.some((message) => message.id === runRequest?.assistantMessageId
&& message.producedFiles?.some((file) => file.name === 'agent-notes.md')
&& !message.producedFiles.some((file) => file.name === 'agent-output.html')),
JSON.stringify({ phase: 'error finalizer accepted existing Agent file', causalOrder })).toBe(true));
expect(fileContents.get('agent-output.html')).toBe(html);
expect(fileContents.get('agent-notes.md')).toBe(agentNotes);
return { oldAssistantId: runRequest!.assistantMessageId, oldUserId: runRequest!.userMessageId,
oldRunId: `run-${project.id}` };
}
function markOpenBoundary(phase: string) {
observationPhase = phase;
openBoundaries.push({ phase, committedCount: committedOpenRequests.length,
workspace: screen.queryByTestId('workspace-open-request')?.textContent ?? null,
view: screen.queryByTestId('home-setup-state')?.textContent ?? null });
}
async function releaseOldArtifactResponse() {
markOpenBoundary('before releasing old HTML response');
await act(async () => {
htmlResponseGate.release();
await htmlResponseReturned.promise;
});
markOpenBoundary('after old HTML response and React commit');
}
// This guard uses the production useMemoryWrittenCard polling/append/save path.
// The fixture only supplies the public extraction and memory HTTP responses;
// it never inserts a host message into React state or the simulated message store.
describe('Artifact recovery with a later host-authored memory message', () => {
it('keeps the original physical run outputs when the host persists its auxiliary card during a pending artifact response', async () => {
const { oldAssistantId, oldUserId, oldRunId } = await reachPendingArtifactRecovery();
expect(causalOrder).not.toContain('html-post-response-released');
memoryExtractionReady = true;
// The actual hook polls every 3000ms after the turn ends. Advancing its
// real interval is independent of the still-unreleased artifact HTTP gate.
await act(async () => { await vi.advanceTimersByTimeAsync(3000); });
await vi.waitFor(() => expect(messageWrites.some((message) =>
message.role === 'assistant' && message.id !== oldAssistantId
&& message.content.includes('Keep replies concise')),
JSON.stringify({ causalOrder, requests: requests.map(({ method, path }) => ({ method, path })) })).toBe(true));
const hostMessage = messageWrites.find((message) => message.role === 'assistant'
&& message.id !== oldAssistantId && message.content.includes('Keep replies concise'));
if (!hostMessage) throw new Error('The production memory poll must append and persist its host message');
expect(hostMessage).toMatchObject({ role: 'assistant', agentId: 'opencode',
events: [{ kind: 'text', text: hostMessage.content }], createdAt: expect.any(Number) });
for (const key of ['runId', 'runStatus', 'startedAt', 'endedAt'] as const) {
expect(hostMessage[key]).toBeUndefined();
}
expect(hostMessage.content).toContain('<od-card type="memory-applied">');
expect(screen.getByTestId('assistant-produced-files').textContent).toContain(hostMessage.id);
expect(requests.some((request) => request.method === 'GET' && request.path === '/api/memory/extractions')).toBe(true);
expect(requests.some((request) => request.method === 'GET' && request.path === '/api/memory')).toBe(true);
expect(causalOrder).not.toContain('html-post-response-released');
expect(runCount).toBe(1);
expect(persisted.get(oldAssistantId)?.runId).toBe(oldRunId);
expect(persisted.get(oldUserId)?.role).toBe('user');
await releaseOldArtifactResponse();
await vi.waitFor(() => expect(persisted.get(oldAssistantId)?.producedFiles?.map((file) => file.name),
JSON.stringify({ causalOrder, oldAssistant: persisted.get(oldAssistantId), hostMessage }))
.toEqual(expect.arrayContaining(['agent-notes.md', 'agent-output.html'])));
expect(persisted.get(oldAssistantId)?.runId).toBe(oldRunId);
expect(persisted.get(hostMessage.id)?.content).toBe(hostMessage.content);
expect(persisted.get(hostMessage.id)?.producedFiles ?? []).toEqual([]);
expect(runCount).toBe(1);
expect(streamViaDaemon).toHaveBeenCalledTimes(1);
expect(fileContents.get('agent-output.html')).toBe(html);
expect(fileContents.get('agent-notes.md')).toBe(agentNotes);
});
});