feat: refine business chat workflow
This commit is contained in:
24
tests/unit/business-guidance.test.ts
Normal file
24
tests/unit/business-guidance.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
appendBusinessResponseGuidance,
|
||||
hasBusinessResponseGuidance,
|
||||
stripBusinessResponseGuidance,
|
||||
} from '../../shared/business-guidance';
|
||||
|
||||
describe('business response guidance', () => {
|
||||
it('appends hidden business response guidance without changing the visible prompt', () => {
|
||||
const message = appendBusinessResponseGuidance('生成昨日经营日报');
|
||||
|
||||
expect(message).toContain('生成昨日经营日报');
|
||||
expect(message).toContain('智念业务员工');
|
||||
expect(hasBusinessResponseGuidance(message)).toBe(true);
|
||||
});
|
||||
|
||||
it('strips guidance for UI display and avoids duplicate blocks', () => {
|
||||
const once = appendBusinessResponseGuidance('检查渠道房态');
|
||||
const twice = appendBusinessResponseGuidance(once);
|
||||
|
||||
expect(stripBusinessResponseGuidance(twice)).toBe('检查渠道房态');
|
||||
expect((twice.match(/\[\[YINIAN_BUSINESS_RESPONSE_GUIDANCE\]\]/g) ?? [])).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ChatMessage } from '@/pages/Chat/ChatMessage';
|
||||
import type { RawMessage } from '@/stores/chat';
|
||||
import { appendBusinessResponseGuidance } from '../../shared/business-guidance';
|
||||
|
||||
describe('ChatMessage attachment dedupe', () => {
|
||||
it('keeps attachment-only assistant replies visible even when process attachments are suppressed', () => {
|
||||
@@ -79,3 +80,67 @@ describe('ChatMessage LaTeX rendering', () => {
|
||||
expect(container.querySelector('.katex')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatMessage business answer rendering', () => {
|
||||
it('renders a business summary panel for structured operational replies', () => {
|
||||
const message: RawMessage = {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
'状态:需处理,携程渠道房态与 PMS 不一致。',
|
||||
'依据:携程 0508 房型显示可售 3 间,PMS 为 0 间。',
|
||||
'影响:可能产生超售风险。',
|
||||
'下一步:请先暂停该房型自动售卖,并复核渠道登录态。',
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
render(<ChatMessage message={message} />);
|
||||
|
||||
const panel = screen.getByTestId('business-answer-panel');
|
||||
expect(panel).toHaveTextContent('业务摘要');
|
||||
expect(panel).toHaveTextContent('需处理,携程渠道房态与 PMS 不一致');
|
||||
expect(panel).toHaveTextContent('携程 0508 房型显示可售 3 间');
|
||||
expect(panel).toHaveTextContent('请先暂停该房型自动售卖');
|
||||
});
|
||||
|
||||
it('does not add the business panel to ordinary assistant chat', () => {
|
||||
const message: RawMessage = {
|
||||
role: 'assistant',
|
||||
content: '你好,我可以帮你处理问题。',
|
||||
};
|
||||
|
||||
render(<ChatMessage message={message} />);
|
||||
|
||||
expect(screen.queryByTestId('business-answer-panel')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatMessage markdown rendering', () => {
|
||||
it('hides legacy injected business guidance from visible user messages', () => {
|
||||
const message: RawMessage = {
|
||||
role: 'user',
|
||||
content: appendBusinessResponseGuidance('会话中的 markdown 结构化,表格也可以加强 UI 渲染'),
|
||||
};
|
||||
|
||||
render(<ChatMessage message={message} />);
|
||||
|
||||
expect(screen.getByText('会话中的 markdown 结构化,表格也可以加强 UI 渲染')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/YINIAN_BUSINESS_RESPONSE_GUIDANCE/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/请按智念业务员工/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders markdown tables inside the enhanced table shell', () => {
|
||||
const message: RawMessage = {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
'| 渠道 | 房型 | 状态 |',
|
||||
'| --- | --- | --- |',
|
||||
'| 携程 | 豪华大床房 | 需复核 |',
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
render(<ChatMessage message={message} />);
|
||||
|
||||
expect(screen.getByTestId('markdown-table-scroll')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('markdown-table')).toHaveTextContent('豪华大床房');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,9 +120,10 @@ describe('chat target routing', () => {
|
||||
const sendCall = gatewayRpcMock.mock.calls.find(([method]) => method === 'chat.send');
|
||||
expect(sendCall?.[1]).toMatchObject({
|
||||
sessionKey: 'agent:research:desk',
|
||||
message: 'Hello direct agent',
|
||||
deliver: false,
|
||||
});
|
||||
expect((sendCall?.[1] as { message: string }).message).toContain('Hello direct agent');
|
||||
expect((sendCall?.[1] as { message: string }).message).not.toContain('YINIAN_BUSINESS_RESPONSE_GUIDANCE');
|
||||
expect(typeof (sendCall?.[1] as { idempotencyKey?: unknown })?.idempotencyKey).toBe('string');
|
||||
});
|
||||
|
||||
@@ -182,7 +183,8 @@ describe('chat target routing', () => {
|
||||
};
|
||||
|
||||
expect(payload.sessionKey).toBe('agent:research:desk');
|
||||
expect(payload.message).toBe('Process the attached file(s).');
|
||||
expect(payload.message).toContain('Process the attached file(s).');
|
||||
expect(payload.message).not.toContain('YINIAN_BUSINESS_RESPONSE_GUIDANCE');
|
||||
expect(payload.media[0]?.filePath).toBe('/tmp/design.png');
|
||||
});
|
||||
|
||||
@@ -251,5 +253,6 @@ describe('chat target routing', () => {
|
||||
expect((sendCall?.[1] as { message: string }).message).toContain('[知识库上下文]');
|
||||
expect((sendCall?.[1] as { message: string }).message).toContain('handbook.docx');
|
||||
expect((sendCall?.[1] as { message: string }).message).toContain('Refunds are available within 7 days.');
|
||||
expect((sendCall?.[1] as { message: string }).message).not.toContain('YINIAN_BUSINESS_RESPONSE_GUIDANCE');
|
||||
});
|
||||
});
|
||||
|
||||
103
tests/unit/cron-desktop-reminder.test.ts
Normal file
103
tests/unit/cron-desktop-reminder.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
buildCronDesktopReminderDisplay,
|
||||
buildCronDesktopReminderEvent,
|
||||
createCronDesktopReminderHandler,
|
||||
} from '@electron/utils/cron-desktop-reminder';
|
||||
|
||||
describe('cron desktop reminders', () => {
|
||||
it('builds a desktop reminder event from isolated cron completion notifications', () => {
|
||||
const event = buildCronDesktopReminderEvent({
|
||||
method: 'agent',
|
||||
params: {
|
||||
phase: 'completed',
|
||||
runId: 'run-1',
|
||||
sessionKey: 'agent:main:cron:job-1:run:session-a',
|
||||
data: {
|
||||
summary: '日报已生成,渠道价格无异常。',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(event).toMatchObject({
|
||||
jobId: 'job-1',
|
||||
agentId: 'main',
|
||||
sessionKey: 'agent:main:cron:job-1',
|
||||
runKey: 'job-1:run-1',
|
||||
tone: 'success',
|
||||
summary: '日报已生成,渠道价格无异常。',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores non-terminal or non-cron notifications', () => {
|
||||
expect(buildCronDesktopReminderEvent({
|
||||
method: 'agent',
|
||||
params: {
|
||||
phase: 'started',
|
||||
sessionKey: 'agent:main:cron:job-1',
|
||||
},
|
||||
})).toBeNull();
|
||||
|
||||
expect(buildCronDesktopReminderEvent({
|
||||
method: 'agent',
|
||||
params: {
|
||||
phase: 'completed',
|
||||
sessionKey: 'agent:main:main',
|
||||
},
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('builds action-required display copy for failed runs', () => {
|
||||
const event = buildCronDesktopReminderEvent({
|
||||
method: 'agent',
|
||||
params: {
|
||||
phase: 'failed',
|
||||
runId: 'run-failed',
|
||||
sessionKey: 'agent:main:cron:job-failed',
|
||||
error: '渠道登录态失效',
|
||||
},
|
||||
});
|
||||
|
||||
expect(event).not.toBeNull();
|
||||
const display = buildCronDesktopReminderDisplay(event!, {
|
||||
id: 'job-failed',
|
||||
name: '渠道房态诊断',
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
tone: 'danger',
|
||||
title: '任务需要处理:渠道房态诊断',
|
||||
body: '渠道登录态失效',
|
||||
route: '/tasks?task=job-failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates repeated completion notifications for the same run', async () => {
|
||||
const notify = vi.fn();
|
||||
const handler = createCronDesktopReminderHandler({
|
||||
now: () => 1000,
|
||||
notify,
|
||||
resolveJob: vi.fn().mockResolvedValue({ id: 'job-1', name: '每日经营日报' }),
|
||||
});
|
||||
const notification = {
|
||||
method: 'agent',
|
||||
params: {
|
||||
phase: 'completed',
|
||||
runId: 'run-1',
|
||||
sessionKey: 'agent:main:cron:job-1',
|
||||
},
|
||||
};
|
||||
|
||||
await handler(notification);
|
||||
await handler(notification);
|
||||
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: '任务已完成:每日经营日报',
|
||||
route: '/tasks?task=job-1',
|
||||
}),
|
||||
expect.objectContaining({ jobId: 'job-1' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -68,7 +68,12 @@ describe('handleCronRoutes', () => {
|
||||
expect(handled).toBe(true);
|
||||
expect(rpc).toHaveBeenCalledWith('cron.add', expect.objectContaining({
|
||||
delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_weather' },
|
||||
payload: {
|
||||
kind: 'agentTurn',
|
||||
message: expect.stringContaining('Summarize today'),
|
||||
},
|
||||
}));
|
||||
expect((rpc.mock.calls[0]?.[1] as { payload: { message: string } }).payload.message).not.toContain('YINIAN_BUSINESS_RESPONSE_GUIDANCE');
|
||||
expect(sendJsonMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
200,
|
||||
@@ -114,10 +119,11 @@ describe('handleCronRoutes', () => {
|
||||
expect(rpc).toHaveBeenCalledWith('cron.update', {
|
||||
id: 'job-2',
|
||||
patch: {
|
||||
payload: { kind: 'agentTurn', message: 'Updated prompt' },
|
||||
payload: { kind: 'agentTurn', message: expect.stringContaining('Updated prompt') },
|
||||
delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_next' },
|
||||
},
|
||||
});
|
||||
expect((rpc.mock.calls[0]?.[1] as { patch: { payload: { message: string } } }).patch.payload.message).not.toContain('YINIAN_BUSINESS_RESPONSE_GUIDANCE');
|
||||
expect(sendJsonMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
200,
|
||||
@@ -247,12 +253,13 @@ describe('handleCronRoutes', () => {
|
||||
|
||||
it('builds cron fallback query and running messages as visible chat messages', async () => {
|
||||
const { buildCronSessionFallbackMessages } = await import('@electron/api/routes/cron');
|
||||
const { appendBusinessResponseGuidance } = await import('../../shared/business-guidance');
|
||||
|
||||
const messages = buildCronSessionFallbackMessages({
|
||||
sessionKey: 'agent:main:cron:job-running',
|
||||
job: {
|
||||
name: '写日报',
|
||||
payload: { kind: 'agentTurn', message: '生成今天的经营日报' },
|
||||
payload: { kind: 'agentTurn', message: appendBusinessResponseGuidance('生成今天的经营日报') },
|
||||
state: { runningAtMs: 1778650080011 },
|
||||
},
|
||||
runs: [],
|
||||
|
||||
@@ -62,9 +62,9 @@ function createConfig(overrides: Partial<YinianConfigSnapshot> = {}): YinianConf
|
||||
};
|
||||
}
|
||||
|
||||
function renderTasks() {
|
||||
function renderTasks(initialEntries = ['/tasks']) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<Tasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@@ -190,6 +190,10 @@ describe('Tasks page', () => {
|
||||
enabled: true,
|
||||
});
|
||||
expect(payload.message).toContain('昨日经营情况');
|
||||
|
||||
const reminder = await screen.findByTestId('tasks-reminder-cron-daily');
|
||||
expect(reminder).toHaveTextContent('已安排');
|
||||
expect(reminder).toHaveTextContent('下次将在');
|
||||
});
|
||||
|
||||
it('pins task center cards to the sidebar quick trigger list', async () => {
|
||||
@@ -218,6 +222,57 @@ describe('Tasks page', () => {
|
||||
expect(useTaskCenterStore.getState().pinnedTaskIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('surfaces failed task reminders as action-required rows', async () => {
|
||||
useCronStore.setState({
|
||||
jobs: [{
|
||||
id: 'cron-failed',
|
||||
name: '渠道房态诊断',
|
||||
message: '检查渠道房态是否一致',
|
||||
schedule: '0 9 * * *',
|
||||
delivery: { mode: 'none' },
|
||||
enabled: true,
|
||||
createdAt: '2026-05-13T00:00:00.000Z',
|
||||
updatedAt: '2026-05-13T00:00:00.000Z',
|
||||
agentId: 'main',
|
||||
lastRun: {
|
||||
time: '2026-05-13T01:00:00.000Z',
|
||||
success: false,
|
||||
error: '渠道登录态失效',
|
||||
},
|
||||
}],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderTasks();
|
||||
|
||||
const reminder = await screen.findByTestId('tasks-reminder-cron-failed');
|
||||
expect(reminder).toHaveTextContent('需处理');
|
||||
expect(reminder).toHaveTextContent('渠道登录态失效');
|
||||
});
|
||||
|
||||
it('highlights the scheduled task opened from a desktop reminder', async () => {
|
||||
useCronStore.setState({
|
||||
jobs: [{
|
||||
id: 'cron-daily',
|
||||
name: '每日经营日报',
|
||||
message: '使用日报生成助手 skill 生成日报',
|
||||
schedule: '0 9 * * *',
|
||||
delivery: { mode: 'none' },
|
||||
enabled: true,
|
||||
createdAt: '2026-05-13T00:00:00.000Z',
|
||||
updatedAt: '2026-05-13T00:00:00.000Z',
|
||||
agentId: 'main',
|
||||
}],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderTasks(['/tasks?task=cron-daily']);
|
||||
|
||||
expect(await screen.findByTestId('tasks-job-cron-daily')).toHaveClass('ring-2');
|
||||
});
|
||||
|
||||
it('runs a task in its fixed conversation and switches there immediately', async () => {
|
||||
useCronStore.setState({
|
||||
jobs: [{
|
||||
|
||||
Reference in New Issue
Block a user