diff --git a/.project-docs/30-worklog/tasks/20260903-diagnose-recurring-stall-a83f5c21.md b/.project-docs/30-worklog/tasks/20260903-diagnose-recurring-stall-a83f5c21.md new file mode 100644 index 0000000..721d4a4 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260903-diagnose-recurring-stall-a83f5c21.md @@ -0,0 +1,107 @@ +# Task: Diagnose recurring Coding session stall + +## Identity + +- Task ID: 20260903-diagnose-recurring-stall-a83f5c21 +- Mode: Feature +- Branch: codex/20260903-diagnose-recurring-stall-a83f5c21-diagnose-recurring-stall +- Worktree: /Users/inmanx/Documents/makelore-diagnose-recurring-stall-a83f5c21 +- Base commit: 301c1496b0a3a1af3b9443a68a67de8dfea45749 +- Owner: codex +- Status: Completed + +## Scope + +- Diagnose the recurring visible `处理中` state in the installed macOS app for + project `肉鸽2.0`, including the active Pi logical thread that began around + 09:51 on 2026-09-03. +- Verify whether the installed artifact actually contains the previously integrated + terminal-settlement and Bash tool-bridge fixes. +- Correlate the screenshot with the installed Main log, persisted Conversation/Pi + session, target process state, and any active tool child before deciding whether + this is useful work, a tool wait, a Provider failure, or another settlement gap. + +## Intent And Constraints + +- Keep diagnosis-time live inspection read-only: do not abort the target run, + replay the accepted prompt, or mutate user project/session data. For the user's + later explicit launch request, replace the old installed process only by a + graceful quit after confirming the target had no remaining runtime/tool child; + do not force-kill it. +- Preserve ADR-006 target-only failure and no-replay semantics. +- Do not touch the occupied `main` worktree or its uncommitted packaging changes; + perform any source change only in this isolated worktree after evidence identifies + a concrete defect. + +## Outcome + +- Confirmed that this recurrence was not an active model or Bash wait. The target + Pi JSONL persisted an assistant terminal entry with `stopReason: "aborted"` + and `errorMessage: "Request was aborted"` at 09:52:24 local time. Electron + Main then completed the same Conversation with the intentional + `project_deactivated` reason at 09:52:33. The remaining Agent Server was idle + at 0% CPU with no tool child or network socket while Renderer still showed + `处理中`. +- Confirmed the installed app already contains the earlier Bash write-lease and + bounded terminal-settlement corrections. This incident instead repeated the + foreground recovery gap documented by task + `20260902-check-session-after-bash-fix-a6d12f39`: Renderer retained a cached + `live` Snapshot after Main had stopped the target, and selecting an already + selected Conversation skipped authoritative Snapshot hydration. +- Changed Conversation selection to always reconcile with Main. Existing live + state refreshes silently, while cold and invalidated targets keep their prior + loading/recovery behavior. Accepted or uncertain mutations are not replayed. +- Coding Chat now closes its old event stream on Main's `lifecycle:sleep`, + refreshes the selected Conversation when the window becomes visible or focused, + and refreshes a still-selected Conversation once when the programming view is + mounted or its project context changes. A project/Conversation selection key + prevents duplicate hydration caused by the selection render itself. +- Added store, Renderer, and Electron regressions that begin with a stale running + Snapshot and verify that foreground recovery displays the persisted aborted + terminal state without posting a prompt. + +## Verification + +- Correlated screenshot time, installed Main log, project registry, Conversation + metadata, target Pi JSONL, and live PID/process/socket state. The durable turn + ended before the visible timer continued; no running tool process remained. +- Installed `app.asar` contains `state_probe`, the bounded terminal-settlement + failure string, and `PROMPT_FAILED_AFTER_ACCEPTANCE`, confirming the prior fix + was packaged in the inspected app. +- Focused Vitest: `45 passed` across + `coding-conversations-store.test.tsx` and `coding-chat-panel.test.tsx`. +- Full Vitest: `1736 passed`, `3 skipped` across 213 test files, including the + single-worker pressure suite. +- Focused Electron E2E: + `foreground focus rehydrates a terminal Snapshot after lifecycle sleep` passed. +- TypeScript `tsc --noEmit`, scoped ESLint, production `build:vite`, and + `git diff --check` passed. Full `lint:check` passed with five pre-existing + warnings and zero errors. +- `check_project_docs.py` and task-aware `check_doc_drift.py` passed. +- The old installed app was gracefully quit after confirming no target Agent + Server or tool child remained. `pnpm run dev` then started the repaired + worktree successfully, with the Renderer on port 5173 and Electron using the + existing Makelore user-data directory. + +## Follow-ups + +- Integrate this feature branch after the occupied `main` packaging worktree is + available, then rebuild/reinstall the macOS artifact. The currently installed + `/Applications/Makelore.app` remains the older build; the repaired behavior is + available from this task worktree for development verification. + +## Promotion Candidates + +- Target: `README.md`, `20-architecture/data-flow.md`, and + `30-worklog/current-state.md` during Integration Mode. + Proposal: record the Renderer foreground rehydration contract: lifecycle sleep + closes the old Coding event stream, and view mount, project reselection, + visibility, or focus reconciles the selected Conversation from a Main-owned + Snapshot without replaying accepted mutations. + Evidence: focused store/Renderer tests, real IPC Electron E2E, and the two + installed-app stale-running incidents documented by this task and + `20260902-check-session-after-bash-fix-a6d12f39`. + Future impact: any new Coding navigation or lifecycle surface must preserve the + same terminal-state reconciliation and no-replay behavior. + Human confirmation: not required; this promotes implemented behavior without + changing ADR-006 ownership or product direction. diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index 1770f85..35bdd7b 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -17,6 +17,7 @@ import { abortCodingConversation, forkCodingConversation, } from '@/lib/coding-conversations'; +import { subscribeHostEvent } from '@/lib/host-events'; import { codingConversationStore, selectCodingConversationDraft, @@ -148,6 +149,7 @@ export function CodingChatPanel({ >({}); const appliedNavigationDraftRef = useRef(null); const automaticCreationKeyRef = useRef(null); + const selectedConversationContextRef = useRef(null); const attachmentsRef = useRef(attachmentsByDraftKey); const uploadedAttachmentsRef = useRef(new Map()); const uploadFlightsRef = useRef(new Map>()); @@ -183,6 +185,11 @@ export function CodingChatPanel({ attachmentsRef.current = attachmentsByDraftKey; + const selectProjectConversation = useCallback((projectId: string, conversationId: string) => { + selectedConversationContextRef.current = `${projectId}:${conversationId}`; + return selectConversation(conversationId); + }, [selectConversation]); + const selectDraft = useCallback((state: CodingConversationStoreState) => ( targetConversationId ? selectCodingConversationDraft(targetConversationId)(state) @@ -236,6 +243,24 @@ export function CodingChatPanel({ useEffect(() => () => disconnectEvents(), [disconnectEvents]); + useEffect(() => subscribeHostEvent('lifecycle:sleep', () => { + disconnectEvents(); + }), [disconnectEvents]); + + useEffect(() => { + if (!targetConversationId) return undefined; + const refreshVisibleConversation = () => { + if (document.visibilityState !== 'visible') return; + void selectConversation(targetConversationId).catch(() => undefined); + }; + window.addEventListener('focus', refreshVisibleConversation); + document.addEventListener('visibilitychange', refreshVisibleConversation); + return () => { + window.removeEventListener('focus', refreshVisibleConversation); + document.removeEventListener('visibilitychange', refreshVisibleConversation); + }; + }, [selectConversation, targetConversationId]); + useEffect(() => () => { for (const attachments of Object.values(attachmentsRef.current)) { for (const attachment of attachments) URL.revokeObjectURL(attachment.previewUrl); @@ -251,15 +276,23 @@ export function CodingChatPanel({ useEffect(() => { if (!activeProject || !selectedAgent) { + selectedConversationContextRef.current = null; clearConversationSelection(); return; } - if (selectedConversation?.agentId === selectedAgent.id && !selectedConversation.archivedAt) return; + if (selectedConversation?.agentId === selectedAgent.id && !selectedConversation.archivedAt) { + const selectionContext = `${activeProject.id}:${selectedConversation.id}`; + if (selectedConversationContextRef.current !== selectionContext) { + void selectProjectConversation(activeProject.id, selectedConversation.id) + .catch(() => undefined); + } + return; + } const next = newestConversation(conversations, selectedAgent.id); if (next) { markConversationUnread(next.id, false); if (next.unread) void patchConversation(next.id, { unread: false }).catch(() => undefined); - void selectConversation(next.id).catch(() => undefined); + void selectProjectConversation(activeProject.id, next.id).catch(() => undefined); return; } const creationKey = `${activeProject.id}:${selectedAgent.id}`; @@ -271,7 +304,8 @@ export function CodingChatPanel({ const current = codingWorkspaceStore.getState(); if (current.activeProjectId === activeProject.id && current.selectedAgentId === selectedAgent.id) { - void selectConversation(conversation.id).catch(() => undefined); + void selectProjectConversation(activeProject.id, conversation.id) + .catch(() => undefined); } }) .catch(() => undefined); @@ -283,7 +317,7 @@ export function CodingChatPanel({ markConversationUnread, patchConversation, primeConversation, - selectConversation, + selectProjectConversation, selectedAgent, selectedConversation, ]); @@ -332,8 +366,8 @@ export function CodingChatPanel({ if (conversation.unread) { void patchConversation(conversation.id, { unread: false }).catch(() => undefined); } - void selectConversation(conversation.id).catch(() => undefined); - }, [activeProject, markConversationUnread, patchConversation, primeConversation, selectConversation]); + void selectProjectConversation(activeProject.id, conversation.id).catch(() => undefined); + }, [activeProject, markConversationUnread, patchConversation, primeConversation, selectProjectConversation]); const handleCreateConversation = useCallback(async () => { if (!activeProject || !selectedAgent || creatingAgentIds[selectedAgent.id]) return; @@ -344,14 +378,14 @@ export function CodingChatPanel({ primeConversation(createLocalConversationSnapshot(projectId, conversation)); const current = codingWorkspaceStore.getState(); if (current.activeProjectId === projectId && current.selectedAgentId === agentId) { - await selectConversation(conversation.id).catch(() => undefined); + await selectProjectConversation(projectId, conversation.id).catch(() => undefined); } }, [ activeProject, createConversation, creatingAgentIds, primeConversation, - selectConversation, + selectProjectConversation, selectedAgent, ]); @@ -369,9 +403,9 @@ export function CodingChatPanel({ if (workspace.activeProjectId === sourceProjectId && workspace.selectedAgentId === sourceAgentId && selectedId === sourceConversationId) { - await selectConversation(forked.id); + await selectProjectConversation(sourceProjectId, forked.id); } - }, [activeProject, primeConversation, selectConversation, selectedAgent, targetConversationId, upsertConversation]); + }, [activeProject, primeConversation, selectProjectConversation, selectedAgent, targetConversationId, upsertConversation]); const handleAddFiles = useCallback((files: File[]) => { if (!draftKey diff --git a/src/stores/coding-conversations.ts b/src/stores/coding-conversations.ts index 5547030..55b56e5 100644 --- a/src/stores/coding-conversations.ts +++ b/src/stores/coding-conversations.ts @@ -538,14 +538,12 @@ export function createCodingConversationStore( }; }); const entry = get().entriesByConversationId[conversationId]; - const snapshotFlight = !entry?.reducer.snapshot - || entry.reducer.invalidation - || entry.loadState !== 'live' - ? get().loadSnapshot( - conversationId, - entry?.reducer.invalidation ? 'recovering' : 'loading', - ) - : Promise.resolve(entry.reducer.snapshot); + const refreshMode = entry?.reducer.invalidation + ? 'recovering' + : entry?.reducer.snapshot && entry.loadState === 'live' + ? 'silent' + : 'loading'; + const snapshotFlight = get().loadSnapshot(conversationId, refreshMode); await Promise.all([snapshotFlight, get().connectEvents()]); }, diff --git a/tests/e2e/pi-coding-first-chat.spec.ts b/tests/e2e/pi-coding-first-chat.spec.ts index 8ed3a96..c7127b7 100644 --- a/tests/e2e/pi-coding-first-chat.spec.ts +++ b/tests/e2e/pi-coding-first-chat.spec.ts @@ -19,6 +19,11 @@ type HostConnection = { async function disableCodingEventSource(page: Page): Promise { await page.addInitScript(() => { const sources = new Set(); + type TrackedWindow = Window & { + __makeloreCodingEventSources?: LocalEventSource[]; + }; + const trackedWindow = window as TrackedWindow; + trackedWindow.__makeloreCodingEventSources = []; class LocalEventSource extends EventTarget { static readonly CONNECTING = 0; static readonly OPEN = 1; @@ -37,6 +42,7 @@ async function disableCodingEventSource(page: Page): Promise { super(); this.url = url; sources.add(this); + trackedWindow.__makeloreCodingEventSources?.push(this); queueMicrotask(() => this.onopen?.(new Event('open'))); } @@ -86,6 +92,7 @@ async function installCodingFirstChatHost( abortRequested: boolean; releaseSnapshot: (() => void) | null; snapshotPending: boolean; + snapshotSettled: boolean; }; const mainGlobal = globalThis as typeof globalThis & { __makelorePiFirstChatE2E?: MainState; @@ -97,6 +104,7 @@ async function installCodingFirstChatHost( abortRequested: false, releaseSnapshot: null, snapshotPending: false, + snapshotSettled: false, }; mainGlobal.__makelorePiFirstChatE2E = state; const now = '2026-08-24T00:00:00.000Z'; @@ -530,11 +538,28 @@ async function installCodingFirstChatHost( await new Promise((resolve) => { state.releaseSnapshot = resolve; }); state.snapshotPending = false; } + const currentSnapshot = state.snapshotSettled + ? { + ...snapshot, + run: { + status: 'idle', + runId: 'run-e2e-feature', + mode: 'prompt', + startedAt: 1_000, + settledAt: 2_000, + terminalReason: 'aborted', + }, + queue: { items: [] }, + pendingInteractions: [], + worker: { status: 'stopped', generation: 1 }, + cursor: { workerGeneration: 1, seq: 1 }, + } + : snapshot; return respond({ snapshot: state.abortRequested ? { - ...snapshot, - nodes: snapshot.nodes.map((node) => { + ...currentSnapshot, + nodes: currentSnapshot.nodes.map((node) => { if (node.kind === 'message' && node.role === 'assistant') { return { ...node, @@ -567,8 +592,8 @@ async function installCodingFirstChatHost( cursor: { workerGeneration: 1, seq: 1 }, } : state.interactionAnswered - ? { ...snapshot, pendingInteractions: [] } - : snapshot, + ? { ...currentSnapshot, pendingInteractions: [] } + : currentSnapshot, }); } if (path === `/api/coding/conversations/${secondConversation.id}/snapshot`) { @@ -652,6 +677,17 @@ async function releaseSnapshot(electronApp: ElectronApplication): Promise }); } +async function settleSnapshot(electronApp: ElectronApplication): Promise { + await electronApp.evaluate(() => { + const mainGlobal = globalThis as typeof globalThis & { + __makelorePiFirstChatE2E?: { snapshotSettled: boolean }; + }; + if (mainGlobal.__makelorePiFirstChatE2E) { + mainGlobal.__makelorePiFirstChatE2E.snapshotSettled = true; + } + }); +} + test('first PI Conversation is editable under 500 ms and submits before runtime Snapshot', async ({ launchElectronApp, }) => { @@ -903,6 +939,60 @@ test('hidden Conversation badge ignores process failures until interaction or ta } }); +test('foreground focus rehydrates a terminal Snapshot after lifecycle sleep', async ({ + launchElectronApp, +}) => { + const electronApp = await launchElectronApp({ skipSetup: true }); + let page = await getStableWindow(electronApp); + const hostConnection = await page.evaluate(async () => ({ + token: await window.electron.ipcRenderer.invoke('hostapi:token') as string, + baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string, + })); + await installCodingFirstChatHost(electronApp, hostConnection, true); + await disableCodingEventSource(page); + + await page.reload(); + page = await getStableWindow(electronApp); + await page.getByTestId('ai-module-option-programming').click(); + await expect(page.getByTestId('main-layout')).toBeVisible(); + await page.evaluate(() => { window.location.hash = '/chat'; }); + + const processGroup = page.getByTestId('coding-process-group'); + await expect(processGroup).toHaveAttribute('data-process-state', 'active'); + await expect(processGroup.locator('summary').first()).toContainText('处理中'); + const snapshotPath = '/api/coding/conversations/conversation-pi-first-chat/snapshot'; + const stateBeforeSleep = await readState(electronApp); + const snapshotCallsBeforeSleep = stateBeforeSleep.captured.filter((request) => ( + request.path === snapshotPath + )).length; + await settleSnapshot(electronApp); + + await electronApp.evaluate(({ BrowserWindow }) => { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send('lifecycle:sleep'); + } + }); + await expect.poll(async () => page.evaluate(() => { + const trackedWindow = window as typeof window & { + __makeloreCodingEventSources?: Array<{ readyState: number }>; + }; + return trackedWindow.__makeloreCodingEventSources?.at(-1)?.readyState ?? -1; + })).toBe(2); + + await page.evaluate(() => window.dispatchEvent(new FocusEvent('focus'))); + + await expect(processGroup).toHaveAttribute('data-process-state', 'settled'); + await expect(processGroup.locator('summary').first()).not.toContainText('处理中'); + await expect.poll(async () => { + const state = await readState(electronApp); + return state.captured.filter((request) => request.path === snapshotPath).length; + }).toBeGreaterThan(snapshotCallsBeforeSleep); + const stateAfterFocus = await readState(electronApp); + expect(stateAfterFocus.captured.some((request) => ( + request.path === '/api/coding/conversations/conversation-pi-first-chat/prompt' + ))).toBe(false); +}); + test('PI feature UI isolates Conversations and exposes queue, interaction, model, and subagent state', async ({ launchElectronApp, }) => { diff --git a/tests/unit/coding-chat-panel.test.tsx b/tests/unit/coding-chat-panel.test.tsx index b22b4e0..28b85cf 100644 --- a/tests/unit/coding-chat-panel.test.tsx +++ b/tests/unit/coding-chat-panel.test.tsx @@ -144,6 +144,7 @@ const conversationApi = vi.hoisted(() => ({ diagnostics: vi.fn(), })); const attachmentApi = vi.hoisted(() => ({ upload: vi.fn() })); +const hostEventApi = vi.hoisted(() => ({ subscribe: vi.fn() })); vi.mock('@/lib/coding-projects', () => ({ listCodingProjects: projectApi.list, @@ -175,8 +176,13 @@ vi.mock('@/lib/coding-attachments', async (importOriginal) => ({ uploadCodingAttachment: attachmentApi.upload, })); +vi.mock('@/lib/host-events', () => ({ + subscribeHostEvent: hostEventApi.subscribe, +})); + describe('CodingChatPanel first Conversation', () => { beforeEach(() => { + hostEventApi.subscribe.mockReturnValue(vi.fn()); Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn((file: File) => `blob:${file.name}`), @@ -231,6 +237,133 @@ describe('CodingChatPanel first Conversation', () => { expect(source.close).toHaveBeenCalled(); }); + it('rehydrates a preselected stale Conversation when the programming view mounts', async () => { + projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); + projectApi.config.mockResolvedValue({ project, config }); + projectApi.conversations.mockResolvedValue([conversation]); + conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource); + conversationApi.recover.mockResolvedValue(undefined); + const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); + const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot'); + const { codingConversationStore } = await import('@/stores/coding-conversations'); + const { codingWorkspaceStore } = await import('@/stores/coding-workspace'); + const localSnapshot = createLocalConversationSnapshot(project.id, conversation); + codingWorkspaceStore.setState({ + activeProjectId: project.id, + activeProject: project, + config, + conversations: [conversation], + selectedAgentId: agent.id, + }); + codingConversationStore.getState().applySnapshotEvent({ + type: 'snapshot', + conversationId: conversation.id, + workerGeneration: 1, + seq: 4, + snapshot: { + ...localSnapshot, + run: { + status: 'running', + runId: 'run-stale', + mode: 'prompt', + startedAt: 1_000, + }, + worker: { status: 'ready', generation: 1 }, + cursor: { workerGeneration: 1, seq: 4 }, + }, + }); + codingConversationStore.setState({ selectedConversationId: conversation.id }); + conversationApi.snapshot.mockResolvedValue({ + ...localSnapshot, + run: { + status: 'idle', + runId: 'run-stale', + mode: 'prompt', + startedAt: 1_000, + settledAt: 2_000, + terminalReason: 'aborted', + }, + worker: { status: 'stopped', generation: 1 }, + cursor: { workerGeneration: 1, seq: 5 }, + }); + + render(); + + await waitFor(() => expect( + codingConversationStore.getState().entriesByConversationId[conversation.id]?.reducer.snapshot?.run, + ).toMatchObject({ status: 'idle', terminalReason: 'aborted' })); + expect(conversationApi.snapshot).toHaveBeenCalledWith(conversation.id); + expect(screen.queryByText('处理中')).not.toBeInTheDocument(); + expect(conversationApi.submit).not.toHaveBeenCalled(); + }); + + it('rehydrates a stale running Conversation after lifecycle sleep and window focus', async () => { + let sleepHandler: (() => void) | null = null; + hostEventApi.subscribe.mockImplementation((eventName: string, handler: () => void) => { + if (eventName === 'lifecycle:sleep') sleepHandler = handler; + return vi.fn(); + }); + projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); + projectApi.config.mockResolvedValue({ project, config }); + projectApi.conversations.mockResolvedValue([conversation]); + projectApi.patch.mockResolvedValue(conversation); + const firstSource = new FakeEventSource(); + const resumedSource = new FakeEventSource(); + conversationApi.events + .mockResolvedValueOnce(firstSource as unknown as EventSource) + .mockResolvedValueOnce(resumedSource as unknown as EventSource); + conversationApi.recover.mockResolvedValue(undefined); + const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); + const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot'); + const { codingConversationStore } = await import('@/stores/coding-conversations'); + const localSnapshot = createLocalConversationSnapshot(project.id, conversation); + const runningSnapshot: ConversationSnapshot = { + ...localSnapshot, + run: { + status: 'running', + runId: 'run-stale', + mode: 'prompt', + startedAt: 1_000, + }, + worker: { status: 'ready', generation: 1 }, + cursor: { workerGeneration: 1, seq: 4 }, + }; + const terminalSnapshot: ConversationSnapshot = { + ...runningSnapshot, + run: { + status: 'idle', + runId: 'run-stale', + mode: 'prompt', + startedAt: 1_000, + settledAt: 2_000, + terminalReason: 'aborted', + }, + worker: { status: 'stopped', generation: 1 }, + cursor: { workerGeneration: 1, seq: 5 }, + }; + conversationApi.snapshot.mockResolvedValue(runningSnapshot); + render(); + + await waitFor(() => expect( + codingConversationStore.getState().entriesByConversationId[conversation.id]?.reducer.snapshot?.run.status, + ).toBe('running')); + expect(screen.getByText('处理中')).toBeInTheDocument(); + const snapshotCallsBeforeSleep = conversationApi.snapshot.mock.calls.length; + conversationApi.snapshot.mockResolvedValue(terminalSnapshot); + + act(() => sleepHandler?.()); + expect(firstSource.close).toHaveBeenCalledOnce(); + act(() => window.dispatchEvent(new Event('focus'))); + + await waitFor(() => expect( + codingConversationStore.getState().entriesByConversationId[conversation.id]?.reducer.snapshot?.run, + ).toMatchObject({ status: 'idle', terminalReason: 'aborted' })); + expect(conversationApi.snapshot.mock.calls.length).toBeGreaterThan(snapshotCallsBeforeSleep); + expect(conversationApi.events).toHaveBeenCalledTimes(2); + expect(screen.queryByText('处理中')).not.toBeInTheDocument(); + expect(conversationApi.submit).not.toHaveBeenCalled(); + }); + it('keeps the new core Chat Renderer free of legacy OpenCode imports', async () => { const files = [ '../../src/pages/Chat/CodingChatPanel.tsx', diff --git a/tests/unit/coding-conversations-store.test.tsx b/tests/unit/coding-conversations-store.test.tsx index b83a4f2..0a7fffd 100644 --- a/tests/unit/coding-conversations-store.test.tsx +++ b/tests/unit/coding-conversations-store.test.tsx @@ -486,6 +486,61 @@ describe('coding Conversation store', () => { expect(submitPrompt).toHaveBeenCalledTimes(1); }); + it('rehydrates a selected live Conversation without replaying an accepted mutation', async () => { + const source = new FakeEventSource(); + const refresh = deferred(); + const staleRunning = { + ...snapshot('conversation-a', 1, 4), + run: { + status: 'running', + runId: 'run-stale', + mode: 'prompt', + startedAt: 1_000, + } as const, + }; + const persistedTerminal = { + ...snapshot('conversation-a', 1, 5), + run: { + status: 'idle', + runId: 'run-stale', + mode: 'prompt', + startedAt: 1_000, + settledAt: 2_000, + terminalReason: 'aborted', + } as const, + }; + const getSnapshot = vi.fn() + .mockResolvedValueOnce(staleRunning) + .mockImplementationOnce(() => refresh.promise); + const openEvents = vi.fn(async () => source as unknown as EventSource); + const submitPrompt = vi.fn(); + const store = createCodingConversationStore({ + getSnapshot, + openEvents, + submitPrompt, + createId: ids(), + }); + + await store.getState().selectConversation('conversation-a'); + const reselection = store.getState().selectConversation('conversation-a'); + + expect(getSnapshot).toHaveBeenCalledTimes(2); + expect(openEvents).toHaveBeenCalledTimes(1); + expect(store.getState().entriesByConversationId['conversation-a']).toMatchObject({ + loadState: 'live', + error: null, + }); + expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.run.status) + .toBe('running'); + + refresh.resolve(persistedTerminal); + await reselection; + + expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.run) + .toMatchObject({ status: 'idle', terminalReason: 'aborted' }); + expect(submitPrompt).not.toHaveBeenCalled(); + }); + it('does not let a cancelled connection clear a newer connection flight', async () => { const first = deferred(); const second = deferred();