[issue follow up] Keep chat runs active across gateway phase end (#1027)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -63,6 +63,7 @@ resources/bin
|
||||
|
||||
build/
|
||||
artifacts/
|
||||
.delivery/
|
||||
docs/pr-session-notes-*.md
|
||||
|
||||
.cursor/
|
||||
|
||||
37
harness/specs/tasks/fix-gateway-agent-phase-end-state.md
Normal file
37
harness/specs/tasks/fix-gateway-agent-phase-end-state.md
Normal file
@@ -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
|
||||
---
|
||||
@@ -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<string, unknown>).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, unknown>): 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({
|
||||
|
||||
100
tests/e2e/chat-run-state-events.spec.ts
Normal file
100
tests/e2e/chat-run-state-events.spec.ts
Normal file
@@ -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<string, unknown>)
|
||||
.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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
const hostApiFetchMock = vi.fn();
|
||||
const subscribeHostEventMock = vi.fn();
|
||||
|
||||
function flushAsyncImports(): Promise<void> {
|
||||
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<string, (payload: unknown) => 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<string, (payload: unknown) => 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<string, (payload: unknown) => 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<string, (payload: unknown) => 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<string, (payload: unknown) => 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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user