[issue follow up] Keep chat runs active across gateway phase end (#1027)

This commit is contained in:
Lingxuan Zuo
2026-05-18 16:18:14 +08:00
committed by GitHub
parent f4d6478f1c
commit 8df7eee0c1
5 changed files with 389 additions and 1 deletions

1
.gitignore vendored
View File

@@ -63,6 +63,7 @@ resources/bin
build/
artifacts/
.delivery/
docs/pr-session-notes-*.md
.cursor/

View 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
---

View File

@@ -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({

View 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);
}
});
});

View File

@@ -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);
});
});