fix(teacher): 合并续接工具活动并修正暂停状态

This commit is contained in:
2026-09-27 09:11:27 +08:00
parent 60c20f996e
commit 8a74721337
7 changed files with 141 additions and 16 deletions

View File

@@ -246,12 +246,94 @@ it('merges typed tool activity across replay without adding tool data or child e
await prepareCloudTeacher(f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport, activity)
.run([{ role: 'user', content: '问题' }], new AbortController().signal, text);
expect(activity.mock.calls).toEqual([
[{ id: 'run:call', name: 'search', status: 'running' }],
[{ id: 'run:call', name: 'search', status: 'completed' }],
[{ id: requestId + ':call', name: 'search', status: 'running' }],
[{ id: requestId + ':call', name: 'search', status: 'completed' }],
]);
expect(text.mock.calls).toEqual([['最终正文']]);
});
it.each([
{ path: 'src/game.ts', status: 'completed', result: 'success' },
{ path: 'src/missing.ts', status: 'failed', result: 'error' },
])('keeps one $status local read across interrupted and resumed runs', async scenario => {
const f = await fixture();
const activity = vi.fn(), text = vi.fn();
let before = 0, after = 0;
const transport: TeacherCloudTransport = {
json: vi.fn(async url => {
if (url === '/questions') return { request_id: 'cloud-question', run_id: 'before' };
if (url === '/runs/before') return ++before === 1
? { status: 'running', thread_id: 'main' }
: { status: 'interrupted', interrupt: { source: 'client_read_tools', context_id: requestId,
calls: [{ tool_call_id: 'file', name: 'read_project_file', arguments: { path: scenario.path } }],
} };
if (url === '/runs/before/tool-results') return { run_id: 'after' };
if (url === '/runs/after') return ++after === 1
? { status: 'running', thread_id: 'main' }
: { status: 'completed', output: '基于读取结果回答。' };
throw new Error('Unexpected request: ' + url);
}),
events: vi.fn(async (url, _signal, accept) => {
const data = url.includes('/before/')
? { event: 'tool-error', tool_call_id: 'file', message: 'Interrupt' }
: { event: 'tool-finished', tool_call_id: 'file', output: {
type: 'tool', status: 'success', content: JSON.stringify({ status: scenario.result }),
} };
accept('messages', { thread_id: 'main', payload: { chunk: {
stream_event: { type: 'tool_call', tool_call_id: 'file', name: 'read_project_file' },
} } }, '1-0');
accept('custom', { thread_id: 'main', payload: { chunk: {
status: 'stream_event', event: { method: 'tools', data },
} } }, '2-0');
}),
};
await prepareCloudTeacher(f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport, activity)
.run([{ role: 'user', content: '检查项目' }], new AbortController().signal, text);
expect(activity.mock.calls).toEqual([
[{ id: requestId + ':file', name: 'read_project_file', status: 'running' }],
[{ id: requestId + ':file', name: 'read_project_file', status: scenario.status }],
]);
expect(vi.mocked(transport.json).mock.calls.filter(([url]) => url.endsWith('/tool-results'))).toHaveLength(1);
expect(transport.json).toHaveBeenCalledWith('/runs/before/tool-results', {
context_id: requestId,
results: [expect.objectContaining({ tool_call_id: 'file', status: scenario.result })],
}, expect.anything());
expect(text.mock.calls).toEqual([['基于读取结果回答。']]);
});
it('closes a pending tool as failed when its question fails after a raw tool error', async () => {
const f = await fixture();
const activity = vi.fn();
let activityBeforeFailure: unknown;
let reads = 0;
const transport: TeacherCloudTransport = {
json: vi.fn(async url => {
if (url === '/questions') return { request_id: 'cloud-question', run_id: 'run' };
if (url.endsWith('/cancel')) return { status: 'cancelled' };
return ++reads === 1 ? { status: 'running', thread_id: 'main' }
: { status: 'failed', error: { message: '工具执行失败' } };
}),
events: vi.fn(async (_url, _signal, accept) => {
for (const data of [
{ event: 'tool-started', tool_call_id: 'search', tool_name: 'search' },
{ event: 'tool-error', tool_call_id: 'search', message: 'provider unavailable' },
]) accept('custom', { thread_id: 'main', payload: { chunk: {
status: 'stream_event', event: { method: 'tools', data },
} } }, '1-0');
activityBeforeFailure = structuredClone(activity.mock.calls);
}),
};
await expect(prepareCloudTeacher(f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport, activity)
.run([{ role: 'user', content: '搜索' }], new AbortController().signal, vi.fn()))
.rejects.toMatchObject({ code: 'teacher_run_failed' });
expect(activityBeforeFailure).toEqual([
[{ id: requestId + ':search', name: 'search', status: 'running' }],
]);
expect(activity.mock.calls.at(-1)).toEqual([
{ id: requestId + ':search', name: 'search', status: 'failed' },
]);
});
it('submits the active discussion protocol, current tool content and selected focus', async () => {
const f = await fixture();
f.topic.definition.limits.max_input_tokens = 16000;
@@ -677,8 +759,8 @@ it.each(['suggestions', 'discussion-v1'] as const)(
).run([{ role: 'user', content: '一起讨论' }], new AbortController().signal, text);
expect(text.mock.calls).toEqual([[finalOutput]]);
expect(activity.mock.calls).toEqual([
[{ id: 'before-read:file', name: 'read_project_file', status: 'running' }],
[{ id: 'before-read:file', name: 'read_project_file', status: 'completed' }],
[{ id: requestId + ':file', name: 'read_project_file', status: 'running' }],
[{ id: requestId + ':file', name: 'read_project_file', status: 'completed' }],
]);
const response = text.mock.calls.map(([delta]) => delta).join('');
expect(format === 'suggestions'

View File

@@ -29,10 +29,14 @@ describe('teacher cloud tool activity projection', () => {
});
it.each([
['tool-finished', { type: 'tool', content: 'tool result', status: 'success' }, undefined, 'completed'],
['tool-error', undefined, 'private error', 'failed'],
])('accepts real %s events that carry only the call id', (event, output, error, status) => {
expect(cloudToolActivity({ status: 'stream_event', event: { method: 'tools', data: {
event, tool_call_id: 'call', output, error,
} } }, 'run')).toEqual({ id: 'run:call', status });
});
it('waits for the outcome instead of treating a raw tool error as failure', () => {
expect(cloudToolActivity({ status: 'stream_event', event: { method: 'tools', data: {
event: 'tool-error', tool_call_id: 'call', message: 'Interrupt',
} } }, 'question')).toBeUndefined();
});
});