diff --git a/.gitignore b/.gitignore index 0cdac6d..49c98be 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,7 @@ resources/bin build/ artifacts/ +.delivery/ docs/pr-session-notes-*.md .cursor/ diff --git a/harness/specs/tasks/fix-gateway-agent-phase-end-state.md b/harness/specs/tasks/fix-gateway-agent-phase-end-state.md new file mode 100644 index 0000000..ea5bfce --- /dev/null +++ b/harness/specs/tasks/fix-gateway-agent-phase-end-state.md @@ -0,0 +1,37 @@ +--- +id: fix-gateway-agent-phase-end-state +title: Keep chat run active across non-terminal gateway phase end events +scenario: gateway-backend-communication +taskType: runtime-bridge +intent: Prevent Gateway agent lifecycle hints from prematurely clearing the chat sending state while tool execution continues. +touchedAreas: + - .gitignore + - harness/specs/tasks/fix-gateway-agent-phase-end-state.md + - src/stores/gateway.ts + - tests/e2e/chat-run-state-events.spec.ts + - tests/unit/gateway-events.test.ts +expectedUserBehavior: + - The chat composer keeps showing the stop control while an agent run continues across tool rounds. + - Intermediate `phase: end` notifications refresh history without making the run look interrupted. + - Progressive streaming delta notifications without sequence numbers continue updating the visible response. +requiredProfiles: + - fast + - comms +requiredRules: + - gateway-readiness-policy + - renderer-main-boundary + - backend-communication-boundary + - api-client-transport-policy +requiredTests: + - pnpm exec vitest run tests/unit/gateway-events.test.ts tests/unit/chat-event-dedupe.test.ts + - pnpm run build:vite && pnpm exec playwright test tests/e2e/chat-run-state-events.spec.ts + - pnpm run typecheck +acceptance: + - Renderer does not add direct IPC calls. + - Renderer does not fetch Gateway HTTP directly. + - `phase: end` no longer clears `sending`, `activeRunId`, `pendingFinal`, or `lastUserMessageAt`. + - Terminal phases such as `completed` still clear chat run state. + - Gateway event dedupe does not suppress same-run delta notifications that do not carry `seq`. +docs: + required: false +--- diff --git a/src/stores/gateway.ts b/src/stores/gateway.ts index e8721a8..5967888 100644 --- a/src/stores/gateway.ts +++ b/src/stores/gateway.ts @@ -42,11 +42,54 @@ function pruneGatewayEventDedupe(now: number): void { } } +function stableGatewayEventFingerprint(value: unknown): string { + let hash = 2166136261; + let length = 0; + + const add = (part: string): void => { + length += part.length; + for (let i = 0; i < part.length; i += 1) { + hash ^= part.charCodeAt(i); + hash = Math.imul(hash, 16777619) >>> 0; + } + }; + + const visit = (entry: unknown): void => { + if (entry === undefined) { + add('u:'); + return; + } + if (entry === null || typeof entry !== 'object') { + add(`${typeof entry}:${JSON.stringify(entry)};`); + return; + } + if (Array.isArray(entry)) { + add('['); + for (const item of entry) visit(item); + add(']'); + return; + } + + add('{'); + for (const [key, child] of Object.entries(entry as Record).sort(([left], [right]) => left.localeCompare(right))) { + add(`${JSON.stringify(key)}:`); + visit(child); + } + add('}'); + }; + + visit(value); + return `${hash.toString(36)}:${length.toString(36)}`; +} + function buildGatewayEventDedupeKey(event: Record): string | null { const runId = event.runId != null ? String(event.runId) : ''; const sessionKey = event.sessionKey != null ? String(event.sessionKey) : ''; const seq = event.seq != null ? String(event.seq) : ''; const state = event.state != null ? String(event.state) : ''; + if (state === 'delta' && !seq) { + return ['delta-nosq', runId, sessionKey, stableGatewayEventFingerprint(event.message ?? event)].join('|'); + } if (runId || sessionKey || seq || state) { return [runId, sessionKey, seq, state].join('|'); } @@ -188,7 +231,7 @@ function handleGatewayNotification(notification: { method?: string; params?: Rec const matchesActiveRun = runId != null && state.activeRunId != null && String(runId) === state.activeRunId; if (matchesCurrentSession || matchesActiveRun) { - maybeLoadHistory(state); + maybeLoadHistory(state, isRunCompletion); } if (isRunCompletion && (matchesCurrentSession || matchesActiveRun) && state.sending) { useChatStore.setState({ diff --git a/tests/e2e/chat-run-state-events.spec.ts b/tests/e2e/chat-run-state-events.spec.ts new file mode 100644 index 0000000..e302231 --- /dev/null +++ b/tests/e2e/chat-run-state-events.spec.ts @@ -0,0 +1,100 @@ +import { closeElectronApp, expect, getStableWindow, installIpcMocks, test } from './fixtures/electron'; + +const MAIN_SESSION_KEY = 'agent:main:main'; + +function stableStringify(value: unknown): string { + if (value == null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`; + const entries = Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`); + return `{${entries.join(',')}}`; +} + +test.describe('ClawX chat run state events', () => { + test('keeps stop control active across non-terminal gateway phase end', async ({ launchElectronApp }) => { + const app = await launchElectronApp({ skipSetup: true }); + + try { + await installIpcMocks(app, { + gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true }, + gatewayRpc: { + [stableStringify(['sessions.list', { includeDerivedTitles: true, includeLastMessage: true }])]: { + success: true, + result: { + sessions: [{ key: MAIN_SESSION_KEY, displayName: 'main' }], + }, + }, + [stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 200 }])]: { + success: true, + result: { messages: [] }, + }, + [stableStringify(['chat.send', null])]: { + success: true, + result: { runId: 'run-e2e' }, + }, + }, + hostApi: { + [stableStringify(['/api/gateway/status', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { state: 'running', port: 18789, pid: 12345, gatewayReady: true }, + }, + }, + [stableStringify(['/api/agents', 'GET'])]: { + ok: true, + data: { + status: 200, + ok: true, + json: { success: true, agents: [{ id: 'main', name: 'Main' }] }, + }, + }, + }, + }); + + const page = await getStableWindow(app); + try { + await page.reload(); + } catch (error) { + if (!String(error).includes('ERR_FILE_NOT_FOUND')) { + throw error; + } + } + + await expect(page.getByTestId('chat-composer-input')).toBeEnabled({ timeout: 30_000 }); + await page.getByTestId('chat-composer-input').fill('run long task'); + await page.getByTestId('chat-composer-send').click(); + await expect(page.getByTestId('chat-composer-send')).toHaveAttribute('title', 'Stop'); + + await app.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0]?.webContents.send('gateway:notification', { + method: 'agent', + params: { + runId: 'run-e2e', + sessionKey: 'agent:main:main', + data: { phase: 'end' }, + }, + }); + }); + + await expect(page.getByTestId('chat-composer-send')).toHaveAttribute('title', 'Stop'); + + await app.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0]?.webContents.send('gateway:notification', { + method: 'agent', + params: { + runId: 'run-e2e', + sessionKey: 'agent:main:main', + data: { phase: 'completed' }, + }, + }); + }); + + await expect(page.getByTestId('chat-composer-send')).toHaveAttribute('title', 'Send'); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/tests/unit/gateway-events.test.ts b/tests/unit/gateway-events.test.ts index ec8b492..fd05eeb 100644 --- a/tests/unit/gateway-events.test.ts +++ b/tests/unit/gateway-events.test.ts @@ -3,6 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const hostApiFetchMock = vi.fn(); const subscribeHostEventMock = vi.fn(); +function flushAsyncImports(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), })); @@ -15,6 +19,7 @@ describe('gateway store event wiring', () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); + hostApiFetchMock.mockResolvedValue({ state: 'running', port: 18789 }); }); it('subscribes to host events through subscribeHostEvent on init', async () => { @@ -84,4 +89,206 @@ describe('gateway store event wiring', () => { expect(status.gatewayReady).toBeUndefined(); expect(status.state === 'running' && status.gatewayReady !== false).toBe(true); }); + + it('does not clear chat sending state on non-terminal agent phase end', async () => { + const handlers = new Map void>(); + subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { + handlers.set(eventName, handler); + return () => {}; + }); + const { useChatStore } = await import('@/stores/chat'); + const loadHistory = vi.fn(async () => {}); + useChatStore.setState({ + currentSessionKey: 'agent:main:main', + sessions: [{ key: 'agent:main:main' }], + sending: true, + activeRunId: 'run-1', + pendingFinal: true, + lastUserMessageAt: 1773281731000, + loadHistory, + }); + + const { useGatewayStore } = await import('@/stores/gateway'); + await useGatewayStore.getState().init(); + + handlers.get('gateway:notification')?.({ + method: 'agent', + params: { + runId: 'run-1', + sessionKey: 'agent:main:main', + data: { phase: 'end' }, + }, + }); + await flushAsyncImports(); + + expect(loadHistory).toHaveBeenCalledTimes(1); + expect(useChatStore.getState().sending).toBe(true); + expect(useChatStore.getState().activeRunId).toBe('run-1'); + expect(useChatStore.getState().pendingFinal).toBe(true); + expect(useChatStore.getState().lastUserMessageAt).toBe(1773281731000); + }); + + it('clears chat sending state on terminal completed agent phase', async () => { + const handlers = new Map void>(); + subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { + handlers.set(eventName, handler); + return () => {}; + }); + const { useChatStore } = await import('@/stores/chat'); + const loadHistory = vi.fn(async () => {}); + useChatStore.setState({ + currentSessionKey: 'agent:main:main', + sessions: [{ key: 'agent:main:main' }], + sending: true, + activeRunId: 'run-2', + pendingFinal: true, + lastUserMessageAt: 1773281731000, + loadHistory, + }); + + const { useGatewayStore } = await import('@/stores/gateway'); + await useGatewayStore.getState().init(); + + handlers.get('gateway:notification')?.({ + method: 'agent', + params: { + runId: 'run-2', + sessionKey: 'agent:main:main', + data: { phase: 'completed' }, + }, + }); + await flushAsyncImports(); + + expect(loadHistory).toHaveBeenCalledTimes(1); + expect(useChatStore.getState().sending).toBe(false); + expect(useChatStore.getState().activeRunId).toBeNull(); + expect(useChatStore.getState().pendingFinal).toBe(false); + expect(useChatStore.getState().lastUserMessageAt).toBeNull(); + }); + + it('forces terminal history reload even when non-terminal phase end just refreshed history', async () => { + const handlers = new Map void>(); + subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { + handlers.set(eventName, handler); + return () => {}; + }); + const { useChatStore } = await import('@/stores/chat'); + const loadHistory = vi.fn(async () => {}); + useChatStore.setState({ + currentSessionKey: 'agent:main:main', + sessions: [{ key: 'agent:main:main' }], + sending: true, + activeRunId: 'run-terminal-refresh', + pendingFinal: true, + lastUserMessageAt: 1773281731000, + loadHistory, + }); + + const { useGatewayStore } = await import('@/stores/gateway'); + await useGatewayStore.getState().init(); + + const notifyPhase = (phase: string) => handlers.get('gateway:notification')?.({ + method: 'agent', + params: { + runId: 'run-terminal-refresh', + sessionKey: 'agent:main:main', + data: { phase }, + }, + }); + + notifyPhase('end'); + await flushAsyncImports(); + notifyPhase('completed'); + await flushAsyncImports(); + + expect(loadHistory).toHaveBeenCalledTimes(2); + expect(useChatStore.getState().sending).toBe(false); + expect(useChatStore.getState().activeRunId).toBeNull(); + }); + + it('passes progressive delta notifications without seq through to chat store', async () => { + const handlers = new Map void>(); + subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { + handlers.set(eventName, handler); + return () => {}; + }); + + const { useChatStore } = await import('@/stores/chat'); + const handleChatEvent = vi.fn(); + useChatStore.setState({ + currentSessionKey: 'agent:main:main', + sessions: [{ key: 'agent:main:main' }], + handleChatEvent, + }); + + const { useGatewayStore } = await import('@/stores/gateway'); + await useGatewayStore.getState().init(); + + handlers.get('gateway:notification')?.({ + method: 'agent', + params: { + runId: 'run-no-seq', + sessionKey: 'agent:main:main', + state: 'delta', + message: { role: 'assistant', content: [{ type: 'text', text: 'first' }] }, + }, + }); + handlers.get('gateway:notification')?.({ + method: 'agent', + params: { + runId: 'run-no-seq', + sessionKey: 'agent:main:main', + state: 'delta', + message: { role: 'assistant', content: [{ type: 'text', text: 'first second' }] }, + }, + }); + await flushAsyncImports(); + + expect(handleChatEvent).toHaveBeenCalledTimes(2); + expect(handleChatEvent.mock.calls[0]?.[0]).toMatchObject({ + runId: 'run-no-seq', + state: 'delta', + message: { content: [{ text: 'first' }] }, + }); + expect(handleChatEvent.mock.calls[1]?.[0]).toMatchObject({ + runId: 'run-no-seq', + state: 'delta', + message: { content: [{ text: 'first second' }] }, + }); + }); + + it('dedupes exact replayed delta notifications without seq', async () => { + const handlers = new Map void>(); + subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => { + handlers.set(eventName, handler); + return () => {}; + }); + + const { useChatStore } = await import('@/stores/chat'); + const handleChatEvent = vi.fn(); + useChatStore.setState({ + currentSessionKey: 'agent:main:main', + sessions: [{ key: 'agent:main:main' }], + handleChatEvent, + }); + + const { useGatewayStore } = await import('@/stores/gateway'); + await useGatewayStore.getState().init(); + + const replayedDelta = { + method: 'agent', + params: { + runId: 'run-no-seq-replay', + sessionKey: 'agent:main:main', + state: 'delta', + message: { role: 'assistant', content: [{ type: 'text', text: 'same' }] }, + }, + }; + + handlers.get('gateway:notification')?.(replayedDelta); + handlers.get('gateway:notification')?.(replayedDelta); + await flushAsyncImports(); + + expect(handleChatEvent).toHaveBeenCalledTimes(1); + }); });