merge: integrate foreground conversation reconciliation
# Conflicts: # tests/e2e/pi-coding-first-chat.spec.ts
This commit is contained in:
@@ -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.
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
abortCodingConversation,
|
abortCodingConversation,
|
||||||
forkCodingConversation,
|
forkCodingConversation,
|
||||||
} from '@/lib/coding-conversations';
|
} from '@/lib/coding-conversations';
|
||||||
|
import { subscribeHostEvent } from '@/lib/host-events';
|
||||||
import {
|
import {
|
||||||
codingConversationStore,
|
codingConversationStore,
|
||||||
selectCodingConversationDraft,
|
selectCodingConversationDraft,
|
||||||
@@ -148,6 +149,7 @@ export function CodingChatPanel({
|
|||||||
>({});
|
>({});
|
||||||
const appliedNavigationDraftRef = useRef<string | null>(null);
|
const appliedNavigationDraftRef = useRef<string | null>(null);
|
||||||
const automaticCreationKeyRef = useRef<string | null>(null);
|
const automaticCreationKeyRef = useRef<string | null>(null);
|
||||||
|
const selectedConversationContextRef = useRef<string | null>(null);
|
||||||
const attachmentsRef = useRef(attachmentsByDraftKey);
|
const attachmentsRef = useRef(attachmentsByDraftKey);
|
||||||
const uploadedAttachmentsRef = useRef(new Map<string, CodingAttachmentRef>());
|
const uploadedAttachmentsRef = useRef(new Map<string, CodingAttachmentRef>());
|
||||||
const uploadFlightsRef = useRef(new Map<string, Promise<CodingAttachmentRef>>());
|
const uploadFlightsRef = useRef(new Map<string, Promise<CodingAttachmentRef>>());
|
||||||
@@ -183,6 +185,11 @@ export function CodingChatPanel({
|
|||||||
|
|
||||||
attachmentsRef.current = attachmentsByDraftKey;
|
attachmentsRef.current = attachmentsByDraftKey;
|
||||||
|
|
||||||
|
const selectProjectConversation = useCallback((projectId: string, conversationId: string) => {
|
||||||
|
selectedConversationContextRef.current = `${projectId}:${conversationId}`;
|
||||||
|
return selectConversation(conversationId);
|
||||||
|
}, [selectConversation]);
|
||||||
|
|
||||||
const selectDraft = useCallback((state: CodingConversationStoreState) => (
|
const selectDraft = useCallback((state: CodingConversationStoreState) => (
|
||||||
targetConversationId
|
targetConversationId
|
||||||
? selectCodingConversationDraft(targetConversationId)(state)
|
? selectCodingConversationDraft(targetConversationId)(state)
|
||||||
@@ -236,6 +243,24 @@ export function CodingChatPanel({
|
|||||||
|
|
||||||
useEffect(() => () => disconnectEvents(), [disconnectEvents]);
|
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(() => () => {
|
useEffect(() => () => {
|
||||||
for (const attachments of Object.values(attachmentsRef.current)) {
|
for (const attachments of Object.values(attachmentsRef.current)) {
|
||||||
for (const attachment of attachments) URL.revokeObjectURL(attachment.previewUrl);
|
for (const attachment of attachments) URL.revokeObjectURL(attachment.previewUrl);
|
||||||
@@ -251,15 +276,23 @@ export function CodingChatPanel({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeProject || !selectedAgent) {
|
if (!activeProject || !selectedAgent) {
|
||||||
|
selectedConversationContextRef.current = null;
|
||||||
clearConversationSelection();
|
clearConversationSelection();
|
||||||
return;
|
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);
|
const next = newestConversation(conversations, selectedAgent.id);
|
||||||
if (next) {
|
if (next) {
|
||||||
markConversationUnread(next.id, false);
|
markConversationUnread(next.id, false);
|
||||||
if (next.unread) void patchConversation(next.id, { unread: false }).catch(() => undefined);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const creationKey = `${activeProject.id}:${selectedAgent.id}`;
|
const creationKey = `${activeProject.id}:${selectedAgent.id}`;
|
||||||
@@ -271,7 +304,8 @@ export function CodingChatPanel({
|
|||||||
const current = codingWorkspaceStore.getState();
|
const current = codingWorkspaceStore.getState();
|
||||||
if (current.activeProjectId === activeProject.id
|
if (current.activeProjectId === activeProject.id
|
||||||
&& current.selectedAgentId === selectedAgent.id) {
|
&& current.selectedAgentId === selectedAgent.id) {
|
||||||
void selectConversation(conversation.id).catch(() => undefined);
|
void selectProjectConversation(activeProject.id, conversation.id)
|
||||||
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
@@ -283,7 +317,7 @@ export function CodingChatPanel({
|
|||||||
markConversationUnread,
|
markConversationUnread,
|
||||||
patchConversation,
|
patchConversation,
|
||||||
primeConversation,
|
primeConversation,
|
||||||
selectConversation,
|
selectProjectConversation,
|
||||||
selectedAgent,
|
selectedAgent,
|
||||||
selectedConversation,
|
selectedConversation,
|
||||||
]);
|
]);
|
||||||
@@ -332,8 +366,8 @@ export function CodingChatPanel({
|
|||||||
if (conversation.unread) {
|
if (conversation.unread) {
|
||||||
void patchConversation(conversation.id, { unread: false }).catch(() => undefined);
|
void patchConversation(conversation.id, { unread: false }).catch(() => undefined);
|
||||||
}
|
}
|
||||||
void selectConversation(conversation.id).catch(() => undefined);
|
void selectProjectConversation(activeProject.id, conversation.id).catch(() => undefined);
|
||||||
}, [activeProject, markConversationUnread, patchConversation, primeConversation, selectConversation]);
|
}, [activeProject, markConversationUnread, patchConversation, primeConversation, selectProjectConversation]);
|
||||||
|
|
||||||
const handleCreateConversation = useCallback(async () => {
|
const handleCreateConversation = useCallback(async () => {
|
||||||
if (!activeProject || !selectedAgent || creatingAgentIds[selectedAgent.id]) return;
|
if (!activeProject || !selectedAgent || creatingAgentIds[selectedAgent.id]) return;
|
||||||
@@ -344,14 +378,14 @@ export function CodingChatPanel({
|
|||||||
primeConversation(createLocalConversationSnapshot(projectId, conversation));
|
primeConversation(createLocalConversationSnapshot(projectId, conversation));
|
||||||
const current = codingWorkspaceStore.getState();
|
const current = codingWorkspaceStore.getState();
|
||||||
if (current.activeProjectId === projectId && current.selectedAgentId === agentId) {
|
if (current.activeProjectId === projectId && current.selectedAgentId === agentId) {
|
||||||
await selectConversation(conversation.id).catch(() => undefined);
|
await selectProjectConversation(projectId, conversation.id).catch(() => undefined);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
activeProject,
|
activeProject,
|
||||||
createConversation,
|
createConversation,
|
||||||
creatingAgentIds,
|
creatingAgentIds,
|
||||||
primeConversation,
|
primeConversation,
|
||||||
selectConversation,
|
selectProjectConversation,
|
||||||
selectedAgent,
|
selectedAgent,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -369,9 +403,9 @@ export function CodingChatPanel({
|
|||||||
if (workspace.activeProjectId === sourceProjectId
|
if (workspace.activeProjectId === sourceProjectId
|
||||||
&& workspace.selectedAgentId === sourceAgentId
|
&& workspace.selectedAgentId === sourceAgentId
|
||||||
&& selectedId === sourceConversationId) {
|
&& 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[]) => {
|
const handleAddFiles = useCallback((files: File[]) => {
|
||||||
if (!draftKey
|
if (!draftKey
|
||||||
|
|||||||
@@ -538,14 +538,12 @@ export function createCodingConversationStore(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
const entry = get().entriesByConversationId[conversationId];
|
const entry = get().entriesByConversationId[conversationId];
|
||||||
const snapshotFlight = !entry?.reducer.snapshot
|
const refreshMode = entry?.reducer.invalidation
|
||||||
|| entry.reducer.invalidation
|
? 'recovering'
|
||||||
|| entry.loadState !== 'live'
|
: entry?.reducer.snapshot && entry.loadState === 'live'
|
||||||
? get().loadSnapshot(
|
? 'silent'
|
||||||
conversationId,
|
: 'loading';
|
||||||
entry?.reducer.invalidation ? 'recovering' : 'loading',
|
const snapshotFlight = get().loadSnapshot(conversationId, refreshMode);
|
||||||
)
|
|
||||||
: Promise.resolve(entry.reducer.snapshot);
|
|
||||||
await Promise.all([snapshotFlight, get().connectEvents()]);
|
await Promise.all([snapshotFlight, get().connectEvents()]);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ type HostConnection = {
|
|||||||
async function disableCodingEventSource(page: Page): Promise<void> {
|
async function disableCodingEventSource(page: Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
const sources = new Set<LocalEventSource>();
|
const sources = new Set<LocalEventSource>();
|
||||||
|
type TrackedWindow = Window & {
|
||||||
|
__makeloreCodingEventSources?: LocalEventSource[];
|
||||||
|
};
|
||||||
|
const trackedWindow = window as TrackedWindow;
|
||||||
|
trackedWindow.__makeloreCodingEventSources = [];
|
||||||
class LocalEventSource extends EventTarget {
|
class LocalEventSource extends EventTarget {
|
||||||
static readonly CONNECTING = 0;
|
static readonly CONNECTING = 0;
|
||||||
static readonly OPEN = 1;
|
static readonly OPEN = 1;
|
||||||
@@ -37,6 +42,7 @@ async function disableCodingEventSource(page: Page): Promise<void> {
|
|||||||
super();
|
super();
|
||||||
this.url = url;
|
this.url = url;
|
||||||
sources.add(this);
|
sources.add(this);
|
||||||
|
trackedWindow.__makeloreCodingEventSources?.push(this);
|
||||||
queueMicrotask(() => this.onopen?.(new Event('open')));
|
queueMicrotask(() => this.onopen?.(new Event('open')));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +92,7 @@ async function installCodingFirstChatHost(
|
|||||||
abortRequested: boolean;
|
abortRequested: boolean;
|
||||||
releaseSnapshot: (() => void) | null;
|
releaseSnapshot: (() => void) | null;
|
||||||
snapshotPending: boolean;
|
snapshotPending: boolean;
|
||||||
|
snapshotSettled: boolean;
|
||||||
};
|
};
|
||||||
const mainGlobal = globalThis as typeof globalThis & {
|
const mainGlobal = globalThis as typeof globalThis & {
|
||||||
__makelorePiFirstChatE2E?: MainState;
|
__makelorePiFirstChatE2E?: MainState;
|
||||||
@@ -97,6 +104,7 @@ async function installCodingFirstChatHost(
|
|||||||
abortRequested: false,
|
abortRequested: false,
|
||||||
releaseSnapshot: null,
|
releaseSnapshot: null,
|
||||||
snapshotPending: false,
|
snapshotPending: false,
|
||||||
|
snapshotSettled: false,
|
||||||
};
|
};
|
||||||
mainGlobal.__makelorePiFirstChatE2E = state;
|
mainGlobal.__makelorePiFirstChatE2E = state;
|
||||||
const now = '2026-08-24T00:00:00.000Z';
|
const now = '2026-08-24T00:00:00.000Z';
|
||||||
@@ -530,11 +538,28 @@ async function installCodingFirstChatHost(
|
|||||||
await new Promise<void>((resolve) => { state.releaseSnapshot = resolve; });
|
await new Promise<void>((resolve) => { state.releaseSnapshot = resolve; });
|
||||||
state.snapshotPending = false;
|
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({
|
return respond({
|
||||||
snapshot: state.abortRequested
|
snapshot: state.abortRequested
|
||||||
? {
|
? {
|
||||||
...snapshot,
|
...currentSnapshot,
|
||||||
nodes: snapshot.nodes.map((node) => {
|
nodes: currentSnapshot.nodes.map((node) => {
|
||||||
if (node.kind === 'message' && node.role === 'assistant') {
|
if (node.kind === 'message' && node.role === 'assistant') {
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
@@ -567,8 +592,8 @@ async function installCodingFirstChatHost(
|
|||||||
cursor: { workerGeneration: 1, seq: 1 },
|
cursor: { workerGeneration: 1, seq: 1 },
|
||||||
}
|
}
|
||||||
: state.interactionAnswered
|
: state.interactionAnswered
|
||||||
? { ...snapshot, pendingInteractions: [] }
|
? { ...currentSnapshot, pendingInteractions: [] }
|
||||||
: snapshot,
|
: currentSnapshot,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (path === `/api/coding/conversations/${secondConversation.id}/snapshot`) {
|
if (path === `/api/coding/conversations/${secondConversation.id}/snapshot`) {
|
||||||
@@ -652,6 +677,17 @@ async function releaseSnapshot(electronApp: ElectronApplication): Promise<void>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function settleSnapshot(electronApp: ElectronApplication): Promise<void> {
|
||||||
|
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 ({
|
test('first PI Conversation is editable under 500 ms and submits before runtime Snapshot', async ({
|
||||||
launchElectronApp,
|
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 ({
|
test('PI feature UI isolates Conversations and exposes queue, interaction, model, and subagent state', async ({
|
||||||
launchElectronApp,
|
launchElectronApp,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ const conversationApi = vi.hoisted(() => ({
|
|||||||
diagnostics: vi.fn(),
|
diagnostics: vi.fn(),
|
||||||
}));
|
}));
|
||||||
const attachmentApi = vi.hoisted(() => ({ upload: vi.fn() }));
|
const attachmentApi = vi.hoisted(() => ({ upload: vi.fn() }));
|
||||||
|
const hostEventApi = vi.hoisted(() => ({ subscribe: vi.fn() }));
|
||||||
|
|
||||||
vi.mock('@/lib/coding-projects', () => ({
|
vi.mock('@/lib/coding-projects', () => ({
|
||||||
listCodingProjects: projectApi.list,
|
listCodingProjects: projectApi.list,
|
||||||
@@ -175,8 +176,13 @@ vi.mock('@/lib/coding-attachments', async (importOriginal) => ({
|
|||||||
uploadCodingAttachment: attachmentApi.upload,
|
uploadCodingAttachment: attachmentApi.upload,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/host-events', () => ({
|
||||||
|
subscribeHostEvent: hostEventApi.subscribe,
|
||||||
|
}));
|
||||||
|
|
||||||
describe('CodingChatPanel first Conversation', () => {
|
describe('CodingChatPanel first Conversation', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
hostEventApi.subscribe.mockReturnValue(vi.fn());
|
||||||
Object.defineProperty(URL, 'createObjectURL', {
|
Object.defineProperty(URL, 'createObjectURL', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: vi.fn((file: File) => `blob:${file.name}`),
|
value: vi.fn((file: File) => `blob:${file.name}`),
|
||||||
@@ -231,6 +237,133 @@ describe('CodingChatPanel first Conversation', () => {
|
|||||||
expect(source.close).toHaveBeenCalled();
|
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(<CodingChatPanel />);
|
||||||
|
|
||||||
|
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(<CodingChatPanel />);
|
||||||
|
|
||||||
|
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 () => {
|
it('keeps the new core Chat Renderer free of legacy OpenCode imports', async () => {
|
||||||
const files = [
|
const files = [
|
||||||
'../../src/pages/Chat/CodingChatPanel.tsx',
|
'../../src/pages/Chat/CodingChatPanel.tsx',
|
||||||
|
|||||||
@@ -486,6 +486,61 @@ describe('coding Conversation store', () => {
|
|||||||
expect(submitPrompt).toHaveBeenCalledTimes(1);
|
expect(submitPrompt).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rehydrates a selected live Conversation without replaying an accepted mutation', async () => {
|
||||||
|
const source = new FakeEventSource();
|
||||||
|
const refresh = deferred<ConversationSnapshot>();
|
||||||
|
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 () => {
|
it('does not let a cancelled connection clear a newer connection flight', async () => {
|
||||||
const first = deferred<EventSource>();
|
const first = deferred<EventSource>();
|
||||||
const second = deferred<EventSource>();
|
const second = deferred<EventSource>();
|
||||||
|
|||||||
Reference in New Issue
Block a user