Add execution graph to chat history (#776)

This commit is contained in:
Lingxuan Zuo
2026-04-07 01:37:06 +08:00
committed by GitHub
Unverified
parent 91c735c9f4
commit c866205eac
13 changed files with 1261 additions and 48 deletions

View File

@@ -0,0 +1,221 @@
import { closeElectronApp, expect, getStableWindow, installIpcMocks, test } from './fixtures/electron';
const PROJECT_MANAGER_SESSION_KEY = 'agent:main:main';
const CODER_SESSION_KEY = 'agent:coder:subagent:child-123';
const CODER_SESSION_ID = 'child-session-id';
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(',')}}`;
}
const seededHistory = [
{
role: 'user',
content: [{ type: 'text', text: '[Mon 2026-04-06 15:18 GMT+8] 分析 Velaria 当前未提交改动' }],
timestamp: Date.now(),
},
{
role: 'assistant',
content: [{
type: 'toolCall',
id: 'spawn-call',
name: 'sessions_spawn',
arguments: { agentId: 'coder', task: 'analyze core blocks' },
}],
timestamp: Date.now(),
},
{
role: 'toolResult',
toolCallId: 'spawn-call',
toolName: 'sessions_spawn',
content: [{
type: 'text',
text: JSON.stringify({
status: 'accepted',
childSessionKey: CODER_SESSION_KEY,
runId: 'child-run-id',
mode: 'run',
}, null, 2),
}],
details: {
status: 'accepted',
childSessionKey: CODER_SESSION_KEY,
runId: 'child-run-id',
mode: 'run',
},
isError: false,
timestamp: Date.now(),
},
{
role: 'assistant',
content: [{
type: 'toolCall',
id: 'yield-call',
name: 'sessions_yield',
arguments: { message: '我让 coder 去拆 ~/Velaria 当前未提交改动的核心块了,等它回来我直接给你结论。' },
}],
timestamp: Date.now(),
},
{
role: 'toolResult',
toolCallId: 'yield-call',
toolName: 'sessions_yield',
content: [{
type: 'text',
text: JSON.stringify({
status: 'yielded',
message: '我让 coder 去拆 ~/Velaria 当前未提交改动的核心块了,等它回来我直接给你结论。',
}, null, 2),
}],
details: {
status: 'yielded',
message: '我让 coder 去拆 ~/Velaria 当前未提交改动的核心块了,等它回来我直接给你结论。',
},
isError: false,
timestamp: Date.now(),
},
{
role: 'user',
content: [{
type: 'text',
text: `[Internal task completion event]
source: subagent
session_key: ${CODER_SESSION_KEY}
session_id: ${CODER_SESSION_ID}
type: subagent task
status: completed successfully`,
}],
timestamp: Date.now(),
},
{
role: 'assistant',
content: [{ type: 'text', text: '我让 coder 分析完了,下面是结论。' }],
timestamp: Date.now(),
},
];
const childTranscriptMessages = [
{
role: 'user',
content: [{ type: 'text', text: '分析 ~/Velaria 当前未提交改动的核心内容' }],
timestamp: Date.now(),
},
{
role: 'assistant',
content: [{
type: 'toolCall',
id: 'coder-exec-call',
name: 'exec',
arguments: {
command: "cd ~/Velaria && git status --short && sed -n '1,200p' src/dataflow/core/logical/planner/plan.h",
workdir: '/Users/bytedance/.openclaw/workspace-coder',
},
}],
timestamp: Date.now(),
},
{
role: 'toolResult',
toolCallId: 'coder-exec-call',
toolName: 'exec',
content: [{ type: 'text', text: 'M src/dataflow/core/logical/planner/plan.h' }],
details: {
status: 'completed',
aggregated: "M src/dataflow/core/logical/planner/plan.h\nM src/dataflow/core/execution/runtime/execution_optimizer.cc",
cwd: '/Users/bytedance/.openclaw/workspace-coder',
},
isError: false,
timestamp: Date.now(),
},
{
role: 'assistant',
content: [{ type: 'text', text: '已完成分析,最关键的有 4 块。' }],
timestamp: Date.now(),
},
];
test.describe('ClawX chat execution graph', () => {
test('renders internal yield status and linked subagent branch from mocked IPC', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installIpcMocks(app, {
gatewayStatus: { state: 'running', port: 18789, pid: 12345 },
gatewayRpc: {
[stableStringify(['sessions.list', {}])]: {
success: true,
result: {
sessions: [{ key: PROJECT_MANAGER_SESSION_KEY, displayName: 'main' }],
},
},
[stableStringify(['chat.history', { sessionKey: PROJECT_MANAGER_SESSION_KEY, limit: 200 }])]: {
success: true,
result: {
messages: seededHistory,
},
},
[stableStringify(['chat.history', { sessionKey: PROJECT_MANAGER_SESSION_KEY, limit: 1000 }])]: {
success: true,
result: {
messages: seededHistory,
},
},
},
hostApi: {
[stableStringify(['/api/gateway/status', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: { state: 'running', port: 18789, pid: 12345 },
},
},
[stableStringify(['/api/agents', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: {
success: true,
agents: [
{ id: 'main', name: 'main' },
{ id: 'coder', name: 'coder' },
],
},
},
},
[stableStringify([`/api/sessions/transcript?agentId=coder&sessionId=${CODER_SESSION_ID}`, 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: {
success: true,
messages: childTranscriptMessages,
},
},
},
},
});
const page = await getStableWindow(app);
await page.reload();
await expect(page.getByTestId('main-layout')).toBeVisible();
await expect(page.getByTestId('chat-execution-graph')).toBeVisible({ timeout: 30_000 });
await expect(
page.locator('[data-testid="chat-execution-graph"] [data-testid="chat-execution-step"]').getByText('sessions_yield', { exact: true }),
).toBeVisible();
await expect(page.getByText('coder subagent')).toBeVisible();
await expect(
page.locator('[data-testid="chat-execution-graph"] [data-testid="chat-execution-step"]').getByText('exec', { exact: true }),
).toBeVisible();
await expect(page.locator('[data-testid="chat-execution-graph"]').getByText('我让 coder 去拆 ~/Velaria 当前未提交改动的核心块了,等它回来我直接给你结论。')).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
});

View File

@@ -9,6 +9,12 @@ type LaunchElectronOptions = {
skipSetup?: boolean;
};
type IpcMockConfig = {
gatewayStatus?: Record<string, unknown>;
gatewayRpc?: Record<string, unknown>;
hostApi?: Record<string, unknown>;
};
type ElectronFixtures = {
electronApp: ElectronApplication;
page: Page;
@@ -194,3 +200,57 @@ export async function completeSetup(page: Page): Promise<void> {
export { closeElectronApp };
export { getStableWindow };
export { expect };
export async function installIpcMocks(
app: ElectronApplication,
config: IpcMockConfig,
): Promise<void> {
await app.evaluate(
async ({ app: _app }, mockConfig) => {
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
const 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(',')}}`;
};
if (mockConfig.gatewayRpc) {
ipcMain.removeHandler('gateway:rpc');
ipcMain.handle('gateway:rpc', async (_event: unknown, method: string, payload: unknown) => {
const key = stableStringify([method, payload ?? null]);
if (key in mockConfig.gatewayRpc!) {
return mockConfig.gatewayRpc![key];
}
const fallbackKey = stableStringify([method, null]);
if (fallbackKey in mockConfig.gatewayRpc!) {
return mockConfig.gatewayRpc![fallbackKey];
}
return { success: true, result: {} };
});
}
if (mockConfig.hostApi) {
ipcMain.removeHandler('hostapi:fetch');
ipcMain.handle('hostapi:fetch', async (_event: unknown, request: { path?: string; method?: string }) => {
const key = stableStringify([request?.path ?? '', request?.method ?? 'GET']);
if (key in mockConfig.hostApi!) {
return mockConfig.hostApi![key];
}
return {
ok: true,
data: { status: 200, ok: true, json: {} },
};
});
}
if (mockConfig.gatewayStatus) {
ipcMain.removeHandler('gateway:status');
ipcMain.handle('gateway:status', async () => mockConfig.gatewayStatus);
}
},
config,
);
}

View File

@@ -0,0 +1,156 @@
import { describe, expect, it } from 'vitest';
import { deriveTaskSteps, parseSubagentCompletionInfo } from '@/pages/Chat/task-visualization';
import type { RawMessage, ToolStatus } from '@/stores/chat';
describe('deriveTaskSteps', () => {
it('builds running steps from streaming thinking and tool status', () => {
const streamingTools: ToolStatus[] = [
{
name: 'web_search',
status: 'running',
updatedAt: Date.now(),
summary: 'Searching docs',
},
];
const steps = deriveTaskSteps({
messages: [],
streamingMessage: {
role: 'assistant',
content: [
{ type: 'thinking', thinking: 'Compare a few approaches before coding.' },
{ type: 'tool_use', id: 'tool-1', name: 'web_search', input: { query: 'openclaw task list' } },
],
},
streamingTools,
sending: true,
pendingFinal: false,
showThinking: true,
});
expect(steps).toEqual([
expect.objectContaining({
id: 'stream-thinking',
label: 'Thinking',
status: 'running',
kind: 'thinking',
}),
expect.objectContaining({
label: 'web_search',
status: 'running',
kind: 'tool',
}),
]);
});
it('keeps recent completed steps from assistant history', () => {
const messages: RawMessage[] = [
{
role: 'assistant',
id: 'assistant-1',
content: [
{ type: 'thinking', thinking: 'Reviewing the code path.' },
{ type: 'tool_use', id: 'tool-2', name: 'read_file', input: { path: 'src/App.tsx' } },
],
},
];
const steps = deriveTaskSteps({
messages,
streamingMessage: null,
streamingTools: [],
sending: false,
pendingFinal: false,
showThinking: true,
});
expect(steps).toEqual([
expect.objectContaining({
id: 'history-thinking-assistant-1',
label: 'Thinking',
status: 'completed',
kind: 'thinking',
depth: 1,
}),
expect.objectContaining({
id: 'tool-2',
label: 'read_file',
status: 'completed',
kind: 'tool',
depth: 1,
}),
]);
});
it('builds a branch for spawned subagents', () => {
const messages: RawMessage[] = [
{
role: 'assistant',
id: 'assistant-2',
content: [
{
type: 'tool_use',
id: 'spawn-1',
name: 'sessions_spawn',
input: { agentId: 'coder', task: 'inspect repo' },
},
{
type: 'tool_use',
id: 'yield-1',
name: 'sessions_yield',
input: { message: 'wait coder finishes' },
},
],
},
];
const steps = deriveTaskSteps({
messages,
streamingMessage: null,
streamingTools: [],
sending: false,
pendingFinal: false,
showThinking: true,
});
expect(steps).toEqual([
expect.objectContaining({
id: 'spawn-1',
label: 'sessions_spawn',
depth: 1,
}),
expect.objectContaining({
id: 'spawn-1:branch',
label: 'coder run',
depth: 2,
parentId: 'spawn-1',
}),
expect.objectContaining({
id: 'yield-1',
label: 'sessions_yield',
depth: 3,
parentId: 'spawn-1:branch',
}),
]);
});
it('parses internal subagent completion events from injected user messages', () => {
const info = parseSubagentCompletionInfo({
role: 'user',
content: [{
type: 'text',
text: `[Internal task completion event]
source: subagent
session_key: agent:coder:subagent:child-123
session_id: child-session-id
status: completed successfully`,
}],
} as RawMessage);
expect(info).toEqual({
sessionKey: 'agent:coder:subagent:child-123',
sessionId: 'child-session-id',
agentId: 'coder',
});
});
});