Files
nexu-io--open-design/apps/web/tests/components/ProjectView.run-cleanup.test.tsx
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

4865 lines
175 KiB
TypeScript

// @vitest-environment jsdom
import type { ComponentProps } from 'react';
import { act, cleanup, render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
ProjectView,
clearStreamingConversationMarker,
finalizeActiveAssistantMessagesOnStop,
findExistingArtifactProjectFile,
findExistingNonHtmlArtifactProjectFile,
findSameTurnNonHtmlWriteForRecoveredArtifact,
hasRecoverableArtifactMessage,
resolveRetryTarget,
resolveSucceededRunStatus,
selectPrimaryProjectFile,
shouldClearActiveRunRefs,
} from '../../src/components/ProjectView';
import type { Artifact, ChatMessage, ProjectFile } from '../../src/types';
const listConversations = vi.fn();
const listMessages = vi.fn();
const fetchPreviewComments = vi.fn();
const loadTabs = vi.fn();
const fetchProjectFiles = vi.fn();
const fetchProjectDesignSystemPackageAudit = vi.fn();
const fetchLiveArtifacts = vi.fn();
const fetchSkill = vi.fn();
const fetchDesignSystem = vi.fn();
const getTemplate = vi.fn();
const fetchChatRunStatus = vi.fn();
const listActiveChatRuns = vi.fn();
const listProjectRuns = vi.fn();
const reattachDaemonRun = vi.fn();
const streamViaDaemon = vi.fn();
const saveMessage = vi.fn();
const createConversation = vi.fn();
const patchConversation = vi.fn();
const patchProject = vi.fn();
const patchPreviewCommentStatus = vi.fn();
const saveTabs = vi.fn();
const writeProjectTextFile = vi.fn();
const cancelBrandExtraction = vi.fn();
const originalFetch = globalThis.fetch;
const replayArtifact: Artifact = {
identifier: 'real-daemon-smoke',
artifactType: 'text/html',
title: 'Real Daemon Smoke',
html: '<!doctype html><html><body><h1>Real Daemon Smoke</h1></body></html>',
};
function artifactProjectFile(name: string, mtime: number): ProjectFile {
return {
artifactManifest: {
entry: name,
exports: ['html'],
kind: 'html',
metadata: {
artifactType: 'text/html',
identifier: 'real-daemon-smoke',
inferred: false,
},
renderer: 'html',
title: 'Real Daemon Smoke',
version: 1,
},
kind: 'html',
mime: 'text/html',
mtime,
name,
size: 100,
};
}
function projectFile(
name: string,
kind: ProjectFile['kind'],
mtime: number,
artifactManifest?: ProjectFile['artifactManifest'],
): ProjectFile {
return {
artifactManifest,
kind,
mime: kind === 'html' ? 'text/html' : 'application/octet-stream',
mtime,
name,
size: 100,
};
}
async function createGenericDisconnectError() {
const { createGenericDaemonDisconnectError } = await import('../../src/providers/daemon');
return createGenericDaemonDisconnectError();
}
async function createCodeOnlyGenericDisconnectError(message = 'code-only generic disconnect') {
const { GENERIC_DAEMON_DISCONNECT_CODE } = await import('../../src/providers/daemon');
const error = new Error(message) as Error & { code?: string };
error.code = GENERIC_DAEMON_DISCONNECT_CODE;
return error;
}
vi.mock('../../src/i18n', () => ({
useI18n: () => ({
locale: 'en',
setLocale: () => undefined,
t: (value: string) => value,
}),
useT: () => ((value: string) => value),
}));
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,
fetchChatRunStatus: (...args: unknown[]) => fetchChatRunStatus(...args),
listActiveChatRuns: (...args: unknown[]) => listActiveChatRuns(...args),
listProjectRuns: (...args: unknown[]) => listProjectRuns(...args),
reattachDaemonRun: (...args: unknown[]) => reattachDaemonRun(...args),
streamViaDaemon: (...args: unknown[]) => streamViaDaemon(...args),
};
});
vi.mock('../../src/providers/registry', () => ({
deletePreviewComment: vi.fn(),
fetchPreviewComments: (...args: unknown[]) => fetchPreviewComments(...args),
fetchDesignSystem: (...args: unknown[]) => fetchDesignSystem(...args),
fetchProjectDesignSystemPackageAudit: (...args: unknown[]) => fetchProjectDesignSystemPackageAudit(...args),
fetchLiveArtifacts: (...args: unknown[]) => fetchLiveArtifacts(...args),
fetchProjectFiles: (...args: unknown[]) => fetchProjectFiles(...args),
fetchSkill: (...args: unknown[]) => fetchSkill(...args),
patchPreviewCommentStatus: (...args: unknown[]) => patchPreviewCommentStatus(...args),
upsertPreviewComment: vi.fn(),
writeProjectTextFile: (...args: unknown[]) => writeProjectTextFile(...args),
}));
vi.mock('../../src/providers/project-events', () => ({
useProjectFileEvents: vi.fn(),
}));
vi.mock('../../src/runtime/brands', async () => {
const actual = await vi.importActual<typeof import('../../src/runtime/brands')>('../../src/runtime/brands');
return {
...actual,
cancelBrandExtraction: (...args: unknown[]) => cancelBrandExtraction(...args),
};
});
vi.mock('../../src/router', () => ({
navigate: vi.fn(),
}));
vi.mock('../../src/state/projects', () => ({
createConversation: (...args: unknown[]) => createConversation(...args),
deleteConversation: vi.fn(),
getTemplate: (...args: unknown[]) => getTemplate(...args),
listConversations: (...args: unknown[]) => listConversations(...args),
listMessages: (...args: unknown[]) => listMessages(...args),
loadTabs: (...args: unknown[]) => loadTabs(...args),
patchConversation: (...args: unknown[]) => patchConversation(...args),
patchProject: (...args: unknown[]) => patchProject(...args),
saveMessage: (...args: unknown[]) => saveMessage(...args),
saveTabs: (...args: unknown[]) => saveTabs(...args),
cacheTabsLocally: (_projectId: string, state: unknown) => state,
persistTabsToDaemonNow: vi.fn(),
}));
vi.mock('../../src/components/AppChromeHeader', () => ({
AppChromeHeader: () => null,
}));
vi.mock('../../src/components/AvatarMenu', () => ({
AvatarMenu: () => null,
}));
const chatPaneSpy = vi.fn();
vi.mock('../../src/components/ChatPane', () => ({
ChatPane: (props: Record<string, unknown>) => {
chatPaneSpy(props);
return (
<div className="pane">
<div className={`chat-log-wrap${props.chatLogTray ? ' has-chat-log-tray' : ''}`}>
{props.chatLogTray as ComponentProps<'div'>['children']}
</div>
</div>
);
},
}));
const fileWorkspaceSpy = vi.fn();
vi.mock('../../src/components/FileWorkspace', () => ({
DESIGN_SYSTEM_TAB: '__design_system__',
FileWorkspace: (props: Record<string, unknown>) => {
fileWorkspaceSpy(props);
return null;
},
}));
vi.mock('../../src/components/Loading', () => ({
CenteredLoader: () => null,
}));
async function waitForReadyChatPaneProps() {
await waitFor(() => {
expect(chatPaneSpy).toHaveBeenCalled();
expect(chatPaneSpy.mock.calls.at(-1)?.[0]?.sendDisabled).toBe(false);
});
return chatPaneSpy.mock.calls.at(-1)?.[0] as {
onBrandBrowserAssistConfirm?: (card: {
brandId: string;
browserTabId?: string;
reason?: string;
url?: string;
}) => Promise<{ ok: boolean; action?: string; message?: string } | void> | { ok: boolean; action?: string; message?: string } | void;
onSend?: (prompt: string, attachments: unknown[], comments: unknown[]) => Promise<void>;
initialDraft?: string;
};
}
describe('terminal replay artifact recovery', () => {
it('only reuses existing artifacts created at or after the current run started', () => {
const runCreatedAt = 1_000;
const stale = artifactProjectFile('real-daemon-smoke.html', runCreatedAt - 1);
const current = artifactProjectFile('real-daemon-smoke-2.html', runCreatedAt + 1);
expect(findExistingArtifactProjectFile(replayArtifact, [stale], { minMtime: runCreatedAt }))
.toBeNull();
expect(findExistingArtifactProjectFile(replayArtifact, [stale, current], { minMtime: runCreatedAt }))
.toBe(current);
});
it('only reuses html pointer targets created at or after the current run started', () => {
const runCreatedAt = 1_000;
const pointerArtifact: Artifact = {
identifier: 'real-daemon-smoke',
artifactType: 'text/html',
title: 'Real Daemon Smoke',
html: 'See index.html',
};
const staleTarget = projectFile('index.html', 'html', runCreatedAt - 1);
const currentTarget = projectFile('index.html', 'html', runCreatedAt + 1);
expect(
findExistingArtifactProjectFile(
pointerArtifact,
[staleTarget],
{ minMtime: runCreatedAt },
),
).toBeNull();
expect(
findExistingArtifactProjectFile(
pointerArtifact,
[staleTarget, currentTarget],
{ minMtime: runCreatedAt },
),
).toBe(currentTarget);
});
it('reuses same-turn non-html artifact files with their declared extension after content matches', async () => {
const cssArtifact: Artifact = {
identifier: 'theme',
artifactType: 'text/css',
title: 'Theme',
html: 'body { color: red; }',
};
const cssFile = projectFile('theme.css', 'code', 1_000);
expect(findExistingArtifactProjectFile(cssArtifact, [cssFile])).toBeNull();
expect(findExistingNonHtmlArtifactProjectFile(cssArtifact, [cssFile])).toBeNull();
await expect(
findSameTurnNonHtmlWriteForRecoveredArtifact({
artifact: cssArtifact,
producedFiles: [cssFile],
readProjectText: async () => 'body { color: red; }',
}),
).resolves.toBe(cssFile);
});
it('reuses same-turn non-html artifact files with collision suffixes after content matches', async () => {
const cssArtifact: Artifact = {
identifier: '',
artifactType: 'text/css',
title: 'Theme',
html: 'body { color: red; }',
};
const cssFile = projectFile('theme-2.css', 'code', 1_000);
expect(findExistingArtifactProjectFile(cssArtifact, [cssFile])).toBeNull();
expect(findExistingNonHtmlArtifactProjectFile(cssArtifact, [cssFile])).toBeNull();
await expect(
findSameTurnNonHtmlWriteForRecoveredArtifact({
artifact: cssArtifact,
producedFiles: [cssFile],
readProjectText: async () => 'body { color: red; }',
}),
).resolves.toBe(cssFile);
});
it('does not reuse same-turn non-html filename matches when contents differ', async () => {
const cssArtifact: Artifact = {
identifier: '',
artifactType: 'text/css',
title: 'Theme',
html: 'body { color: red; }',
};
const cssFile = projectFile('theme.css', 'code', 1_000);
await expect(
findSameTurnNonHtmlWriteForRecoveredArtifact({
artifact: cssArtifact,
producedFiles: [cssFile],
readProjectText: async () => 'body { color: blue; }',
}),
).resolves.toBeNull();
});
it('reuses same-turn non-html collision suffixes only when contents match', async () => {
const cssArtifact: Artifact = {
identifier: '',
artifactType: 'text/css',
title: 'Theme',
html: 'body { color: red; }',
};
const cssFile = projectFile('theme-2.css', 'code', 1_000);
await expect(
findSameTurnNonHtmlWriteForRecoveredArtifact({
artifact: cssArtifact,
producedFiles: [cssFile],
readProjectText: async () => 'body { color: red; }',
}),
).resolves.toBe(cssFile);
});
it('does not reuse same-turn html files before checking recovered content', () => {
const htmlArtifact: Artifact = {
identifier: 'landing',
artifactType: 'text/html',
title: 'Landing',
html: '<!doctype html><html><body>final artifact</body></html>',
};
const sameNameHtmlFile = projectFile('landing.html', 'html', 1_000);
expect(findExistingArtifactProjectFile(htmlArtifact, [sameNameHtmlFile]))
.toBe(sameNameHtmlFile);
expect(findExistingNonHtmlArtifactProjectFile(htmlArtifact, [sameNameHtmlFile]))
.toBeNull();
});
it('treats standalone HTML terminal assistant messages as recoverable', () => {
expect(
hasRecoverableArtifactMessage({
id: 'msg-standalone-html',
role: 'assistant',
content:
'<!doctype html><html><head><title>Standalone</title></head>' +
'<body><h1>Standalone</h1><p>Recovered artifact output.</p></body></html>',
createdAt: Date.now(),
runId: 'run-standalone-html',
runStatus: 'succeeded',
producedFiles: [],
}),
).toBe(true);
});
});
describe('selectPrimaryProjectFile', () => {
it('prefers explicit primary manifests over newer renderable files', () => {
const newer = projectFile('preview.html', 'html', 2_000);
const primary = projectFile('index.html', 'html', 1_000, {
entry: 'index.html',
exports: ['html'],
kind: 'html',
primary: true,
renderer: 'html',
title: 'Index',
version: 1,
});
expect(selectPrimaryProjectFile([newer, primary])).toBe(primary);
});
it('ignores sidecar manifest files when choosing a fallback', () => {
const sidecar = projectFile('index.html.artifact.json', 'text', 2_000);
const html = projectFile('index.html', 'html', 1_000);
expect(selectPrimaryProjectFile([sidecar, html])).toBe(html);
});
});
describe('retry target resolution', () => {
const userMessage: ChatMessage = {
id: 'user-1',
role: 'user',
content: 'Create a login page',
createdAt: 1,
};
const failedAssistant: ChatMessage = {
id: 'assistant-1',
role: 'assistant',
content: 'Generation failed',
createdAt: 2,
runStatus: 'failed',
};
it('returns the prior transcript and original user turn for the last failed assistant', () => {
const systemContext: ChatMessage = {
id: 'assistant-0',
role: 'assistant',
content: 'Earlier result',
createdAt: 0,
runStatus: 'succeeded',
};
expect(resolveRetryTarget([systemContext, userMessage, failedAssistant], failedAssistant.id)).toEqual({
failedAssistant,
userMsg: userMessage,
priorMessages: [systemContext],
preservedAttempts: [failedAssistant],
});
});
it('keeps earlier failed retry attempts visible while reusing the original user turn', () => {
const firstFailure: ChatMessage = {
...failedAssistant,
id: 'assistant-1',
content: 'First attempt produced partial output',
events: [{ kind: 'text', text: 'thinking before failure' }],
producedFiles: [
{
name: 'partial.html',
kind: 'html',
mime: 'text/html',
mtime: 1,
size: 100,
},
],
};
const secondFailure: ChatMessage = {
...failedAssistant,
id: 'assistant-2',
content: 'Retry failed too',
};
expect(resolveRetryTarget([userMessage, firstFailure, secondFailure], secondFailure.id)).toEqual({
failedAssistant: secondFailure,
userMsg: userMessage,
priorMessages: [],
preservedAttempts: [firstFailure, secondFailure],
});
});
it('rejects non-terminal, non-last, or non-user-preceded retry targets', () => {
expect(resolveRetryTarget([userMessage, { ...failedAssistant, runStatus: 'running' }], failedAssistant.id))
.toBeNull();
expect(resolveRetryTarget([userMessage, failedAssistant, { ...userMessage, id: 'user-2' }], failedAssistant.id))
.toBeNull();
expect(resolveRetryTarget([failedAssistant], failedAssistant.id)).toBeNull();
});
});
describe('ProjectView daemon cleanup', () => {
beforeEach(() => {
listProjectRuns.mockResolvedValue([]);
cancelBrandExtraction.mockResolvedValue({ ok: true, status: 'failed' });
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.useRealTimers();
globalThis.fetch = originalFetch;
window.sessionStorage.clear();
});
it('does not abort daemon cancel reattach controllers during unmount cleanup', async () => {
let seenCancelSignal: { aborted: boolean } | null = null;
let seenSignal: { aborted: boolean } | null = null;
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-1',
role: 'assistant',
content: 'working',
createdAt: Date.now(),
runId: 'run-1',
runStatus: 'running',
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
fetchChatRunStatus.mockResolvedValue({
id: 'run-1',
status: 'running',
createdAt: Date.now(),
updatedAt: Date.now(),
exitCode: null,
signal: null,
});
listActiveChatRuns.mockResolvedValue([]);
reattachDaemonRun.mockImplementation(async (options: { signal: { aborted: boolean }; cancelSignal?: { aborted: boolean } }) => {
seenSignal = options.signal;
seenCancelSignal = options.cancelSignal ?? null;
return new Promise<void>(() => {});
});
const view = render(
<ProjectView
project={{ id: 'project-1', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => expect(reattachDaemonRun).toHaveBeenCalledTimes(1));
expect(seenSignal).not.toBeNull();
expect(seenCancelSignal).not.toBeNull();
view.unmount();
if (!seenSignal || !seenCancelSignal) throw new Error('Expected reattach signals to be captured');
expect((seenSignal as any).aborted).toBe(true);
expect((seenCancelSignal as any).aborted).toBe(false);
});
it('marks successful daemon completion as succeeded even before runId reaches message state', () => {
expect(resolveSucceededRunStatus('running')).toBe('succeeded');
expect(resolveSucceededRunStatus('queued')).toBe('succeeded');
expect(resolveSucceededRunStatus(undefined)).toBe('succeeded');
expect(resolveSucceededRunStatus('failed')).toBe('failed');
expect(resolveSucceededRunStatus('canceled')).toBe('canceled');
});
// Regression: a phantom 'running' row in DB (no runId, no matching active
// daemon run) used to stick the UI on "Waiting for first output —
// Working 24m+" forever. The reattach loop now self-heals by marking
// such a message as failed so the composer is interactive again.
//
// TODO(reconcile): re-add the three unit tests for
// finalizeActiveAssistantMessagesOnStop / clearStreamingConversationMarker /
// shouldClearActiveRunRefs that landed on main alongside this hunk —
// they were dropped at merge because their bodies sat on top of HEAD's
// self-heals fixture and the test body that follows uses the
// `startedAt` variable declared only in this `it()` opener.
it('self-heals running messages with no runId when daemon has no active run', async () => {
const startedAt = Date.now();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-phantom',
role: 'assistant',
content: '',
createdAt: startedAt,
startedAt,
runStatus: 'running',
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
render(
<ProjectView
project={{ id: 'project-1', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => expect(listActiveChatRuns).toHaveBeenCalled());
await waitFor(() => {
const failedCall = saveMessage.mock.calls.find(
(call) =>
call[2]?.id === 'msg-phantom' && call[2]?.runStatus === 'failed',
);
expect(failedCall).toBeTruthy();
});
expect(reattachDaemonRun).not.toHaveBeenCalled();
});
it('persists a delayed daemon run id after switching projects so returning can reattach', async () => {
const projectOne = { id: 'project-1', name: 'Project One', skillId: null, designSystemId: null };
const projectTwo = { id: 'project-2', name: 'Project Two', skillId: null, designSystemId: null };
const messagesByConversation = new Map<string, ChatMessage[]>([
['conv-1', []],
['conv-2', []],
]);
listConversations.mockImplementation(async (projectId: string) => [
projectId === 'project-1'
? { id: 'conv-1', title: 'Conversation 1' }
: { id: 'conv-2', title: 'Conversation 2' },
]);
listMessages.mockImplementation(async (_projectId: string, conversationId: string) =>
messagesByConversation.get(conversationId) ?? [],
);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus.mockResolvedValue({
id: 'run-delayed',
status: 'running',
createdAt: 1,
updatedAt: 1,
exitCode: null,
signal: null,
});
saveMessage.mockImplementation(async (_projectId: string, conversationId: string, message: ChatMessage) => {
const existing = messagesByConversation.get(conversationId) ?? [];
const next = existing.filter((item) => item.id !== message.id);
next.push(message);
messagesByConversation.set(conversationId, next);
return message;
});
reattachDaemonRun.mockImplementation(async () => new Promise<void>(() => {}));
let capturedRunCreated: ((runId: string) => void) | null = null;
let capturedStreamSignal: AbortSignal | null = null;
let capturedCancelSignal: AbortSignal | null = null;
let capturedAssistantMessageId: string | null = null;
streamViaDaemon.mockImplementation(async (options: {
assistantMessageId?: string;
signal: AbortSignal;
cancelSignal?: AbortSignal;
onRunCreated?: (runId: string) => void;
}) => {
capturedRunCreated = options.onRunCreated ?? null;
capturedStreamSignal = options.signal;
capturedCancelSignal = options.cancelSignal ?? null;
capturedAssistantMessageId = options.assistantMessageId ?? null;
return new Promise<void>(() => {});
});
const view = render(
<ProjectView
project={projectOne as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
const sendProps = await waitForReadyChatPaneProps();
await sendProps.onSend!('keep running', [], []);
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
expect(capturedRunCreated).not.toBeNull();
view.rerender(
<ProjectView
project={projectTwo as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => expect((capturedStreamSignal as AbortSignal | null)?.aborted).toBe(true));
expect((capturedCancelSignal as AbortSignal | null)?.aborted).toBe(false);
capturedRunCreated!('run-delayed');
await waitFor(() => {
const persistedAssistant = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-1' &&
call[1] === 'conv-1' &&
call[2]?.id === capturedAssistantMessageId &&
call[2]?.role === 'assistant' &&
call[2]?.runId === 'run-delayed' &&
call[2]?.runStatus === 'queued',
);
expect(persistedAssistant).toBeTruthy();
});
view.rerender(
<ProjectView
project={projectOne as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => {
expect(reattachDaemonRun).toHaveBeenCalledWith(
expect.objectContaining({ runId: 'run-delayed' }),
);
});
});
// Regression: when a project is created via PluginLoopHome with the
// auto-send sessionStorage flag set, ProjectView used to seed
// ChatComposer.initialDraft with project.pendingPrompt. The composer
// latched that seed into local state, then auto-send fired the same
// text as a real user message — leaving the textarea populated while
// the run streamed. The user reported "好像发送了输入框的 query 还
// 没有清除". With the fix, auto-send projects must hand the composer
// an undefined initialDraft so the textarea stays empty; the seed
// still flows through autoSendSeedRef so the prompt is delivered.
it('does not seed composer initialDraft when auto-send sessionStorage flag is set', async () => {
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
streamViaDaemon.mockResolvedValue(undefined);
chatPaneSpy.mockClear();
window.sessionStorage.setItem('od:auto-send-first:project-2', '1');
try {
render(
<ProjectView
project={{
id: 'project-2',
name: 'Project',
skillId: null,
designSystemId: null,
pendingPrompt: 'design a landing page for a coffee shop',
} as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
const seededCall = chatPaneSpy.mock.calls.find(
(call) => call[0]?.initialDraft === 'design a landing page for a coffee shop',
);
expect(seededCall).toBeUndefined();
} finally {
window.sessionStorage.removeItem('od:auto-send-first:project-2');
}
});
it('reloads an empty brand-extraction transcript without auto-sending the fallback prompt', async () => {
const programmaticMessages: ChatMessage[] = [
{
id: 'brand-user-1',
role: 'user',
content: 'Extract a design system from https://refly.ai/.',
createdAt: 1,
},
{
id: 'brand-assistant-1',
role: 'assistant',
content: 'Programmatic design-system extraction started from https://refly.ai/.',
createdAt: 1,
startedAt: 1,
runStatus: 'running',
},
];
listConversations.mockResolvedValue([{ id: 'conv-brand', title: 'Conversation' }]);
listMessages
.mockResolvedValueOnce([])
.mockResolvedValue(programmaticMessages);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: ['brand.html'], activeTabId: 'brand.html' });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
streamViaDaemon.mockResolvedValue(undefined);
window.sessionStorage.setItem('od:auto-send-first:brand-project', '1');
render(
<ProjectView
project={{
id: 'brand-project',
name: 'refly.ai Design System',
skillId: null,
designSystemId: null,
pendingPrompt: 'Extract refly.ai into a design system.',
metadata: {
kind: 'brand',
importedFrom: 'brand-extraction',
brandId: 'refly-ai',
brandSourceUrl: 'https://refly.ai/',
},
createdAt: 1,
updatedAt: 1,
} as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
await waitFor(() => {
expect(chatPaneSpy.mock.calls.at(-1)?.[0]?.messages).toEqual(programmaticMessages);
});
expect(chatPaneSpy.mock.calls.at(-1)?.[0]?.streaming).toBe(true);
expect(chatPaneSpy.mock.calls.at(-1)?.[0]?.sendDisabled).toBe(false);
const latestChatPaneProps = chatPaneSpy.mock.calls.at(-1)?.[0] as {
onStop?: () => void;
};
latestChatPaneProps.onStop?.();
await waitFor(() => expect(cancelBrandExtraction).toHaveBeenCalledWith('refly-ai'));
await waitFor(() => {
expect(saveMessage).toHaveBeenCalledWith(
'brand-project',
'conv-brand',
expect.objectContaining({
id: 'brand-assistant-1',
runStatus: 'canceled',
}),
expect.objectContaining({ telemetryFinalized: true }),
);
});
expect(streamViaDaemon).not.toHaveBeenCalled();
expect(window.sessionStorage.getItem('od:auto-send-first:brand-project')).toBeNull();
});
it('anchors the brand browser-assist download guide between the transcript and composer', async () => {
listConversations.mockResolvedValue([{ id: 'conv-brand', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
streamViaDaemon.mockResolvedValue(undefined);
chatPaneSpy.mockClear();
fileWorkspaceSpy.mockClear();
render(
<ProjectView
project={{
id: 'brand-project',
name: 'Refly Design System',
skillId: null,
designSystemId: null,
metadata: {
kind: 'brand',
importedFrom: 'brand-extraction',
brandId: 'refly-ai',
brandSourceUrl: 'https://refly.ai/',
},
createdAt: 1,
updatedAt: 1,
} as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
const chatProps = await waitForReadyChatPaneProps();
expect(chatProps.onBrandBrowserAssistConfirm).toBeTypeOf('function');
let result: { ok: boolean; action?: string; message?: string } | void = undefined;
await act(async () => {
result = await chatProps.onBrandBrowserAssistConfirm?.({
brandId: 'refly-ai',
browserTabId: 'brand-browser-tab',
reason: 'Cloudflare',
url: 'https://refly.ai/',
});
});
expect(result).toMatchObject({ ok: true, action: 'opened' });
await waitFor(() => {
expect(fileWorkspaceSpy.mock.calls.at(-1)?.[0]?.browserOpenRequest).toMatchObject({
tabId: 'brand-browser-tab',
url: 'https://refly.ai/',
attentionAction: 'download-page',
});
});
const toast = await waitFor(() => {
const node = document.querySelector('.od-toast');
expect(node?.textContent).toContain('chat.brandBrowserAssistDownloadGuideTitle');
return node as HTMLElement;
});
expect(toast.closest('.project-actions-toast-anchor')).toBeTruthy();
expect(toast.closest('.split-chat-slot')).toBeTruthy();
expect(toast.closest('.chat-log-wrap')?.className).toContain('has-chat-log-tray');
});
it('waits for pendingPrompt hydration before consuming an auto-send flag', async () => {
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
streamViaDaemon.mockResolvedValue(undefined);
chatPaneSpy.mockClear();
const onClearPendingPrompt = vi.fn();
window.sessionStorage.setItem('od:auto-send-first:project-hydrating', '1');
const baseProps = {
project: {
id: 'project-hydrating',
name: 'Ramp.com Design System',
skillId: null,
designSystemId: null,
} as never,
routeFileName: null,
config: { mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never,
agents: [{ id: 'agent-1', name: 'OpenCode', models: [] } as never],
skills: [],
designTemplates: [],
designSystems: [],
daemonLive: true,
onModeChange: () => {},
onAgentChange: () => {},
onAgentModelChange: () => {},
onRefreshAgents: () => {},
onOpenSettings: () => {},
onBack: () => {},
onClearPendingPrompt,
onTouchProject: () => {},
onProjectChange: () => {},
onProjectsRefresh: () => {},
} satisfies ComponentProps<typeof ProjectView>;
try {
const view = render(<ProjectView {...baseProps} />);
await waitFor(() => {
expect(chatPaneSpy).toHaveBeenCalled();
expect(chatPaneSpy.mock.calls.at(-1)?.[0]?.sendDisabled).toBe(false);
});
expect(streamViaDaemon).not.toHaveBeenCalled();
expect(onClearPendingPrompt).not.toHaveBeenCalled();
expect(window.sessionStorage.getItem('od:auto-send-first:project-hydrating')).toBe('1');
view.rerender(
<ProjectView
{...baseProps}
project={{
id: 'project-hydrating',
name: 'Ramp.com Design System',
skillId: null,
designSystemId: null,
createdAt: 1,
updatedAt: 1,
pendingPrompt: 'Extract ramp.com into a design system.',
}}
/>,
);
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
expect(onClearPendingPrompt).toHaveBeenCalledTimes(1);
expect(streamViaDaemon.mock.calls[0]?.[0]).toMatchObject({
history: [
expect.objectContaining({
role: 'user',
content: 'Extract ramp.com into a design system.',
}),
],
});
expect(window.sessionStorage.getItem('od:auto-send-first:project-hydrating')).toBeNull();
} finally {
window.sessionStorage.removeItem('od:auto-send-first:project-hydrating');
}
});
it('auto-sends Home-staged design files as first-turn daemon attachments', async () => {
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
streamViaDaemon.mockResolvedValue(undefined);
chatPaneSpy.mockClear();
window.sessionStorage.setItem('od:auto-send-first:project-files', '1');
window.sessionStorage.setItem(
'od:auto-send-attachments:project-files',
JSON.stringify([
{ path: 'brief.pdf', name: 'brief.pdf', kind: 'file', size: 5 },
{ path: 'logo.png', name: 'logo.png', kind: 'image', size: 7 },
]),
);
try {
render(
<ProjectView
project={{
id: 'project-files',
name: 'Project',
skillId: null,
designSystemId: null,
} as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
expect(streamViaDaemon.mock.calls[0]?.[0]).toMatchObject({
attachments: ['brief.pdf', 'logo.png'],
history: [
expect.objectContaining({
role: 'user',
content: '',
attachments: [
{ path: 'brief.pdf', name: 'brief.pdf', kind: 'file', size: 5 },
{ path: 'logo.png', name: 'logo.png', kind: 'image', size: 7 },
],
}),
],
});
expect(window.sessionStorage.getItem('od:auto-send-first:project-files')).toBeNull();
expect(window.sessionStorage.getItem('od:auto-send-attachments:project-files')).toBeNull();
} finally {
window.sessionStorage.removeItem('od:auto-send-first:project-files');
window.sessionStorage.removeItem('od:auto-send-attachments:project-files');
}
});
it('passes Home-staged workspace contexts into the project composer during auto-send', async () => {
const workspaceItems = [
{
id: 'project:reference-a',
kind: 'project',
label: 'Reference A',
title: 'Reference A',
path: 'reference-a',
absolutePath: '/Users/me/reference-a',
},
{
id: 'project:reference-b',
kind: 'project',
label: 'Reference B',
title: 'Reference B',
path: 'reference-b',
absolutePath: '/Users/me/reference-b',
},
];
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
streamViaDaemon.mockResolvedValue(undefined);
chatPaneSpy.mockClear();
window.sessionStorage.setItem('od:auto-send-first:project-context', '1');
window.sessionStorage.setItem(
'od:auto-send-context:project-context',
JSON.stringify({ workspaceItems }),
);
try {
render(
<ProjectView
project={{
id: 'project-context',
name: 'Project',
skillId: null,
designSystemId: null,
pendingPrompt: 'Inspect the reference dir.',
metadata: { kind: 'prototype', linkedDirs: ['/Users/me/reference-a', '/Users/me/reference-b'] },
} as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
const chatProps = await waitForReadyChatPaneProps() as {
initialWorkspaceContexts?: unknown[];
};
expect(chatProps.initialWorkspaceContexts).toEqual(workspaceItems);
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
expect(streamViaDaemon.mock.calls[0]?.[0]).toMatchObject({
history: [
expect.objectContaining({
content: 'Inspect the reference dir.',
runContext: { workspaceItems },
}),
],
});
expect(window.sessionStorage.getItem('od:auto-send-context:project-context')).toBeNull();
} finally {
window.sessionStorage.removeItem('od:auto-send-first:project-context');
window.sessionStorage.removeItem('od:auto-send-context:project-context');
}
});
it('queues board comment attachments while the current daemon run is still busy', async () => {
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
streamViaDaemon.mockImplementation(async () => new Promise<void>(() => {}));
render(
<ProjectView
project={{
id: 'project-comments',
name: 'Project',
skillId: null,
designSystemId: null,
} as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
const chatProps = await waitForReadyChatPaneProps();
await chatProps.onSend!('keep running', [], []);
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
await waitFor(() => {
expect(chatPaneSpy.mock.calls.at(-1)?.[0]?.streaming).toBe(true);
});
const workspaceProps = fileWorkspaceSpy.mock.calls.at(-1)?.[0] as {
onSendBoardCommentAttachments?: (attachments: unknown[]) => Promise<boolean | void>;
};
expect(workspaceProps.onSendBoardCommentAttachments).toBeTruthy();
await workspaceProps.onSendBoardCommentAttachments!([
{
id: 'hero-board-1',
order: 1,
filePath: 'preview.html',
elementId: 'hero',
selector: '[data-od-id="hero"]',
label: 'Hero',
comment: 'Use a warmer accent',
currentText: 'Hero',
pagePosition: { x: 10, y: 20, width: 100, height: 40 },
htmlHint: '<main data-od-id="hero">',
source: 'board-batch',
},
]);
await waitFor(() => {
const latest = chatPaneSpy.mock.calls.at(-1)?.[0] as {
queuedItems?: Array<{
prompt?: string;
commentAttachments?: Array<{
comment: string;
commentContext?: string;
elementId?: string;
}>;
}>;
};
expect(latest.queuedItems).toHaveLength(1);
// Each board comment is queued as its own task: the comment text becomes
// the task prompt while the attachment rides along as element context
// (comment blanked, commentContext === 'query').
expect(latest.queuedItems?.[0]?.prompt).toBe('Use a warmer accent');
const queuedAttachment = latest.queuedItems?.[0]?.commentAttachments?.[0];
expect(queuedAttachment?.elementId).toBe('hero');
expect(queuedAttachment?.commentContext).toBe('query');
});
expect(streamViaDaemon).toHaveBeenCalledTimes(1);
});
it('audits design-system workspace output after first auto-send and seeds a bounded repair prompt', async () => {
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue({
ok: false,
projectPath: '/tmp/ds',
filesInspected: 12,
errors: [{
severity: 'error',
code: 'ui_kit_index_missing_runtime_bootstrap',
message: 'ui_kits/app/index.html must mount the kit.',
path: 'ui_kits/app/index.html',
}],
warnings: [],
});
let streamCallCount = 0;
streamViaDaemon.mockImplementation(async (options: {
handlers: { onDone: () => void };
onRunCreated?: (runId: string) => void;
}) => {
streamCallCount += 1;
options.onRunCreated?.(`run-ds-${streamCallCount}`);
if (streamCallCount === 1) {
options.handlers.onDone();
}
});
chatPaneSpy.mockClear();
window.sessionStorage.setItem('od:auto-send-first:project-ds', '1');
render(
<ProjectView
project={{
id: 'project-ds',
name: 'Cherry Studio Design System',
skillId: null,
designSystemId: 'user:cherry-studio',
pendingPrompt: 'Create this project as a design system.',
metadata: {
importedFrom: 'design-system',
entryFile: 'DESIGN.md',
sourceFileName: 'user:cherry-studio',
},
} as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => expect(fetchProjectDesignSystemPackageAudit).toHaveBeenCalledWith('project-ds'));
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalled());
expect(window.sessionStorage.getItem('od:design-system-audit-auto-repair:project-ds')).toBe('1');
await waitFor(() => {
const repairSeed = chatPaneSpy.mock.calls.find(
(call) => typeof call[0]?.initialDraft === 'string'
&& call[0].initialDraft.includes('Fix the design-system package audit findings below.')
&& call[0].initialDraft.includes('ui_kit_index_missing_runtime_bootstrap'),
);
expect(repairSeed).toBeTruthy();
});
expect(saveMessage.mock.calls.some((call) =>
call[2]?.role === 'assistant'
&& call[2]?.events?.some((event: { kind?: string; label?: string; detail?: string }) =>
event.kind === 'status'
&& event.label === 'audit'
&& event.detail?.includes('Package audit found 1 error'),
),
)).toBe(true);
});
it('does not seed audit repair prompt for manual design-system runs without auto-repair budget', async () => {
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue({
ok: false,
projectPath: '/tmp/ds',
filesInspected: 12,
errors: [{
severity: 'error',
code: 'ui_kit_index_missing_runtime_bootstrap',
message: 'ui_kits/app/index.html must mount the kit.',
path: 'ui_kits/app/index.html',
}],
warnings: [],
});
streamViaDaemon.mockImplementation(async (options: {
handlers: { onDone: () => void };
onRunCreated?: (runId: string) => void;
}) => {
options.onRunCreated?.('run-ds-manual');
options.handlers.onDone();
});
chatPaneSpy.mockClear();
render(
<ProjectView
project={{
id: 'project-ds-manual',
name: 'Manual Design System',
skillId: null,
designSystemId: 'user:manual-ds',
metadata: {
importedFrom: 'design-system',
entryFile: 'DESIGN.md',
sourceFileName: 'user:manual-ds',
},
} as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
const chatProps = await waitForReadyChatPaneProps();
await chatProps.onSend!('Update the design system', [], []);
await waitFor(() => expect(fetchProjectDesignSystemPackageAudit).toHaveBeenCalledWith('project-ds-manual'));
await waitFor(() => {
expect(saveMessage.mock.calls.some((call) =>
call[2]?.role === 'assistant'
&& call[2]?.events?.some((event: { kind?: string; label?: string; detail?: string }) =>
event.kind === 'status'
&& event.label === 'audit'
&& event.detail?.includes('Package audit found 1 error'),
),
)).toBe(true);
});
expect(window.sessionStorage.getItem('od:design-system-audit-auto-repair:project-ds-manual')).toBeNull();
expect(chatPaneSpy.mock.calls.some(
(call) => typeof call[0]?.initialDraft === 'string'
&& call[0].initialDraft.includes('Fix the design-system package audit findings below.'),
)).toBe(false);
});
it('clears design-system auto-repair budget when the first audit passes', async () => {
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue({
ok: true,
projectPath: '/tmp/ds',
filesInspected: 24,
errors: [],
warnings: [],
});
streamViaDaemon.mockImplementation(async (options: {
handlers: { onDone: () => void };
onRunCreated?: (runId: string) => void;
}) => {
options.onRunCreated?.('run-ds-pass');
options.handlers.onDone();
});
chatPaneSpy.mockClear();
window.sessionStorage.setItem('od:auto-send-first:project-ds-pass', '1');
render(
<ProjectView
project={{
id: 'project-ds-pass',
name: 'Passing Design System',
skillId: null,
designSystemId: 'user:passing-ds',
pendingPrompt: 'Create this project as a design system.',
metadata: {
importedFrom: 'design-system',
entryFile: 'DESIGN.md',
sourceFileName: 'user:passing-ds',
},
} as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => expect(fetchProjectDesignSystemPackageAudit).toHaveBeenCalledWith('project-ds-pass'));
expect(streamViaDaemon).toHaveBeenCalledTimes(1);
expect(window.sessionStorage.getItem('od:design-system-audit-auto-repair:project-ds-pass')).toBeNull();
});
// Sister check: without the auto-send flag, the composer should still
// seed from pendingPrompt so the user can edit before manually sending.
it('seeds composer initialDraft with pendingPrompt when auto-send flag is absent', async () => {
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
chatPaneSpy.mockClear();
window.sessionStorage.removeItem('od:auto-send-first:project-3');
render(
<ProjectView
project={{
id: 'project-3',
name: 'Project',
skillId: null,
designSystemId: null,
pendingPrompt: 'design a landing page for a coffee shop',
} as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => expect(chatPaneSpy).toHaveBeenCalled());
// The first render — before activeConversationId resolves — must
// pass the seed through so ChatComposer can populate its draft.
const seedingCall = chatPaneSpy.mock.calls.find(
(call) => call[0]?.initialDraft === 'design a landing page for a coffee shop',
);
expect(seedingCall).toBeTruthy();
});
// Root-cause regression for the "Working 24m+ / Waiting for first output"
// stuck UI. The phantom was created at line `persistMessage(assistantMsg)`
// in handleSend: a daemon assistant row was written to DB with
// runStatus='running' BEFORE POST /api/runs returned a runId. If that POST
// never returned (slow daemon, network blip, component unmount mid-flight),
// the row was orphaned forever with no runId for the reattach loop to
// recover. The fix: persistMessage / persistMessageById / updateMessageById
// all refuse to write a daemon assistant row that is still active without
// a runId. The first DB write for that row only happens once onRunCreated
// pins the daemon's runId onto the message.
it('does not persist an assistant message before POST /api/runs returns a runId', async () => {
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
// streamViaDaemon: capture onRunCreated but never resolve, so the POST
// looks "in-flight" for the rest of the test. This is the exact window
// in which phantom rows used to be written.
let capturedOnRunCreated: ((runId: string) => void) | null = null;
streamViaDaemon.mockImplementation(async (options: { onRunCreated?: (runId: string) => void }) => {
capturedOnRunCreated = options.onRunCreated ?? null;
return new Promise<void>(() => {});
});
chatPaneSpy.mockClear();
render(
<ProjectView
project={{ id: 'project-phantom', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
const sendProps = await waitForReadyChatPaneProps();
expect(sendProps?.onSend).toBeTypeOf('function');
await sendProps!.onSend!('hello world', [], []);
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
// The user message must be persisted immediately — it is committed
// user intent and has no runId concept.
const userSave = saveMessage.mock.calls.find((call) => call[2]?.role === 'user');
expect(userSave?.[2]?.content).toBe('hello world');
// The assistant placeholder must NOT be persisted yet: runStatus
// is 'running' and the daemon has not returned a runId.
const phantomSave = saveMessage.mock.calls.find(
(call) =>
call[2]?.role === 'assistant' &&
call[2]?.runStatus === 'running' &&
!call[2]?.runId,
);
expect(phantomSave).toBeUndefined();
// Now simulate POST /api/runs returning a runId. The assistant row
// transitions to 'queued' with a runId — that's a non-phantom write
// that the guard lets through.
expect(capturedOnRunCreated).not.toBeNull();
capturedOnRunCreated!('run-pinned-xyz');
await waitFor(() => {
const pinnedSave = saveMessage.mock.calls.find(
(call) =>
call[2]?.role === 'assistant' &&
call[2]?.runId === 'run-pinned-xyz' &&
call[2]?.runStatus === 'queued',
);
expect(pinnedSave).toBeTruthy();
});
});
// Companion regression: if the user navigates away (component unmounts)
// BEFORE onRunCreated ever fires, the assistant placeholder must never
// appear in DB. This is the exact failure mode the user reported — the
// PluginLoopHome auto-send fired, the user moved on, and a phantom row
// sat forever in the project's conversation.
it('never persists a phantom assistant row when send aborts before runId', async () => {
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
streamViaDaemon.mockImplementation(async () => {
// Simulate a POST that never returns (network blip, daemon timeout).
return new Promise<void>(() => {});
});
chatPaneSpy.mockClear();
const view = render(
<ProjectView
project={{ id: 'project-aborted', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
const sendProps = await waitForReadyChatPaneProps();
await sendProps!.onSend!('quick send', [], []);
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
view.unmount();
const phantomSave = saveMessage.mock.calls.find(
(call) =>
call[2]?.role === 'assistant' &&
call[2]?.runStatus === 'running' &&
!call[2]?.runId,
);
expect(phantomSave).toBeUndefined();
});
it('persists a daemon assistant row as failed after an AMR auth error returns post-run creation', async () => {
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
streamViaDaemon.mockImplementation(async (options: {
onRunCreated?: (runId: string) => void;
handlers: { onError: (error: Error) => void };
}) => {
options.onRunCreated?.('run-auth-expired');
options.handlers.onError(
new Error('Your authentication token has expired. Please sign in again.'),
);
});
chatPaneSpy.mockClear();
render(
<ProjectView
project={{ id: 'project-auth-expired', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
const sendProps = await waitForReadyChatPaneProps();
await sendProps!.onSend!('retry auth', [], []);
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
await waitFor(() => {
const failedAssistantSave = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-auth-expired' &&
call[1] === 'conv-1' &&
call[2]?.role === 'assistant' &&
call[2]?.runId === 'run-auth-expired' &&
call[2]?.runStatus === 'failed' &&
call[2]?.events?.some(
(event: { kind?: string; label?: string; detail?: string }) =>
event.kind === 'status' &&
event.label === 'error' &&
event.detail === 'Your authentication token has expired. Please sign in again.',
),
);
expect(failedAssistantSave).toBeTruthy();
});
});
it('threads the resumable flag from a live daemon failure onto the assistant message', async () => {
// Regression: a transient failure that arrives on the live streamViaDaemon
// onError path (not just reattach/status-fetch) must carry `resumable` onto
// the assistant message, otherwise ChatPane's Continue CTA never appears on
// the primary surface until a reload re-fetches run status.
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
streamViaDaemon.mockImplementation(async (options: {
onRunCreated?: (runId: string) => void;
handlers: { onError: (error: Error) => void };
}) => {
options.onRunCreated?.('run-resumable-1');
const err = new Error('Upstream request failed: stream disconnected.') as Error & {
resumable?: boolean;
};
err.resumable = true;
options.handlers.onError(err);
});
chatPaneSpy.mockClear();
render(
<ProjectView
project={{ id: 'project-resumable', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
const sendProps = await waitForReadyChatPaneProps();
await sendProps!.onSend!('do the thing', [], []);
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
// The failed assistant row carries `resumable: true` (persisted + rendered).
await waitFor(() => {
const resumableFailedSave = saveMessage.mock.calls.find(
(call) =>
call[2]?.role === 'assistant' &&
call[2]?.runStatus === 'failed' &&
call[2]?.resumable === true,
);
expect(resumableFailedSave).toBeTruthy();
});
// And ChatPane receives that resumable failed message so the CTA can gate on it.
const latest = chatPaneSpy.mock.calls.at(-1)?.[0] as {
messages?: Array<{ role: string; runStatus?: string; resumable?: boolean }>;
};
expect(
latest?.messages?.some((m) => m.role === 'assistant' && m.runStatus === 'failed' && m.resumable === true),
).toBe(true);
});
it('does not replay a terminal succeeded row with empty produced files', async () => {
const runCreatedAt = Date.now();
const existingArtifact = {
artifactManifest: {
entry: 'real-daemon-smoke.html',
exports: ['html'],
kind: 'html',
metadata: {
artifactType: 'text/html',
identifier: 'real-daemon-smoke',
inferred: false,
},
renderer: 'html',
title: 'Real Daemon Smoke',
version: 1,
},
kind: 'html',
mime: 'text/html',
mtime: runCreatedAt + 1,
name: 'real-daemon-smoke.html',
size: 100,
};
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-replay',
role: 'assistant',
content: '',
createdAt: Date.now(),
runId: 'run-replay',
runStatus: 'succeeded',
producedFiles: [],
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([existingArtifact]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
fetchChatRunStatus.mockResolvedValue({
id: 'run-replay',
status: 'succeeded',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: 0,
signal: null,
});
listActiveChatRuns.mockResolvedValue([]);
reattachDaemonRun.mockImplementation(async (options: {
handlers: {
onDelta: (delta: string) => void;
onDone: () => void;
};
}) => {
options.handlers.onDelta(
'<artifact identifier="real-daemon-smoke" type="text/html" title="Real Daemon Smoke"><h1>Real Daemon Smoke</h1></artifact>',
);
options.handlers.onDone();
});
render(
<ProjectView
project={{ id: 'project-1', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => expect(fetchProjectFiles).toHaveBeenCalledWith('project-1'));
expect(fetchChatRunStatus).not.toHaveBeenCalled();
expect(reattachDaemonRun).not.toHaveBeenCalled();
expect(saveMessage).not.toHaveBeenCalledWith(
'project-1',
'conv-1',
expect.objectContaining({
id: 'msg-replay',
producedFiles: [existingArtifact],
}),
);
expect(writeProjectTextFile).not.toHaveBeenCalled();
});
it('replays a legacy terminal succeeded row when agent events still contain the artifact', async () => {
const runCreatedAt = Date.now();
const existingArtifact = artifactProjectFile('real-daemon-smoke.html', runCreatedAt + 1);
const artifactEvent = {
kind: 'text',
text:
'<artifact identifier="real-daemon-smoke" type="text/html" title="Real Daemon Smoke"><h1>Real Daemon Smoke</h1></artifact>',
};
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-legacy-replay',
role: 'assistant',
content: '',
createdAt: runCreatedAt,
events: [artifactEvent],
runId: 'run-legacy-replay',
runStatus: 'succeeded',
producedFiles: [],
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([existingArtifact]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
fetchChatRunStatus.mockResolvedValue({
id: 'run-legacy-replay',
status: 'succeeded',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: 0,
signal: null,
});
listActiveChatRuns.mockResolvedValue([]);
render(
<ProjectView
project={{ id: 'project-1', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => expect(fetchChatRunStatus).toHaveBeenCalledWith('run-legacy-replay'));
await waitFor(() => {
expect(saveMessage).toHaveBeenCalledWith(
'project-1',
'conv-1',
expect.objectContaining({
id: 'msg-legacy-replay',
content: artifactEvent.text,
producedFiles: [existingArtifact],
runStatus: 'succeeded',
}),
expect.objectContaining({ telemetryFinalized: true }),
);
});
expect(reattachDaemonRun).not.toHaveBeenCalled();
expect(writeProjectTextFile).not.toHaveBeenCalled();
});
it('preserves authoritative failed status while replaying a stale succeeded row', async () => {
const runCreatedAt = Date.now();
const artifactEvent = {
kind: 'text',
text:
'<artifact identifier="real-daemon-smoke" type="text/html" title="Real Daemon Smoke"><h1>Real Daemon Smoke</h1></artifact>',
};
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-stale-succeeded-failed-status',
role: 'assistant',
content: '',
createdAt: runCreatedAt,
events: [artifactEvent],
runId: 'run-stale-succeeded-failed-status',
runStatus: 'succeeded',
producedFiles: [],
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
fetchChatRunStatus.mockResolvedValue({
id: 'run-stale-succeeded-failed-status',
status: 'failed',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: 1,
signal: null,
resumable: true,
});
listActiveChatRuns.mockResolvedValue([]);
render(
<ProjectView
project={{ id: 'project-stale-succeeded-failed-status', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => {
expect(saveMessage).toHaveBeenCalledWith(
'project-stale-succeeded-failed-status',
'conv-1',
expect.objectContaining({
id: 'msg-stale-succeeded-failed-status',
content: artifactEvent.text,
runStatus: 'failed',
resumable: true,
}),
expect.objectContaining({ telemetryFinalized: true }),
);
});
expect(reattachDaemonRun).not.toHaveBeenCalled();
});
it('keeps reattaching after two generic disconnects while daemon status stays running, but backs off before the next retry', async () => {
const runCreatedAt = Date.now();
const genericDisconnect = await createGenericDisconnectError();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-flaky-reattach',
role: 'assistant',
content: '',
createdAt: runCreatedAt,
startedAt: runCreatedAt,
runId: 'run-flaky-reattach',
runStatus: 'failed',
producedFiles: [],
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
fetchChatRunStatus.mockResolvedValue({
id: 'run-flaky-reattach',
status: 'running',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: null,
signal: null,
});
listActiveChatRuns.mockResolvedValue([]);
reattachDaemonRun.mockImplementation(async (options: {
handlers: { onError: (error: Error) => void };
}) => {
options.handlers.onError(genericDisconnect);
});
render(
<ProjectView
project={{ id: 'project-flaky-reattach', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => expect(reattachDaemonRun.mock.calls.length).toBeGreaterThanOrEqual(2), {
timeout: 2_000,
});
expect(reattachDaemonRun.mock.calls.length).toBe(2);
await new Promise((resolve) => setTimeout(resolve, 500));
expect(reattachDaemonRun.mock.calls.length).toBe(2);
await waitFor(() => expect(reattachDaemonRun.mock.calls.length).toBeGreaterThanOrEqual(3), {
timeout: 4_500,
});
}, 12_000);
it('clears streaming after a slow retry-cap status probe on reattach generic disconnects', async () => {
const runCreatedAt = Date.now();
const genericDisconnect = await createGenericDisconnectError();
type RunningStatusProbe = {
id: string;
status: 'running';
createdAt: number;
updatedAt: number;
exitCode: null;
signal: null;
};
let resolveStatusProbe!: (value: RunningStatusProbe) => void;
const statusProbe = new Promise<RunningStatusProbe>((resolve) => {
resolveStatusProbe = resolve;
});
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-reattach-slow-status-probe',
role: 'assistant',
content: '',
createdAt: runCreatedAt,
startedAt: runCreatedAt,
runId: 'run-reattach-slow-status-probe',
runStatus: 'failed',
producedFiles: [],
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
let statusChecks = 0;
fetchChatRunStatus.mockImplementation(async () => {
statusChecks += 1;
if (statusChecks === 3) return statusProbe;
return {
id: 'run-reattach-slow-status-probe',
status: 'running' as const,
createdAt: runCreatedAt,
updatedAt: runCreatedAt + statusChecks,
exitCode: null,
signal: null,
};
});
reattachDaemonRun.mockImplementation(async (options: {
handlers: { onError: (error: Error) => Promise<void> };
}) => {
void options.handlers.onError(genericDisconnect);
});
render(
<ProjectView
project={{ id: 'project-reattach-slow-status-probe', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => {
expect(reattachDaemonRun).toHaveBeenCalledTimes(2);
expect(fetchChatRunStatus).toHaveBeenCalledTimes(3);
expect(chatPaneSpy.mock.calls.at(-1)?.[0]?.streaming).toBe(false);
expect(chatPaneSpy.mock.calls.at(-1)?.[0]?.sendDisabled).toBe(false);
});
resolveStatusProbe({
id: 'run-reattach-slow-status-probe',
status: 'running',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 3,
exitCode: null,
signal: null,
});
await waitFor(() => {
expect(chatPaneSpy.mock.calls.at(-1)?.[0]?.streaming).toBe(false);
expect(chatPaneSpy.mock.calls.at(-1)?.[0]?.sendDisabled).toBe(false);
});
});
it('keeps live-stream recovery retryable after two generic disconnects while daemon status stays running, but backs off before the next retry', async () => {
const runCreatedAt = Date.now();
const genericDisconnect = await createGenericDisconnectError();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus.mockResolvedValue({
id: 'run-live-flaky',
status: 'running',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: null,
signal: null,
});
streamViaDaemon.mockImplementation(async (options: {
onRunCreated?: (runId: string) => void;
handlers: { onError: (error: Error) => void };
}) => {
options.onRunCreated?.('run-live-flaky');
options.handlers.onError(genericDisconnect);
});
chatPaneSpy.mockClear();
render(
<ProjectView
project={{ id: 'project-live-flaky', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
const sendProps = await waitForReadyChatPaneProps();
await sendProps!.onSend!('flaky stream', [], []);
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
await waitFor(() => expect(reattachDaemonRun.mock.calls.length).toBeGreaterThanOrEqual(1), {
timeout: 2_000,
});
expect(reattachDaemonRun.mock.calls.length).toBe(1);
await new Promise((resolve) => setTimeout(resolve, 500));
expect(reattachDaemonRun.mock.calls.length).toBe(1);
await waitFor(() => expect(reattachDaemonRun.mock.calls.length).toBeGreaterThanOrEqual(2), {
timeout: 4_500,
});
}, 12_000);
it('keeps a partial live generic disconnect recoverable after the first failure', async () => {
const runCreatedAt = Date.now();
const genericDisconnect = await createGenericDisconnectError();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus.mockResolvedValue({
id: 'run-live-partial-recoverable',
status: 'running',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: null,
signal: null,
});
streamViaDaemon.mockImplementation(async (options: {
onRunCreated?: (runId: string) => void;
handlers: {
onDelta: (delta: string) => void;
onError: (error: Error) => Promise<void>;
};
}) => {
options.onRunCreated?.('run-live-partial-recoverable');
options.handlers.onDelta('partial output');
await options.handlers.onError(genericDisconnect);
});
reattachDaemonRun.mockImplementation(async () => new Promise<void>(() => {}));
chatPaneSpy.mockClear();
render(
<ProjectView
project={{ id: 'project-live-partial-recoverable', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
const sendProps = await waitForReadyChatPaneProps();
await sendProps!.onSend!('recover partial live disconnect', [], []);
await waitFor(() => expect(reattachDaemonRun).toHaveBeenCalledTimes(1), {
timeout: 2_000,
});
});
it('installs live generic-disconnect backoff before a slow retry-cap status probe can trigger reattach', async () => {
const runCreatedAt = Date.now();
const genericDisconnect = await createGenericDisconnectError();
type RunningStatusProbe = {
id: string;
status: 'running';
createdAt: number;
updatedAt: number;
exitCode: null;
signal: null;
};
let resolveStatusProbe!: (value: RunningStatusProbe) => void;
const statusProbe = new Promise<RunningStatusProbe>((resolve) => {
resolveStatusProbe = resolve;
});
let statusChecks = 0;
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus.mockImplementation(async () => {
statusChecks += 1;
if (statusChecks === 1) return statusProbe;
return {
id: 'run-live-slow-status-probe',
status: 'running' as const,
createdAt: runCreatedAt,
updatedAt: runCreatedAt + statusChecks,
exitCode: null,
signal: null,
};
});
streamViaDaemon.mockImplementation(async (options: {
onRunCreated?: (runId: string) => void;
handlers: {
onError: (error: Error) => Promise<void>;
};
}) => {
options.onRunCreated?.('run-live-slow-status-probe');
await options.handlers.onError(genericDisconnect);
void options.handlers.onError(genericDisconnect);
});
reattachDaemonRun.mockImplementation(async () => new Promise<void>(() => {}));
chatPaneSpy.mockClear();
render(
<ProjectView
project={{ id: 'project-live-slow-status-probe', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
const sendProps = await waitForReadyChatPaneProps();
await sendProps!.onSend!('slow live status probe', [], []);
await waitFor(() => {
expect(fetchChatRunStatus).toHaveBeenCalledTimes(1);
});
await new Promise((resolve) => setTimeout(resolve, 200));
expect(reattachDaemonRun).not.toHaveBeenCalled();
resolveStatusProbe({
id: 'run-live-slow-status-probe',
status: 'running',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: null,
signal: null,
});
await waitFor(() => expect(reattachDaemonRun).toHaveBeenCalledTimes(1), {
timeout: 4_000,
});
});
it('keeps generic-disconnect cap retryable when the follow-up status probe returns null, but backs off before retrying', async () => {
const runCreatedAt = Date.now();
const genericDisconnect = await createGenericDisconnectError();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-null-status-retry',
role: 'assistant',
content: '',
createdAt: runCreatedAt,
startedAt: runCreatedAt,
runId: 'run-null-status-retry',
runStatus: 'failed',
producedFiles: [],
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus
.mockResolvedValueOnce({
id: 'run-null-status-retry',
status: 'running',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: null,
signal: null,
})
.mockResolvedValueOnce({
id: 'run-null-status-retry',
status: 'running',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 2,
exitCode: null,
signal: null,
})
.mockResolvedValueOnce(null)
.mockResolvedValue({
id: 'run-null-status-retry',
status: 'running',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 3,
exitCode: null,
signal: null,
});
reattachDaemonRun.mockImplementation(async (options: {
handlers: { onError: (error: Error) => void };
}) => {
options.handlers.onError(genericDisconnect);
});
render(
<ProjectView
project={{ id: 'project-null-status-retry', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => expect(reattachDaemonRun.mock.calls.length).toBeGreaterThanOrEqual(2), {
timeout: 2_000,
});
expect(reattachDaemonRun.mock.calls.length).toBe(2);
await new Promise((resolve) => setTimeout(resolve, 500));
expect(reattachDaemonRun.mock.calls.length).toBe(2);
await waitFor(() => expect(reattachDaemonRun.mock.calls.length).toBeGreaterThanOrEqual(3), {
timeout: 4_500,
});
}, 12_000);
it('finalizes a reattach generic disconnect as succeeded when the next status poll turns terminal', async () => {
const runCreatedAt = Date.now();
const { GENERIC_DAEMON_DISCONNECT_MESSAGE } = await import('../../src/providers/daemon');
const genericDisconnect = await createGenericDisconnectError();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-reattach-terminal-success',
role: 'assistant',
content: '',
createdAt: runCreatedAt,
startedAt: runCreatedAt,
runId: 'run-reattach-terminal-success',
runStatus: 'failed',
producedFiles: [],
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus
.mockResolvedValueOnce({
id: 'run-reattach-terminal-success',
status: 'running',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: null,
signal: null,
})
.mockResolvedValueOnce({
id: 'run-reattach-terminal-success',
status: 'succeeded',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 2,
exitCode: 0,
signal: null,
});
let reattachAttempts = 0;
reattachDaemonRun.mockImplementation(async (options: {
handlers: {
onError: (error: Error) => Promise<void>;
onDone: () => void;
};
}) => {
reattachAttempts += 1;
if (reattachAttempts === 1) {
await options.handlers.onError(genericDisconnect);
return;
}
options.handlers.onDone();
});
render(
<ProjectView
project={{ id: 'project-reattach-terminal-success', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => {
expect(saveMessage).toHaveBeenCalledWith(
'project-reattach-terminal-success',
'conv-1',
expect.objectContaining({
id: 'msg-reattach-terminal-success',
runStatus: 'succeeded',
}),
expect.objectContaining({ telemetryFinalized: true }),
);
});
const recoveredSave = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-reattach-terminal-success' &&
call[2]?.id === 'msg-reattach-terminal-success' &&
call[2]?.runStatus === 'succeeded',
);
expect(
recoveredSave?.[2]?.events?.some(
(event: { kind?: string; label?: string; detail?: string }) =>
event.kind === 'status' &&
event.label === 'error' &&
event.detail === GENERIC_DAEMON_DISCONNECT_MESSAGE,
),
).toBe(false);
});
it('treats a code-only generic disconnect as retryable even when the message differs', async () => {
const runCreatedAt = Date.now();
const genericDisconnect = await createCodeOnlyGenericDisconnectError();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-reattach-code-only-generic-disconnect',
role: 'assistant',
content: '',
createdAt: runCreatedAt,
startedAt: runCreatedAt,
runId: 'run-reattach-code-only-generic-disconnect',
runStatus: 'failed',
producedFiles: [],
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus
.mockResolvedValueOnce({
id: 'run-reattach-code-only-generic-disconnect',
status: 'running',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: null,
signal: null,
})
.mockResolvedValueOnce({
id: 'run-reattach-code-only-generic-disconnect',
status: 'running',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 2,
exitCode: null,
signal: null,
})
.mockResolvedValueOnce({
id: 'run-reattach-code-only-generic-disconnect',
status: 'succeeded',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 3,
exitCode: 0,
signal: null,
});
let reattachAttempts = 0;
reattachDaemonRun.mockImplementation(async (options: {
handlers: {
onError: (error: Error) => Promise<void>;
onDone: () => void;
};
}) => {
reattachAttempts += 1;
if (reattachAttempts < 3) {
await options.handlers.onError(genericDisconnect);
return;
}
options.handlers.onDone();
});
render(
<ProjectView
project={{ id: 'project-reattach-code-only-generic-disconnect', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => {
expect(reattachDaemonRun.mock.calls.length).toBeGreaterThanOrEqual(3);
expect(saveMessage).toHaveBeenCalledWith(
'project-reattach-code-only-generic-disconnect',
'conv-1',
expect.objectContaining({
id: 'msg-reattach-code-only-generic-disconnect',
runStatus: 'succeeded',
}),
expect.objectContaining({ telemetryFinalized: true }),
);
});
});
it('patches terminal metadata when a reattach generic disconnect later proves failed', async () => {
const runCreatedAt = Date.now();
const genericDisconnect = await createGenericDisconnectError() as Error & {
resumable?: boolean;
};
genericDisconnect.resumable = false;
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-reattach-terminal-failed',
role: 'assistant',
content: '',
createdAt: runCreatedAt,
startedAt: runCreatedAt,
runId: 'run-reattach-terminal-failed',
runStatus: 'failed',
producedFiles: [],
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus
.mockResolvedValueOnce({
id: 'run-reattach-terminal-failed',
status: 'running',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: null,
signal: null,
})
.mockResolvedValueOnce({
id: 'run-reattach-terminal-failed',
status: 'failed',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 2,
exitCode: 1,
signal: null,
resumable: true,
});
reattachDaemonRun.mockImplementation(async (options: {
handlers: {
onError: (error: Error) => Promise<void>;
};
}) => {
await options.handlers.onError(genericDisconnect);
});
render(
<ProjectView
project={{ id: 'project-reattach-terminal-failed', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => {
const failedSave = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-reattach-terminal-failed' &&
call[2]?.id === 'msg-reattach-terminal-failed' &&
call[2]?.runStatus === 'failed' &&
call[2]?.resumable === true,
);
expect(failedSave).toBeTruthy();
});
});
it('replays a terminally-succeeded reattach run again when the previous retry only restored partial content', async () => {
const runCreatedAt = Date.now();
const genericDisconnect = await createGenericDisconnectError();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-reattach-terminal-partial-success',
role: 'assistant',
content: '',
createdAt: runCreatedAt,
startedAt: runCreatedAt,
runId: 'run-reattach-terminal-partial-success',
runStatus: 'failed',
producedFiles: [],
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus
.mockResolvedValueOnce({
id: 'run-reattach-terminal-partial-success',
status: 'running',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: null,
signal: null,
})
.mockResolvedValueOnce({
id: 'run-reattach-terminal-partial-success',
status: 'running',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 2,
exitCode: null,
signal: null,
})
.mockResolvedValueOnce({
id: 'run-reattach-terminal-partial-success',
status: 'succeeded',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 3,
exitCode: 0,
signal: null,
})
.mockResolvedValue({
id: 'run-reattach-terminal-partial-success',
status: 'succeeded',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 4,
exitCode: 0,
signal: null,
});
let reattachAttempts = 0;
reattachDaemonRun.mockImplementation(async (options: {
handlers: {
onDelta: (delta: string) => void;
onError: (error: Error) => Promise<void>;
onDone: () => void;
};
}) => {
reattachAttempts += 1;
if (reattachAttempts === 1) {
await options.handlers.onError(genericDisconnect);
return;
}
if (reattachAttempts === 2) {
options.handlers.onDelta('partial output');
await options.handlers.onError(genericDisconnect);
return;
}
options.handlers.onDelta('full recovered output');
options.handlers.onDone();
});
render(
<ProjectView
project={{ id: 'project-reattach-terminal-partial-success', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => {
expect(reattachDaemonRun.mock.calls.length).toBeGreaterThanOrEqual(3);
const succeededSave = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-reattach-terminal-partial-success' &&
call[2]?.id === 'msg-reattach-terminal-partial-success' &&
call[2]?.runStatus === 'succeeded' &&
call[2]?.content === 'full recovered output',
);
expect(succeededSave).toBeTruthy();
});
const truncatedSucceededSave = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-reattach-terminal-partial-success' &&
call[2]?.id === 'msg-reattach-terminal-partial-success' &&
call[2]?.runStatus === 'succeeded' &&
call[2]?.content === 'partial output',
);
expect(truncatedSucceededSave).toBeFalsy();
});
it('finalizes a live generic disconnect as succeeded when the next status poll turns terminal', async () => {
const runCreatedAt = Date.now();
const { GENERIC_DAEMON_DISCONNECT_MESSAGE } = await import('../../src/providers/daemon');
const genericDisconnect = await createGenericDisconnectError();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus.mockResolvedValue({
id: 'run-live-terminal-success',
status: 'succeeded',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: 0,
signal: null,
});
streamViaDaemon.mockImplementation(async (options: {
onRunCreated?: (runId: string) => void;
handlers: { onError: (error: Error) => Promise<void> };
}) => {
options.onRunCreated?.('run-live-terminal-success');
await options.handlers.onError(genericDisconnect);
await options.handlers.onError(genericDisconnect);
});
chatPaneSpy.mockClear();
render(
<ProjectView
project={{ id: 'project-live-terminal-success', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
const sendProps = await waitForReadyChatPaneProps();
await sendProps!.onSend!('terminal success after disconnect', [], []);
await waitFor(() => {
const succeededSave = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-live-terminal-success' &&
call[2]?.role === 'assistant' &&
call[2]?.runId === 'run-live-terminal-success' &&
call[2]?.runStatus === 'succeeded',
);
expect(succeededSave).toBeTruthy();
});
const succeededSave = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-live-terminal-success' &&
call[2]?.role === 'assistant' &&
call[2]?.runId === 'run-live-terminal-success' &&
call[2]?.runStatus === 'succeeded',
);
expect(
succeededSave?.[2]?.events?.some(
(event: { kind?: string; label?: string; detail?: string }) =>
event.kind === 'status' &&
event.label === 'error' &&
event.detail === GENERIC_DAEMON_DISCONNECT_MESSAGE,
),
).toBe(false);
expect(patchPreviewCommentStatus).not.toHaveBeenCalled();
});
it('does not persist a live generic disconnect as succeeded with partial streamed text', async () => {
const runCreatedAt = Date.now();
const genericDisconnect = await createGenericDisconnectError();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus.mockResolvedValue({
id: 'run-live-terminal-partial-success',
status: 'succeeded',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: 0,
signal: null,
});
streamViaDaemon.mockImplementation(async (options: {
onRunCreated?: (runId: string) => void;
handlers: {
onDelta: (delta: string) => void;
onError: (error: Error) => Promise<void>;
};
}) => {
options.onRunCreated?.('run-live-terminal-partial-success');
options.handlers.onDelta('partial output');
await options.handlers.onError(genericDisconnect);
await options.handlers.onError(genericDisconnect);
});
reattachDaemonRun.mockImplementation(async (options: {
handlers: {
onDelta: (delta: string) => void;
onDone: () => void;
};
}) => {
options.handlers.onDelta('full recovered output');
options.handlers.onDone();
});
chatPaneSpy.mockClear();
render(
<ProjectView
project={{ id: 'project-live-terminal-partial-success', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
const sendProps = await waitForReadyChatPaneProps();
await sendProps!.onSend!('terminal success after partial disconnect', [], []);
await waitFor(() => {
expect(saveMessage).toHaveBeenCalled();
});
const truncatedSucceededSave = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-live-terminal-partial-success' &&
call[2]?.role === 'assistant' &&
call[2]?.runId === 'run-live-terminal-partial-success' &&
call[2]?.runStatus === 'succeeded' &&
call[2]?.content === 'partial output',
);
expect(truncatedSucceededSave).toBeFalsy();
await waitFor(() => {
const recoveredSave = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-live-terminal-partial-success' &&
call[2]?.role === 'assistant' &&
call[2]?.runId === 'run-live-terminal-partial-success' &&
call[2]?.runStatus === 'succeeded' &&
call[2]?.content === 'full recovered output',
);
expect(recoveredSave).toBeTruthy();
});
});
it('preserves canceled status when a reattached run reports canceled before onDone', async () => {
const runCreatedAt = Date.now();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-reattach-canceled',
role: 'assistant',
content: '',
createdAt: runCreatedAt,
startedAt: runCreatedAt,
runId: 'run-reattach-canceled',
runStatus: 'failed',
producedFiles: [],
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus.mockResolvedValue({
id: 'run-reattach-canceled',
status: 'running',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: null,
signal: null,
});
reattachDaemonRun.mockImplementation(async (options: {
onRunStatus?: (runStatus: 'canceled') => void;
handlers: {
onDone: () => void;
};
}) => {
options.onRunStatus?.('canceled');
options.handlers.onDone();
});
render(
<ProjectView
project={{ id: 'project-reattach-canceled', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => {
expect(saveMessage).toHaveBeenCalledWith(
'project-reattach-canceled',
'conv-1',
expect.objectContaining({
id: 'msg-reattach-canceled',
runStatus: 'canceled',
}),
expect.objectContaining({ telemetryFinalized: true }),
);
});
expect(saveMessage).not.toHaveBeenCalledWith(
'project-reattach-canceled',
'conv-1',
expect.objectContaining({
id: 'msg-reattach-canceled',
runStatus: 'succeeded',
}),
expect.objectContaining({ telemetryFinalized: true }),
);
});
it('patches live generic-disconnect terminal metadata from a failed daemon status', async () => {
const runCreatedAt = Date.now();
const genericDisconnect = await createGenericDisconnectError() as Error & {
resumable?: boolean;
};
genericDisconnect.resumable = false;
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus
.mockResolvedValueOnce({
id: 'run-live-terminal-failed',
status: 'failed',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: 1,
signal: null,
resumable: true,
})
.mockResolvedValue({
id: 'run-live-terminal-failed',
status: 'failed',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: 1,
signal: null,
resumable: true,
});
streamViaDaemon.mockImplementation(async (options: {
onRunCreated?: (runId: string) => void;
handlers: { onError: (error: Error) => Promise<void> };
}) => {
options.onRunCreated?.('run-live-terminal-failed');
await options.handlers.onError(genericDisconnect);
await options.handlers.onError(genericDisconnect);
});
chatPaneSpy.mockClear();
render(
<ProjectView
project={{ id: 'project-live-terminal-failed', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
const sendProps = await waitForReadyChatPaneProps();
await sendProps!.onSend!('terminal failed after disconnect', [], []);
await waitFor(() => {
const failedSave = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-live-terminal-failed' &&
call[2]?.role === 'assistant' &&
call[2]?.runId === 'run-live-terminal-failed' &&
call[2]?.runStatus === 'failed' &&
call[2]?.resumable === true,
);
expect(failedSave).toBeTruthy();
});
});
it('restores attached comment statuses when a live generic disconnect later proves succeeded', async () => {
const runCreatedAt = Date.now();
const genericDisconnect = await createGenericDisconnectError();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus.mockResolvedValue({
id: 'run-live-success-comments',
status: 'succeeded',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: 0,
signal: null,
});
streamViaDaemon.mockImplementation(async (options: {
onRunCreated?: (runId: string) => void;
handlers: { onError: (error: Error) => Promise<void> };
}) => {
options.onRunCreated?.('run-live-success-comments');
await options.handlers.onError(genericDisconnect);
await options.handlers.onError(genericDisconnect);
});
chatPaneSpy.mockClear();
render(
<ProjectView
project={{ id: 'project-live-success-comments', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
const sendProps = await waitForReadyChatPaneProps();
await sendProps!.onSend!(
'terminal success after disconnect with comments',
[],
[
{
id: 'comment-1',
comment: 'Tighten spacing',
commentContext: 'query',
filePath: 'preview.html',
source: 'query',
},
] as never,
);
await waitFor(() => {
expect(patchPreviewCommentStatus).toHaveBeenCalledWith(
'project-live-success-comments',
'conv-1',
'comment-1',
'needs_review',
);
});
});
it('replays a spuriously failed empty row when daemon status is already succeeded', async () => {
const runCreatedAt = Date.now();
const recoveredArtifact = artifactProjectFile('real-daemon-smoke.html', runCreatedAt + 2);
const artifactContent =
'<artifact identifier="real-daemon-smoke" type="text/html" title="Real Daemon Smoke">' +
'<!doctype html><html><body><h1>Real Daemon Smoke</h1></body></html>' +
'</artifact>';
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-spurious-succeeded',
role: 'assistant',
content: '',
createdAt: runCreatedAt,
startedAt: runCreatedAt,
runId: 'run-spurious-succeeded',
runStatus: 'failed',
producedFiles: [],
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([recoveredArtifact]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus.mockResolvedValue({
id: 'run-spurious-succeeded',
status: 'succeeded',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: 0,
signal: null,
});
reattachDaemonRun.mockImplementation(async (options: {
handlers: {
onDelta: (delta: string) => void;
onDone: () => void;
};
}) => {
options.handlers.onDelta(artifactContent);
options.handlers.onDone();
});
render(
<ProjectView
project={{ id: 'project-spurious-succeeded', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => expect(reattachDaemonRun).toHaveBeenCalled());
await waitFor(() => {
expect(saveMessage).toHaveBeenCalledWith(
'project-spurious-succeeded',
'conv-1',
expect.objectContaining({
id: 'msg-spurious-succeeded',
content: artifactContent,
producedFiles: [recoveredArtifact],
runStatus: 'succeeded',
}),
expect.objectContaining({ telemetryFinalized: true }),
);
});
});
it('preserves a spuriously failed empty row when daemon status is already canceled', async () => {
const runCreatedAt = Date.now();
const preservedEvents = [
{
kind: 'status',
label: 'warning',
detail: 'Canceled after reload.',
timestamp: runCreatedAt + 1,
},
];
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-spurious-canceled',
role: 'assistant',
content: '',
createdAt: runCreatedAt,
startedAt: runCreatedAt,
runId: 'run-spurious-canceled',
runStatus: 'failed',
producedFiles: [],
events: preservedEvents,
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus.mockResolvedValue({
id: 'run-spurious-canceled',
status: 'canceled',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: 130,
signal: null,
resumable: true,
});
render(
<ProjectView
project={{ id: 'project-spurious-canceled', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => {
expect(reattachDaemonRun).not.toHaveBeenCalled();
const canceledSave = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-spurious-canceled' &&
call[1] === 'conv-1' &&
call[2]?.id === 'msg-spurious-canceled' &&
call[2]?.runStatus === 'canceled' &&
call[2]?.resumable === true &&
call[2]?.events === preservedEvents &&
call[3] === undefined,
);
expect(canceledSave).toBeTruthy();
});
});
// Regression (#4607): messages persisted BEFORE this PR started tagging
// disconnects with a structured `code` recorded the generic disconnect as
// a legacy status/error event — `detail` matches the generic-disconnect
// message but there is no `code` field. `hasGenericDisconnectFailureEvent`
// must still recognize that legacy shape so a spuriously-failed row
// recovers on reload once the daemon reports the run actually succeeded.
// The row is seeded with non-empty partial content (rather than empty
// content) specifically to prove the plain empty-content spurious-fail
// heuristic is NOT what catches this row — only the legacy-detail
// predicate does. Recovery replaces that local content with the daemon's
// authoritative replayed transcript (`attachRecoverableRuns` wipes and
// re-drives `reattachDaemonRun`), so the persisted content must match the
// REPLAYED content, not the originally-seeded partial content.
it('recovers a legacy message-only generic-disconnect failure (partial content, no code) when the daemon reports succeeded', async () => {
const runCreatedAt = Date.now();
const partialContent = 'Partial output captured before the browser SSE reconnect loop gave up.';
const replayedContent =
'<artifact identifier="real-daemon-smoke" type="text/html" title="Real Daemon Smoke">' +
'<!doctype html><html><body><h1>Real Daemon Smoke</h1></body></html>' +
'</artifact>';
const legacyDisconnectEvents = [
{
kind: 'status',
label: 'error',
detail: 'daemon stream disconnected before run completed',
},
];
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-legacy-disconnect-partial',
role: 'assistant',
content: partialContent,
createdAt: runCreatedAt,
startedAt: runCreatedAt,
runId: 'run-legacy-disconnect-partial',
runStatus: 'failed',
events: legacyDisconnectEvents,
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus.mockResolvedValue({
id: 'run-legacy-disconnect-partial',
status: 'succeeded',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: 0,
signal: null,
});
reattachDaemonRun.mockImplementation(async (options: {
handlers: {
onDelta: (delta: string) => void;
onDone: () => void;
};
}) => {
options.handlers.onDelta(replayedContent);
options.handlers.onDone();
});
render(
<ProjectView
project={{ id: 'project-legacy-disconnect-partial', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
// The daemon reattach must actually be entered — this is what proves the
// legacy no-code event was recognized as recoverable in the first place.
await waitFor(() => expect(reattachDaemonRun).toHaveBeenCalled());
await waitFor(() => {
expect(saveMessage).toHaveBeenCalledWith(
'project-legacy-disconnect-partial',
'conv-1',
expect.objectContaining({
id: 'msg-legacy-disconnect-partial',
content: replayedContent,
runStatus: 'succeeded',
}),
expect.objectContaining({ telemetryFinalized: true }),
);
});
});
it('keeps reload artifact recovery retryable after a transient persistence miss', async () => {
const runCreatedAt = Date.now();
const recoveredArtifact = artifactProjectFile('real-daemon-smoke.html', runCreatedAt + 2);
const artifactContent =
'<artifact identifier="real-daemon-smoke" type="text/html" title="Real Daemon Smoke">' +
'<!doctype html><html><body><h1>Real Daemon Smoke</h1></body></html>' +
'</artifact>';
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-recover-failed',
role: 'assistant',
content: artifactContent,
createdAt: runCreatedAt + 1,
runId: 'run-recover-failed',
runStatus: 'succeeded',
producedFiles: [],
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
let persistAttempts = 0;
fetchProjectFiles.mockImplementation(async () =>
persistAttempts >= 2 ? [recoveredArtifact] : [],
);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
fetchChatRunStatus.mockResolvedValue({
id: 'run-recover-failed',
status: 'succeeded',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: 0,
signal: null,
});
listActiveChatRuns.mockResolvedValue([]);
writeProjectTextFile.mockImplementation(async () => {
persistAttempts += 1;
return persistAttempts >= 2 ? recoveredArtifact : null;
});
render(
<ProjectView
project={{ id: 'project-recover-failed', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => expect(writeProjectTextFile).toHaveBeenCalledTimes(1));
await waitFor(() => expect(writeProjectTextFile).toHaveBeenCalledTimes(2), { timeout: 2_500 });
await waitFor(() => {
expect(saveMessage).toHaveBeenCalledWith(
'project-recover-failed',
'conv-1',
expect.objectContaining({
id: 'msg-recover-failed',
producedFiles: [recoveredArtifact],
}),
expect.objectContaining({ telemetryFinalized: true }),
);
});
});
it('persists reload-recovered non-html artifacts when same-turn suffix matches have different contents', async () => {
const runCreatedAt = Date.now();
const staleCss = projectFile('theme-2.css', 'code', runCreatedAt + 1);
const finalCss = 'body { color: red; }';
const artifactContent =
`<artifact type="text/css" title="Theme">${finalCss}</artifact>`;
globalThis.fetch = vi.fn(async () =>
new Response('body { color: blue; }', { status: 200 }),
) as typeof fetch;
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-css-recover',
role: 'assistant',
content: artifactContent,
createdAt: runCreatedAt + 1,
runId: 'run-css-recover',
runStatus: 'succeeded',
producedFiles: [],
preTurnFileNames: [],
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockImplementation(async () =>
writeProjectTextFile.mock.calls.length > 0
? [staleCss, projectFile('theme.css', 'code', runCreatedAt + 2)]
: [staleCss],
);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
fetchChatRunStatus.mockResolvedValue({
id: 'run-css-recover',
status: 'succeeded',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: 0,
signal: null,
});
listActiveChatRuns.mockResolvedValue([]);
writeProjectTextFile.mockImplementation(async (_projectId, name) =>
projectFile(String(name), 'code', runCreatedAt + 2),
);
render(
<ProjectView
project={{ id: 'project-css-recover', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => expect(writeProjectTextFile).toHaveBeenCalledTimes(1));
expect(writeProjectTextFile).toHaveBeenCalledWith(
'project-css-recover',
'theme.css',
finalCss,
expect.objectContaining({
artifactManifest: expect.objectContaining({ entry: 'theme.css' }),
}),
);
});
it('does not recover a stale pointer target when reattached artifact persistence falls back to writing', async () => {
const runCreatedAt = Date.now();
const stalePointerTarget = projectFile('index.html', 'html', runCreatedAt - 1);
const recoveredPointerHtml = '<!doctype html><html><body><main>See: index.html</main><p>Recovered artifact body.</p></body></html>';
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-replay',
role: 'assistant',
content: '',
createdAt: runCreatedAt,
startedAt: runCreatedAt,
runId: 'run-replay',
runStatus: 'running',
preTurnFileNames: ['index.html'],
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([stalePointerTarget]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
fetchChatRunStatus.mockResolvedValue({
id: 'run-replay',
status: 'running',
createdAt: runCreatedAt,
updatedAt: runCreatedAt,
exitCode: null,
signal: null,
});
listActiveChatRuns.mockResolvedValue([]);
writeProjectTextFile.mockResolvedValue({
...projectFile('real-daemon-smoke.html', 'html', runCreatedAt + 1),
artifactManifest: {
entry: 'real-daemon-smoke.html',
exports: ['html'],
kind: 'html',
metadata: {
artifactType: 'text/html',
identifier: 'real-daemon-smoke',
inferred: false,
},
renderer: 'html',
title: 'Real Daemon Smoke',
version: 1,
},
});
reattachDaemonRun.mockImplementation(async (options: {
handlers: {
onDelta: (delta: string) => void;
onDone: () => void;
};
}) => {
options.handlers.onDelta(
`<artifact identifier="real-daemon-smoke" type="text/html" title="Real Daemon Smoke">${recoveredPointerHtml}</artifact>`,
);
options.handlers.onDone();
});
render(
<ProjectView
project={{ id: 'project-1', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => expect(reattachDaemonRun).toHaveBeenCalledTimes(1));
await waitFor(() => expect(writeProjectTextFile).toHaveBeenCalledTimes(1));
expect(writeProjectTextFile).toHaveBeenCalledWith(
'project-1',
'real-daemon-smoke.html',
recoveredPointerHtml,
expect.objectContaining({
artifactManifest: expect.objectContaining({ entry: 'real-daemon-smoke.html' }),
}),
);
expect(saveTabs).not.toHaveBeenCalledWith('project-1', expect.objectContaining({ active: 'index.html' }));
});
it('advances endedAt to the daemon terminal time when a reattach generic disconnect probe turns succeeded', async () => {
const runCreatedAt = Date.now();
// Far enough past any plausible disconnect-time Date.now() stamp taken during
// this test run that the two timestamps can never coincide by accident.
const daemonTerminalUpdatedAt = runCreatedAt + 10_000_000;
const genericDisconnect = await createGenericDisconnectError();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-reattach-endedat-succeeded',
role: 'assistant',
content: '',
createdAt: runCreatedAt,
startedAt: runCreatedAt,
runId: 'run-reattach-endedat-succeeded',
runStatus: 'failed',
producedFiles: [],
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus
.mockResolvedValueOnce({
id: 'run-reattach-endedat-succeeded',
status: 'running',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: null,
signal: null,
})
.mockResolvedValueOnce({
id: 'run-reattach-endedat-succeeded',
status: 'succeeded',
createdAt: runCreatedAt,
updatedAt: daemonTerminalUpdatedAt,
exitCode: 0,
signal: null,
});
let reattachAttempts = 0;
reattachDaemonRun.mockImplementation(async (options: {
handlers: {
onError: (error: Error) => Promise<void>;
onDone: () => void;
};
}) => {
reattachAttempts += 1;
if (reattachAttempts === 1) {
await options.handlers.onError(genericDisconnect);
return;
}
options.handlers.onDone();
});
render(
<ProjectView
project={{ id: 'project-reattach-endedat-succeeded', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => {
expect(saveMessage).toHaveBeenCalledWith(
'project-reattach-endedat-succeeded',
'conv-1',
expect.objectContaining({
id: 'msg-reattach-endedat-succeeded',
runStatus: 'succeeded',
}),
expect.objectContaining({ telemetryFinalized: true }),
);
});
const recoveredSave = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-reattach-endedat-succeeded' &&
call[2]?.id === 'msg-reattach-endedat-succeeded' &&
call[2]?.runStatus === 'succeeded',
);
// The daemon's authoritative terminal timestamp must win over the stale
// disconnect-time stamp recorded when the generic disconnect first fired.
expect(recoveredSave?.[2]?.endedAt).toBe(daemonTerminalUpdatedAt);
});
it('advances endedAt to the daemon terminal time when a reattach generic disconnect probe turns canceled', async () => {
const runCreatedAt = Date.now();
const daemonTerminalUpdatedAt = runCreatedAt + 10_000_000;
const genericDisconnect = await createGenericDisconnectError() as Error & {
resumable?: boolean;
};
genericDisconnect.resumable = false;
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-reattach-endedat-canceled',
role: 'assistant',
content: '',
createdAt: runCreatedAt,
startedAt: runCreatedAt,
runId: 'run-reattach-endedat-canceled',
runStatus: 'failed',
producedFiles: [],
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus
.mockResolvedValueOnce({
id: 'run-reattach-endedat-canceled',
status: 'running',
createdAt: runCreatedAt,
updatedAt: runCreatedAt + 1,
exitCode: null,
signal: null,
})
.mockResolvedValueOnce({
id: 'run-reattach-endedat-canceled',
status: 'canceled',
createdAt: runCreatedAt,
updatedAt: daemonTerminalUpdatedAt,
exitCode: null,
signal: null,
});
reattachDaemonRun.mockImplementation(async (options: {
handlers: {
onError: (error: Error) => Promise<void>;
};
}) => {
await options.handlers.onError(genericDisconnect);
});
render(
<ProjectView
project={{ id: 'project-reattach-endedat-canceled', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => {
const canceledSave = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-reattach-endedat-canceled' &&
call[2]?.id === 'msg-reattach-endedat-canceled' &&
call[2]?.runStatus === 'canceled',
);
expect(canceledSave).toBeTruthy();
});
const canceledSave = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-reattach-endedat-canceled' &&
call[2]?.id === 'msg-reattach-endedat-canceled' &&
call[2]?.runStatus === 'canceled',
);
expect(canceledSave?.[2]?.endedAt).toBe(daemonTerminalUpdatedAt);
});
it('advances endedAt to the daemon terminal time when a live generic disconnect probe turns succeeded', async () => {
const runCreatedAt = Date.now();
const daemonTerminalUpdatedAt = runCreatedAt + 10_000_000;
const genericDisconnect = await createGenericDisconnectError();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus.mockResolvedValue({
id: 'run-live-endedat-succeeded',
status: 'succeeded',
createdAt: runCreatedAt,
updatedAt: daemonTerminalUpdatedAt,
exitCode: 0,
signal: null,
});
streamViaDaemon.mockImplementation(async (options: {
onRunCreated?: (runId: string) => void;
handlers: { onError: (error: Error) => Promise<void> };
}) => {
options.onRunCreated?.('run-live-endedat-succeeded');
await options.handlers.onError(genericDisconnect);
await options.handlers.onError(genericDisconnect);
});
chatPaneSpy.mockClear();
render(
<ProjectView
project={{ id: 'project-live-endedat-succeeded', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
const sendProps = await waitForReadyChatPaneProps();
await sendProps!.onSend!('terminal success after disconnect (endedAt check)', [], []);
await waitFor(() => {
const succeededSave = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-live-endedat-succeeded' &&
call[2]?.role === 'assistant' &&
call[2]?.runId === 'run-live-endedat-succeeded' &&
call[2]?.runStatus === 'succeeded',
);
expect(succeededSave).toBeTruthy();
});
const succeededSave = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-live-endedat-succeeded' &&
call[2]?.role === 'assistant' &&
call[2]?.runId === 'run-live-endedat-succeeded' &&
call[2]?.runStatus === 'succeeded',
);
expect(succeededSave?.[2]?.endedAt).toBe(daemonTerminalUpdatedAt);
});
// Regression (#4607): a reload while a run is still active fetches the
// daemon status exactly once, before reattachDaemonRun starts. For the
// ordinary reload-while-running path (no generic disconnect in the mix)
// that one snapshot is still 'running' — a near-run-start heartbeat, not
// a terminal completion time. The reattach onDone handler must not stamp
// that heartbeat as endedAt; it must observe an authoritative terminal
// status before finalizing, so endedAt advances past the initial running
// snapshot instead of freezing at it.
it('advances the recovered endedAt past the initial running snapshot when a reload reattaches an active run', async () => {
const runCreatedAt = Date.now();
const initialRunningUpdatedAt = runCreatedAt + 1;
// Far enough past the pre-reattach heartbeat that the two timestamps can
// never coincide by accident.
const daemonTerminalUpdatedAt = runCreatedAt + 10_000_000;
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-reload-active-run',
role: 'assistant',
content: '',
createdAt: runCreatedAt,
startedAt: runCreatedAt,
runId: 'run-reload-active',
runStatus: 'running',
producedFiles: [],
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus
.mockResolvedValueOnce({
id: 'run-reload-active',
status: 'running',
createdAt: runCreatedAt,
updatedAt: initialRunningUpdatedAt,
exitCode: null,
signal: null,
})
.mockResolvedValueOnce({
id: 'run-reload-active',
status: 'succeeded',
createdAt: runCreatedAt,
updatedAt: daemonTerminalUpdatedAt,
exitCode: 0,
signal: null,
});
// A plain, error-free reattach: the daemon stream simply finishes.
reattachDaemonRun.mockImplementation(async (options: {
handlers: { onDone: () => void };
}) => {
options.handlers.onDone();
});
render(
<ProjectView
project={{ id: 'project-reload-active-endedat', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => {
expect(saveMessage).toHaveBeenCalledWith(
'project-reload-active-endedat',
'conv-1',
expect.objectContaining({
id: 'msg-reload-active-run',
runStatus: 'succeeded',
}),
expect.objectContaining({ telemetryFinalized: true }),
);
});
const recoveredSave = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-reload-active-endedat' &&
call[2]?.id === 'msg-reload-active-run' &&
call[2]?.runStatus === 'succeeded',
);
// Must land on the daemon's authoritative terminal time, not the
// pre-reattach 'running' heartbeat fetched before recovery started.
expect(recoveredSave?.[2]?.endedAt).toBe(daemonTerminalUpdatedAt);
expect(recoveredSave?.[2]?.endedAt).not.toBe(initialRunningUpdatedAt);
});
// Regression (#4607): updateConversationLatestRun() drives the sidebar/
// dropdown's conversation.updatedAt, latestRun.endedAt, and durationMs.
// The live generic-disconnect onError handler already patches the
// assistant MESSAGE row with the daemon's authoritative terminal
// timestamp once the retry-cap status probe turns terminal, but the
// `endedAt` threaded into updateConversationLatestRun() is still the
// stale disconnect-time stamp captured at the top of onError. This
// regression asserts the conversation-level metadata advances too.
it('threads the daemon terminal timestamp into the conversation latestRun after a live generic disconnect recovers as succeeded', async () => {
const runCreatedAt = Date.now();
const daemonTerminalUpdatedAt = runCreatedAt + 10_000_000;
const genericDisconnect = await createGenericDisconnectError();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus.mockResolvedValue({
id: 'run-conv-endedat-succeeded',
status: 'succeeded',
createdAt: runCreatedAt,
updatedAt: daemonTerminalUpdatedAt,
exitCode: 0,
signal: null,
});
streamViaDaemon.mockImplementation(async (options: {
onRunCreated?: (runId: string) => void;
handlers: { onError: (error: Error) => Promise<void> };
}) => {
options.onRunCreated?.('run-conv-endedat-succeeded');
await options.handlers.onError(genericDisconnect);
await options.handlers.onError(genericDisconnect);
});
chatPaneSpy.mockClear();
render(
<ProjectView
project={{ id: 'project-conv-endedat-succeeded', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
const sendProps = await waitForReadyChatPaneProps();
await sendProps!.onSend!('terminal success after disconnect (conversation endedAt check)', [], []);
await waitFor(() => {
const succeededSave = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-conv-endedat-succeeded' &&
call[2]?.role === 'assistant' &&
call[2]?.runId === 'run-conv-endedat-succeeded' &&
call[2]?.runStatus === 'succeeded',
);
expect(succeededSave).toBeTruthy();
});
const conversations = chatPaneSpy.mock.calls.at(-1)?.[0]?.conversations as
| Array<{ id: string; latestRun?: { endedAt?: number; durationMs?: number } }>
| undefined;
const conversation = conversations?.find((c) => c.id === 'conv-1');
// The conversation-level metadata must adopt the same daemon terminal
// timestamp as the message row, not the stale disconnect-time stamp
// captured before the terminal probe resolved.
expect(conversation?.latestRun?.endedAt).toBe(daemonTerminalUpdatedAt);
});
// Regression (#4607): proves neither surface — the assistant message row
// nor the conversation's latestRun metadata — keeps the stale
// disconnect-time stamp after a generic-disconnect recovery. Uses a
// terminal 'failed' probe (rather than 'succeeded') to cover the sibling
// branch of the same onError handler.
it('advances both the message row and the conversation latestRun to the daemon terminal time when a live generic disconnect probe turns failed', async () => {
const runCreatedAt = Date.now();
const daemonTerminalUpdatedAt = runCreatedAt + 10_000_000;
const genericDisconnect = await createGenericDisconnectError() as Error & {
resumable?: boolean;
};
genericDisconnect.resumable = false;
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus.mockResolvedValue({
id: 'run-both-endedat-failed',
status: 'failed',
createdAt: runCreatedAt,
updatedAt: daemonTerminalUpdatedAt,
exitCode: 1,
signal: null,
resumable: true,
});
streamViaDaemon.mockImplementation(async (options: {
onRunCreated?: (runId: string) => void;
handlers: { onError: (error: Error) => Promise<void> };
}) => {
options.onRunCreated?.('run-both-endedat-failed');
await options.handlers.onError(genericDisconnect);
await options.handlers.onError(genericDisconnect);
});
chatPaneSpy.mockClear();
render(
<ProjectView
project={{ id: 'project-both-endedat-failed', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
const sendProps = await waitForReadyChatPaneProps();
await sendProps!.onSend!('terminal failed after disconnect (both surfaces check)', [], []);
// Match on `resumable === true` (the daemon-probed terminal value) to
// land on the FINAL save, not the interim 'failed' save the first
// onError call persists before the retry-cap probe ever fires (that
// interim save carries `resumable: false` from the disconnect error
// itself, per the same disambiguation the pre-existing "patches live
// generic-disconnect terminal metadata from a failed daemon status"
// test above uses).
await waitFor(() => {
const failedSave = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-both-endedat-failed' &&
call[2]?.role === 'assistant' &&
call[2]?.runId === 'run-both-endedat-failed' &&
call[2]?.runStatus === 'failed' &&
call[2]?.resumable === true,
);
expect(failedSave).toBeTruthy();
});
const failedSave = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-both-endedat-failed' &&
call[2]?.role === 'assistant' &&
call[2]?.runId === 'run-both-endedat-failed' &&
call[2]?.runStatus === 'failed' &&
call[2]?.resumable === true,
);
// Message row surface: already threads the daemon terminal timestamp.
expect(failedSave?.[2]?.endedAt).toBe(daemonTerminalUpdatedAt);
const conversations = chatPaneSpy.mock.calls.at(-1)?.[0]?.conversations as
| Array<{ id: string; latestRun?: { endedAt?: number; durationMs?: number } }>
| undefined;
const conversation = conversations?.find((c) => c.id === 'conv-1');
// Conversation-metadata surface: must not keep the stale disconnect-time
// stamp while the message row has already moved on.
expect(conversation?.latestRun?.endedAt).toBe(daemonTerminalUpdatedAt);
});
// Regression (#4607): the reattach onError handler's recoverable-artifact
// branch (entered when a non-generic-disconnect error still carries
// artifact-shaped replayed content) first stamps the message with the
// disconnect-time endedAt synchronously, then later fetches the daemon's
// authoritative terminal status to recover the artifact. The final
// updateMessageById call must adopt that authoritative terminal time —
// not fall back to the disconnect-time stamp `prev.endedAt` already
// carries by the time it runs.
it('adopts the daemon terminal time instead of the disconnect stamp when a reattach recovers an artifact after a non-generic error', async () => {
const runCreatedAt = Date.now();
const initialRunningUpdatedAt = runCreatedAt + 1;
// Far enough past any disconnect-time Date.now() stamp taken during this
// test run that the two timestamps can never coincide by accident.
const daemonTerminalUpdatedAt = runCreatedAt + 10_000_000;
const artifactContent =
'<artifact identifier="recoverable-artifact" type="text/html" title="Recoverable Artifact">' +
'<!doctype html><html><body><h1>Recovered</h1></body></html>' +
'</artifact>';
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-recoverable-artifact-endedat',
role: 'assistant',
content: '',
createdAt: runCreatedAt,
startedAt: runCreatedAt,
runId: 'run-recoverable-artifact-endedat',
runStatus: 'running',
producedFiles: [],
},
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchProjectDesignSystemPackageAudit.mockResolvedValue(null);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
fetchChatRunStatus
.mockResolvedValueOnce({
id: 'run-recoverable-artifact-endedat',
status: 'running',
createdAt: runCreatedAt,
updatedAt: initialRunningUpdatedAt,
exitCode: null,
signal: null,
})
.mockResolvedValueOnce({
id: 'run-recoverable-artifact-endedat',
status: 'succeeded',
createdAt: runCreatedAt,
updatedAt: daemonTerminalUpdatedAt,
exitCode: 0,
signal: null,
});
reattachDaemonRun.mockImplementation(async (options: {
handlers: {
onDelta: (delta: string) => void;
onError: (error: Error) => Promise<void>;
};
}) => {
options.handlers.onDelta(artifactContent);
// A genuine (non-generic-disconnect) failure — the daemon stream
// really dropped mid-turn, not a browser SSE reconnect-budget
// exhaustion, so the generic-disconnect retry/backoff machinery must
// not apply here.
await options.handlers.onError(new Error('some real non-generic failure'));
});
render(
<ProjectView
project={{ id: 'project-recoverable-artifact-endedat', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
await waitFor(() => {
const recoveredSave = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-recoverable-artifact-endedat' &&
call[2]?.id === 'msg-recoverable-artifact-endedat' &&
call[2]?.runStatus === 'succeeded',
);
expect(recoveredSave).toBeTruthy();
});
const recoveredSave = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-recoverable-artifact-endedat' &&
call[2]?.id === 'msg-recoverable-artifact-endedat' &&
call[2]?.runStatus === 'succeeded',
);
// Must land on the daemon's authoritative terminal time, not the
// disconnect-time stamp the synchronous onError handler wrote onto
// `prev.endedAt` moments earlier.
expect(recoveredSave?.[2]?.endedAt).toBe(daemonTerminalUpdatedAt);
});
});