fix: 等待云端智能体资源释放后续接工具结果

This commit is contained in:
2026-09-24 17:02:24 +08:00
parent 0e22e0545d
commit 79914e1d06
4 changed files with 160 additions and 1 deletions

View File

@@ -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',