fix: 等待云端智能体资源释放后续接工具结果
This commit is contained in:
@@ -5,9 +5,12 @@ import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
prepareCloudTeacher,
|
||||
teacherCloudTransport,
|
||||
type TeacherCloudTransport,
|
||||
} from '../../electron/coding-teacher/cloud-runner';
|
||||
import { TeacherError } from '../../electron/coding-teacher/config-client';
|
||||
import * as teacherConfig from '../../electron/coding-teacher/config-client';
|
||||
import * as cloudFetch from '../../electron/utils/proxy-fetch';
|
||||
import { compileTeacherContext, estimateTeacherTokens } from '../../electron/coding-teacher/context';
|
||||
import { discussionInstructions } from '../../electron/coding-teacher/discussion';
|
||||
const TEACHER_BEHAVIOR_PROMPT = '你是麦洛的创作老师';
|
||||
@@ -18,6 +21,7 @@ import type { TeacherRequestIntent, TeacherTopic } from '../../shared/coding-tea
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
const requestId = '11111111-1111-4111-8111-111111111111';
|
||||
async function fixture() {
|
||||
@@ -68,6 +72,103 @@ async function fixture() {
|
||||
return { topic, access, account, progress, saveRequest };
|
||||
}
|
||||
|
||||
function mockCloudSession() {
|
||||
vi.spyOn(teacherConfig, 'assertTeacherAccount').mockImplementation(() => undefined);
|
||||
vi.spyOn(teacherConfig, 'teacherCloudRequest').mockResolvedValue({
|
||||
access_token: 'test-session', expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
api_base_url: 'https://teacher.test', scope: 'makelore-teachers',
|
||||
});
|
||||
}
|
||||
|
||||
const cleanupBusy = {
|
||||
code: 'run_busy', message: '该智能体线程正在运行,请等待、查询或取消当前运行后再继续',
|
||||
active_run_id: 'run-1', active_run_status: 'interrupted',
|
||||
};
|
||||
|
||||
it('waits for the interrupted parent cleanup and resubmits the same three read results without cancelling', async () => {
|
||||
const f = await fixture();
|
||||
mockCloudSession();
|
||||
const resultBodies: string[] = [], paths: string[] = [];
|
||||
vi.spyOn(cloudFetch, 'proxyAwareFetch').mockImplementation(async (input, init) => {
|
||||
const pathname = new URL(String(input)).pathname.replace('/api/makelore/teachers', '');
|
||||
paths.push(pathname);
|
||||
if (pathname === '/questions') return Response.json({ request_id: 'cloud-request', run_id: 'run-1' });
|
||||
if (pathname === '/runs/run-1') return Response.json({
|
||||
status: 'interrupted', thread_id: 'teacher-thread',
|
||||
interrupt: { source: 'client_read_tools', context_id: requestId, calls: [
|
||||
{ tool_call_id: 'files', name: 'list_project_files', arguments: { path: '.' } },
|
||||
{ tool_call_id: 'file', name: 'read_project_file', arguments: { path: 'src/game.ts' } },
|
||||
{ tool_call_id: 'history', name: 'read_conversation', arguments: { message_id: 'pi-message' } },
|
||||
] },
|
||||
});
|
||||
if (pathname === '/runs/run-1/tool-results') {
|
||||
resultBodies.push(String(init?.body));
|
||||
// A later local edit must not alter the batch that is already being resumed.
|
||||
await writeFile(path.join(f.access.projectPath, 'src/game.ts'), 'const gravity = 9;');
|
||||
return resultBodies.length <= 4
|
||||
? Response.json({ detail: cleanupBusy }, { status: 409 })
|
||||
: Response.json({ run_id: 'run-2' });
|
||||
}
|
||||
if (pathname === '/runs/run-2') return Response.json({ status: 'completed', output: '项目与会话已读完。' });
|
||||
if (pathname.endsWith('/cancel')) return Response.json({ status: 'cancelled' });
|
||||
throw new Error('Unexpected request: ' + pathname);
|
||||
});
|
||||
const text = vi.fn();
|
||||
await prepareCloudTeacher(f.account, f.topic, requestId, f.access, f.progress, f.saveRequest,
|
||||
teacherCloudTransport(f.account))
|
||||
.run([{ role: 'user', content: '我想结构化梳理一下' }], new AbortController().signal, text);
|
||||
expect(text).toHaveBeenCalledWith('项目与会话已读完。');
|
||||
expect(resultBodies).toHaveLength(5);
|
||||
expect(new Set(resultBodies).size).toBe(1);
|
||||
expect(JSON.parse(resultBodies[0]).results).toHaveLength(3);
|
||||
expect(resultBodies[0]).toContain('gravity = 0.6');
|
||||
expect(paths.filter(item => item === '/questions')).toHaveLength(1);
|
||||
expect(paths.filter(item => item === '/runs/run-1')).toHaveLength(1);
|
||||
expect(paths.some(item => item.endsWith('/cancel'))).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...cleanupBusy, active_run_id: 'other-run' },
|
||||
{ ...cleanupBusy, active_run_status: 'running' },
|
||||
{ ...cleanupBusy, code: 'operation_conflict' },
|
||||
])('does not retry a different execution or conflicting result: %j', async detail => {
|
||||
const f = await fixture();
|
||||
mockCloudSession();
|
||||
const fetch = vi.spyOn(cloudFetch, 'proxyAwareFetch').mockImplementation(async () =>
|
||||
Response.json({ detail }, { status: 409 }));
|
||||
await expect(teacherCloudTransport(f.account).json('/runs/run-1/tool-results', {}, new AbortController().signal))
|
||||
.rejects.toMatchObject({ status: 409 });
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not replay a new question on a busy-thread response', async () => {
|
||||
const f = await fixture();
|
||||
mockCloudSession();
|
||||
const fetch = vi.spyOn(cloudFetch, 'proxyAwareFetch').mockImplementation(async () =>
|
||||
Response.json({ detail: cleanupBusy }, { status: 409 }));
|
||||
await expect(teacherCloudTransport(f.account).json('/questions', { request_id: requestId }, new AbortController().signal))
|
||||
.rejects.toMatchObject({ status: 409 });
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps cleanup waiting cancellable and does not resubmit after abort', async () => {
|
||||
const f = await fixture();
|
||||
mockCloudSession();
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('student stopped');
|
||||
const timer = setTimeout(() => controller.abort(reason), 50);
|
||||
const fetch = vi.spyOn(cloudFetch, 'proxyAwareFetch').mockImplementation(async () => {
|
||||
return Response.json({ detail: cleanupBusy }, { status: 409 });
|
||||
});
|
||||
try {
|
||||
await expect(teacherCloudTransport(f.account).json('/runs/run-1/tool-results', {}, controller.signal))
|
||||
.rejects.toBe(reason);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
async function submitCompiledContext(
|
||||
f: Awaited<ReturnType<typeof fixture>>,
|
||||
intent: TeacherRequestIntent = 'question',
|
||||
|
||||
Reference in New Issue
Block a user