fix(teacher): 合并续接工具活动并修正暂停状态
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user