feat: 补齐智能体对话思考和工具过程渲染

This commit is contained in:
brother7 committed 2026-10-12 23:21:54 +08:00
1 parent 7a7d1f7fd8
commit 70a3212bce
12 files changed
+517 -31

No files matched your search

+97
View File
@@ -0,0 +1,97 @@
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
test('personal chat displays thinking, tools and Markdown, then restores them after reload', async ({ launchElectronApp }, testInfo) => {
const app = await launchElectronApp({ skipSetup: true });
try {
const page = await getStableWindow(app);
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
const applicationUrl = page.url();
await page.goto('about:blank');
// Cloud provider is deterministic here; the real history HTTP/PG boundary is tested in Yuxi.
await app.evaluate(({ ipcMain, BrowserWindow }) => {
BrowserWindow.getAllWindows()[0].setSize(1180, 820);
let complete = false;
Object.assign(globalThis, { finishRenderRun: () => { complete = true; } });
const slug = 'ml-' + 'a'.repeat(32);
const configuration = { model: 'test', tools: [], knowledges: [], mcps: [], skills: [], preload_skills: [], subagents: [], tool_approval_mode: 'always_trust', max_execution_steps: 300, max_output_tokens: 4096, max_run_seconds: 600 };
const draft = { slug, name: '渲染测试助手', purpose: '整理资料', system_prompt: '', configuration,
draft_revision: 1, published_version: 1, enabled: true, updated_at: '' };
const run = { agent_run_id: 'render-run', request_id: 'render-request', thread_id: 'render-thread', agent_slug: slug, status: 'running', output: '', version: '1' };
const thread = { thread_id: 'render-thread', client_thread_id: 'client', agent_slug: slug, title: '整理资料', mode: 'published', updated_at: '', run_id: run.agent_run_id, unread: false, status: 'running' };
const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } });
ipcMain.removeHandler('hostapi:fetch');
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; body?: string }) => {
const path = request.path ?? '';
const body = request.body ? JSON.parse(request.body) : {};
if (path === '/api/auth/session/sync') return result({ success: true, session: { accessToken: 'fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false } });
if (path === '/api/auth/me') return result({ success: true, user: { username: 'creator', userId: 'creator', tenantId: null, deptId: null, authorities: [] }, moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } });
if (path === '/api/works/user/agent-profile') return result({ success: true, profile: { display_name: '创作者', completed: true, version: 1 } });
if (path.endsWith('/recovery')) return result({ recent: null, pending: [] });
if (path.endsWith('/recovery/recent')) return result({});
if (path.endsWith('/agents') || path.endsWith('/bootstrap')) return result({ agents: [draft], next_cursor: null });
if (path.endsWith('/actions')) {
if (body.operation === 'threads') return result({ threads: [thread], next_offset: null });
if (body.operation === 'history') return result({ thread_id: thread.thread_id, run: { ...run, status: complete ? 'completed' : 'running' }, next_offset: null, messages: complete ? [{
id: 1, message_id: 'answer', role: 'assistant', run_id: run.agent_run_id, request_id: run.request_id, created_at: '',
content: '## 整理结果\n\n| 项目 | 状态 |\n| --- | --- |\n| 资料 | 已核对 |\n\n**可以使用**',
reasoning_content: '先核对来源,再整理结果。',
tool_calls: [{ id: 'lookup', name: '资料检索', args: { query: '实际检索资料' }, status: 'success', tool_call_result: { content: '找到两条资料' } }],
}] : [] });
if (body.operation === 'run') return result({ ...run, status: complete ? 'completed' : 'running' });
if (body.operation === 'viewed') return result({});
if (body.operation === 'files') return result({ files: [] });
if (body.operation === 'attachments') return result({ attachments: [] });
if (body.operation === 'catalog') return result({ models: [{ id: 'test', name: '测试模型' }], resources: {}, pricing: null });
if (body.operation === 'access') return result({ published_version: 1, enabled: true, versions: [], grants: [], applications: [], share_url: '', api_url: '' });
if (body.operation === 'channelBindings') return result({ items: [], available_accounts: [] });
return result({});
}
if (path.startsWith('/api/cloud-agents/agents/')) return result(draft);
return result({ success: true });
});
});
await page.addInitScript(() => {
const sources: EventTarget[] = [];
Object.assign(window, { renderSources: sources });
class FixtureSource extends EventTarget {
onopen = null; onerror = null; close() {}
constructor(url: string) { super(); if (url.includes('/cloud-agents/runs/')) sources.push(this); }
}
Object.defineProperty(window, 'EventSource', { value: FixtureSource });
});
await page.goto(applicationUrl);
await page.getByTestId('ai-module-option-cloud_agents').click();
await page.getByRole('button', { name: /渲染测试助手.*打开对话/ }).click();
const emit = async (items: unknown[], cursor: string) => page.evaluate(({ items, cursor }) => {
const sources = (window as unknown as { renderSources: EventTarget[] }).renderSources;
sources.at(-1)!.dispatchEvent(new MessageEvent('messages', { lastEventId: cursor, data: JSON.stringify({ run_id: 'render-run', thread_id: 'render-thread', payload: { items } }) }));
}, { items, cursor });
await expect.poll(() => page.evaluate(() => (window as unknown as { renderSources: EventTarget[] }).renderSources.length)).toBeGreaterThan(0);
await emit([{ stream_event: { type: 'message_delta', message_id: 'answer', reasoning_content: '先核对来源,再整理结果。' } }], '100-1');
const process = page.getByTestId('cloud-message-process');
await expect(page.getByLabel('智能体思考过程')).toBeVisible();
await emit([
{ stream_event: { type: 'tool_call', message_id: 'answer', tool_call_id: 'lookup', name: '资料检索', args: { query: '测试资料' } } },
{ event: { method: 'tools', data: { event: 'tool-started', tool_call_id: 'lookup', input: { query: '实际检索资料' } } } },
{ event: { method: 'tools', data: { event: 'tool-finished', tool_call_id: 'lookup', output: { content: '找到两条资料' } } } },
{ stream_event: { type: 'message_delta', message_id: 'answer', content: '## 整理结果\n\n| 项目 | 状态 |\n| --- | --- |\n| 资料 | 已核对 |\n\n**可以使用**' } },
], '100-2');
await expect(page.getByRole('heading', { name: '整理结果' })).toBeVisible();
await expect(page.getByRole('table')).toBeVisible();
await process.locator(':scope > summary').click();
await process.getByText('资料检索 · 已完成', { exact: true }).click();
await expect(process.getByText('找到两条资料', { exact: true })).toBeVisible();
await expect(process.getByText('"实际检索资料"', { exact: false })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('personal-chat-process.png') });
await app.evaluate(() => (globalThis as unknown as { finishRenderRun: () => void }).finishRenderRun());
await page.evaluate(() => (window as unknown as { renderSources: EventTarget[] }).renderSources.at(-1)!.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ thread_id: 'render-thread', payload: { status: 'completed' } }) })));
await expect(page.getByRole('heading', { name: '整理结果' })).toHaveCount(1);
await page.reload();
await page.getByRole('button', { name: /渲染测试助手.*打开对话/ }).click();
await expect(page.getByRole('heading', { name: '整理结果' })).toBeVisible();
await page.getByTestId('cloud-message-process').locator(':scope > summary').click();
await expect(page.getByLabel('智能体思考过程')).toHaveText('先核对来源,再整理结果。');
await expect(page.getByText('资料检索 · 已完成', { exact: true })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('personal-chat-reopened.png') });
} finally { await closeElectronApp(app); }
});
+17 -2
View File
@@ -45,6 +45,22 @@ beforeEach(() => { vi.clearAllMocks(); journalRecords.clear(); clearWorksSquareS
afterEach(() => { instances.splice(0).forEach((module) => module.dispose()); clearWorksSquareSession(); vi.useRealTimers(); });
describe('Main cloud Agents boundary', () => {
it('projects saved reasoning and tool results for both personal and channel history without raw metadata', () => {
const message = { id: 1, role: 'assistant', content: 'answer', reasoning_content: 'thought', message_id: 'model-1',
extra_metadata: { internal: true }, tool_calls: [{ id: 'call-1', name: 'search', args: { query: 'topic' },
status: 'success', internal: 'hidden', tool_call_result: { content: 'result', secret: 'hidden' } }] };
const expected = { id: 1, role: 'assistant', content: 'answer', reasoning_content: 'thought', message_id: 'model-1',
tool_calls: [{ id: 'call-1', name: 'search', args: { query: 'topic' }, status: 'success', tool_call_result: { content: 'result' } }] };
for (const input of [
{ operation: 'history', input: { thread_id: 'thread-1' } },
{ operation: 'channelConversation', input: { session_id: 'session-1' } },
]) {
expect(operationPlan(input).project({ messages: [message], run: null, next_offset: null })).toMatchObject({ messages: [expected] });
expect(JSON.stringify(operationPlan(input).project({ messages: [message] }))).not.toContain('hidden');
expect(JSON.stringify(operationPlan(input).project({ messages: [message] }))).not.toContain('extra_metadata');
}
});
it('uploads avatar bytes through Main and resolves Yuxi public URLs without exposing credentials', async () => {
const directory = await mkdtemp(join(tmpdir(), 'makelore-avatar-'));
try {
@@ -106,8 +122,7 @@ describe('Main cloud Agents boundary', () => {
expect(await module.execute({ operation: 'version', input: { slug: draft.slug, version: '1' } })).toMatchObject(avatar);
});
it.each(['file:///private/avatar.png', 'javascript:alert(1)', 'https://secret@images.example.test/avatar.png'])
('falls back for an unusable legacy avatar URL %s', async icon => {
it.each(['file:///private/avatar.png', 'javascript:alert(1)', 'https://secret@images.example.test/avatar.png'])('falls back for an unusable legacy avatar URL %s', async icon => {
const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ ...draft, icon }));
expect(await moduleFor(transport).get(draft.slug)).toMatchObject({ icon, icon_url: null });
});
+2 -2
View File
@@ -158,8 +158,8 @@ it('retries the same request after a lost response and renders batched native SS
expect(submissions[0][1]).toEqual(submissions[1][1]);
await act(async () => stream.dispatchEvent(new MessageEvent('messages', {
data: JSON.stringify({ thread_id: 'thread-1', payload: { items: [
{ stream_event: { type: 'message_delta', content: '第一段' } },
{ stream_event: { type: 'message_delta', content: '文字' } },
{ stream_event: { type: 'message_delta', message_id: 'answer', content: '第一段' } },
{ stream_event: { type: 'message_delta', message_id: 'answer', content: '文字' } },
] } }),
})));
expect(screen.getByText('第一段文字')).toBeVisible();
+165
View File
@@ -0,0 +1,165 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { CloudChat } from '@/pages/CloudAgents/CloudChat';
import { reduceCloudStream, type CloudStream } from '@/pages/CloudAgents/cloud-chat-stream';
import type { CloudHistory } from '../../shared/cloud-agents';
const api = vi.hoisted(() => ({ call: vi.fn(), events: vi.fn(), recovery: vi.fn(), remember: vi.fn(), download: vi.fn() }));
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
class Stream extends EventTarget { close = vi.fn(); onopen = null; onerror = null; }
let stream: Stream;
let history: CloudHistory;
const run = { agent_run_id: 'run', request_id: 'request', thread_id: 'thread', agent_slug: 'agent', status: 'running', output: '', version: '1' };
const thread = { thread_id: 'thread', client_thread_id: 'client', agent_slug: 'agent', mode: 'published', status: 'running', title: '对话', updated_at: '', unread: false, run_id: 'run' };
beforeEach(() => {
vi.resetAllMocks();
stream = new Stream();
history = { thread_id: 'thread', messages: [], run, next_offset: null };
api.recovery.mockResolvedValue({ recent: null, pending: [] });
api.remember.mockResolvedValue({});
api.download.mockResolvedValue({ saved: true });
api.events.mockResolvedValue(stream);
api.call.mockImplementation(async (operation: string) => {
if (operation === 'threads') return { threads: [thread], next_offset: null };
if (operation === 'history') return history;
if (operation === 'run') return history.run;
if (operation === 'files') return { files: [] };
if (operation === 'attachments') return { attachments: [] };
if (operation === 'viewed') return {};
throw new Error(operation);
});
});
afterEach(cleanup);
async function emit(items: unknown[], cursor: string, threadId = 'thread') {
await act(async () => stream.dispatchEvent(new MessageEvent('messages', {
lastEventId: cursor, data: JSON.stringify({ run_id: 'run', thread_id: threadId, payload: { items } }),
})));
}
const delta = (message_id: string, fields: Record<string, unknown>) => ({ stream_event: { type: 'message_delta', message_id, ...fields } });
it('renders separate thinking and Markdown messages, ignores replay/subthreads, and restores durable history on reopen', async () => {
const view = render(<CloudChat slug="agent" />);
await waitFor(() => expect(api.events).toHaveBeenCalledWith('run'));
await emit([delta('first', { reasoning_content: '先查资料' })], '100-1');
expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('先查资料');
expect(screen.getByTestId('cloud-message-process')).toHaveAttribute('open');
await emit([delta('first', { reasoning_content: '先查资料' })], '100-1');
await emit([delta('child', { content: '不应出现在主回答', reasoning_content: '子线程思考' })], '100-2', 'child');
await emit([delta('first', { content: '**核对完成**' }), delta('second', { additional_reasoning_content: '再整理结论' })], '100-3');
expect(screen.getByText('核对完成').tagName).toBe('STRONG');
expect(screen.queryByText('不应出现在主回答')).not.toBeInTheDocument();
expect(screen.getAllByLabelText('智能体思考过程')[0]).toHaveTextContent(/^先查资料$/);
expect(screen.getAllByTestId('cloud-message-process')[0]).not.toHaveAttribute('open');
await emit([delta('second', { content: '| 项目 | 结果 |\n| --- | --- |\n| 校验 | 通过 |\n\n[报告](sandbox:/report.md)' })], '100-4');
expect(screen.getByRole('table')).toHaveTextContent('校验通过');
fireEvent.click(screen.getByRole('button', { name: '报告' }));
expect(api.download).toHaveBeenCalledWith('thread', '/report.md');
history = { ...history, run: { ...run, status: 'completed' }, messages: [
{ id: 1, role: 'assistant', message_id: 'first', run_id: 'run', request_id: 'request', created_at: '', content: '**核对完成**', reasoning_content: '先查资料' },
{ id: 2, role: 'assistant', message_id: 'second', run_id: 'run', request_id: 'request', created_at: '', content: '最终结论', reasoning_content: '再整理结论' },
] };
await act(async () => stream.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ thread_id: 'thread', payload: { status: 'completed' } }) })));
await screen.findByText('最终结论');
expect(screen.getAllByText('核对完成')).toHaveLength(1);
expect(screen.getAllByLabelText('智能体思考过程')).toHaveLength(2);
expect(screen.queryByRole('table')).not.toBeInTheDocument();
view.unmount();
render(<CloudChat slug="agent" />);
await screen.findByText('最终结论');
expect(screen.getAllByLabelText('智能体思考过程')[1]).toHaveTextContent('再整理结论');
expect(screen.getAllByTestId('cloud-message-process').every(element => !element.hasAttribute('open'))).toBe(true);
});
it('associates tool arguments and results by call identity, keeps failures visible and clears a switched thread', async () => {
render(<CloudChat slug="agent" />);
await waitFor(() => expect(api.events).toHaveBeenCalled());
await emit([
{ stream_event: { type: 'tool_call_delta', message_id: 'm', tool_call_id: 'search', name: '查资料', args_delta: '{"query":', index: 0 } },
{ stream_event: { type: 'tool_call_delta', message_id: 'm', args_delta: '"天气"}', index: 0 } },
{ stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'write', name: '写文件', args: { path: '/a' }, index: 1 } },
{ event: { method: 'tools', data: { event: 'tool-finished', tool_call_id: 'write', output: { content: '磁盘不足', status: 'error' } } } },
{ event: { method: 'tools', data: { event: 'tool-finished', tool_call_id: 'search', output: { content: '晴天', status: 'success' } } } },
], '101-0');
const process = screen.getByTestId('cloud-message-process');
expect(process).toHaveTextContent('查资料 · 已完成');
expect(process).toHaveTextContent('写文件 · 失败');
expect(within(process).getByText('{"query":"天气"}')).toBeInTheDocument();
expect(within(process).getByText('晴天')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '新对话' }));
await waitFor(() => expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument());
await emit([delta('m', { reasoning_content: '旧连接的迟到内容' })], '102-0');
expect(screen.queryByText('旧连接的迟到内容')).not.toBeInTheDocument();
});
it('retains partial thinking and answer if final history cannot be read', async () => {
render(<CloudChat slug="agent" />);
await waitFor(() => expect(api.events).toHaveBeenCalled());
await emit([delta('m', { reasoning_content: '已完成分析', content: '已生成部分结果' })], '1-0');
const implementation = api.call.getMockImplementation()!;
api.call.mockImplementation(async (operation, ...args) => {
if (operation === 'history') throw new Error('历史暂不可用');
return implementation(operation, ...args);
});
await act(async () => stream.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ payload: { status: 'failed' } }) })));
await screen.findByText('历史暂不可用');
expect(screen.getByText('已生成部分结果')).toBeVisible();
expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('已完成分析');
});
it('leaves ordinary answers without an empty thinking panel', async () => {
history = { ...history, run: { ...run, status: 'completed' }, messages: [
{ id: 1, role: 'assistant', content: '普通回答', request_id: 'request', run_id: 'run', created_at: '' },
] };
render(<CloudChat slug="agent" />);
await screen.findByText('普通回答');
expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument();
});
it('keeps the current full stream while a stale partial history is waiting for terminal refresh', async () => {
history = { ...history, messages: [{ id: 1, message_id: 'm', role: 'assistant', content: '旧片段',
request_id: 'request', run_id: 'run', created_at: '' }] };
render(<CloudChat slug="agent" />);
await waitFor(() => expect(api.events).toHaveBeenCalled());
await emit([delta('m', { content: '完整的新答案', reasoning_content: '已完成的思考' })], '200-1');
expect(screen.queryByText('旧片段')).not.toBeInTheDocument();
const implementation = api.call.getMockImplementation()!;
let finish!: (value: CloudHistory) => void;
api.call.mockImplementation((operation, ...args) => operation === 'history'
? new Promise<CloudHistory>(resolve => { finish = resolve; }) : implementation(operation, ...args));
await act(async () => stream.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ payload: { status: 'completed' } }) })));
expect(screen.getByText('完整的新答案')).toBeVisible();
expect(screen.queryByText('旧片段')).not.toBeInTheDocument();
await act(async () => finish({ ...history, run: { ...run, status: 'completed' }, messages: [{
...history.messages[0], content: '完整的新答案', reasoning_content: '已完成的思考',
}] }));
expect(screen.getAllByText('完整的新答案')).toHaveLength(1);
});
it('uses effective execution input once each tool starts, including empty arguments', () => {
let state: CloudStream = { runId: 'run', cursor: '', messages: [] };
state = reduceCloudStream(state, { payload: { items: [
{ stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'search', name: '查资料', args: { query: '模型参数' }, index: 0 } },
{ stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'other', name: '其他工具', args: { unused: true }, index: 1 } },
] } }, 'thread', '1-0');
state = reduceCloudStream(state, { payload: { items: [
{ event: { method: 'tools', data: { event: 'tool-started', tool_call_id: 'search', input: { query: '实际执行参数' } } } },
{ event: { method: 'tools', data: { event: 'tool-started', tool_call_id: 'other', input: {} } } },
] } }, 'thread', '2-0');
expect(state.messages[0].tool_calls).toMatchObject([
{ id: 'search', args: { query: '实际执行参数' }, status: 'running' },
{ id: 'other', args: {}, status: 'running' },
]);
expect(state.messages[0].tool_calls[1].args).toEqual({});
state = reduceCloudStream(state, { payload: { chunk: {
stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'search', args: { query: '模型参数' }, index: 0 },
} } }, 'thread', '3-0');
expect(state.messages[0].tool_calls[0].args).toEqual({ query: '实际执行参数' });
});
it('keeps Redis cursor precision while ignoring other runs', () => {
let state: CloudStream = { runId: 'run', cursor: '', messages: [] };
const event = { run_id: 'run', thread_id: 'thread', payload: { chunk: delta('m', { content: 'A' }) } };
state = reduceCloudStream(state, event, 'thread', '9007199254740993-0');
expect(reduceCloudStream(state, event, 'thread', '9007199254740992-1')).toBe(state);
expect(reduceCloudStream(state, { ...event, run_id: 'other' }, 'thread', '9007199254740994-0')).toBe(state);
});