feat: interrupt canceled codex app-server turns

This commit is contained in:
AI Bot
2026-06-03 13:12:23 +08:00
parent 142fb2a4b3
commit 13201e6aee
11 changed files with 517 additions and 1 deletions

View File

@@ -242,3 +242,151 @@ process.exit(0);
await rm(runtimeRoot, { recursive: true, force: true });
}
});
test("local-agent interrupts an active Codex App Server turn after server-side task cancellation", async () => {
const runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-local-agent-app-server-interrupt-"));
const skillsDir = path.join(runtimeRoot, "skills");
const projectDir = path.join(runtimeRoot, "project");
await mkdir(skillsDir, { recursive: true });
await mkdir(projectDir, { recursive: true });
const completeBodies = [];
const controlStateBodies = [];
let claimCount = 0;
const controlPlane = createServer(async (request, response) => {
const url = request.url || "";
if (request.method === "POST" && url === "/api/v1/master-agent/tasks/claim") {
claimCount += 1;
await readJsonBody(request);
response.writeHead(200, { "content-type": "application/json" });
response.end(
JSON.stringify({
ok: true,
task:
claimCount === 1
? {
taskId: "conversation-app-server-interrupt-task",
taskType: "conversation_reply",
projectId: "app-server-project",
requestMessageId: "msg-app-server-interrupt",
requestText: "继续长任务",
executionPrompt: "执行一个可以被取消的长任务",
requestedByAccount: "krisolo",
targetCodexThreadRef: "019d-app-server-thread",
targetCodexFolderRef: projectDir,
requestedAt: "2026-05-16T10:00:00.000Z",
}
: null,
}),
);
return;
}
if (
request.method === "GET" &&
url === "/api/v1/master-agent/tasks/conversation-app-server-interrupt-task/control-state"
) {
controlStateBodies.push({ at: Date.now() });
response.writeHead(200, { "content-type": "application/json" });
response.end(
JSON.stringify({
ok: true,
taskId: "conversation-app-server-interrupt-task",
status: "canceled",
canceled: true,
cancelReason: "用户取消当前 Codex turn",
}),
);
return;
}
if (
request.method === "POST" &&
url === "/api/v1/master-agent/tasks/conversation-app-server-interrupt-task/complete"
) {
completeBodies.push(await readJsonBody(request));
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: true }));
return;
}
if (request.method === "POST" && url === "/api/device-heartbeat") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: true, token: "server-token" }));
return;
}
if (request.method === "POST" && url === "/api/v1/app-logs") {
await readJsonBody(request);
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: true }));
return;
}
if (request.method === "POST" && url === "/api/v1/devices/mac-studio/skills") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: true }));
return;
}
response.writeHead(404, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: false, message: "not_found", url }));
});
const controlPort = await listen(controlPlane);
const agentServer = createServer();
const agentPort = await listen(agentServer);
await new Promise((resolve) => agentServer.close(resolve));
const configPath = path.join(runtimeRoot, "local-agent-config.json");
await writeFile(
configPath,
JSON.stringify({
port: agentPort,
bindHost: "127.0.0.1",
controlPlaneUrl: `http://127.0.0.1:${controlPort}`,
deviceId: "mac-studio",
token: "local-token",
name: "Mac Studio",
account: "krisolo",
status: "online",
codexSessionDiscoveryEnabled: false,
skillsDir,
masterAgentEnabled: true,
masterAgentPollIntervalMs: 60_000,
masterAgentInterruptPollIntervalMs: 10,
heartbeatIntervalMs: 60_000,
skillLifecycleEnabled: false,
masterAgentWorkdir: projectDir,
masterAgentModel: "gpt-5.4",
codexAppServerEnabled: true,
codexAppServerCommand: process.execPath,
codexAppServerArgs: ["tests/fixtures/codex-app-server-runtime.mjs"],
codexAppServerWorkdir: repoRoot,
codexAppServerTimeoutMs: 5000,
}),
"utf8",
);
const previous = process.env.BOSS_CODEX_APP_SERVER_FIXTURE_WAIT_FOR_INTERRUPT;
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_WAIT_FOR_INTERRUPT = "1";
const child = spawn(process.execPath, ["local-agent/server.mjs", configPath], {
cwd: repoRoot,
env: {
...process.env,
},
stdio: ["ignore", "pipe", "pipe"],
});
try {
await waitFor(() => controlStateBodies.length > 0);
await new Promise((resolve) => setTimeout(resolve, 250));
assert.equal(completeBodies.length, 0);
} finally {
if (previous === undefined) {
delete process.env.BOSS_CODEX_APP_SERVER_FIXTURE_WAIT_FOR_INTERRUPT;
} else {
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_WAIT_FOR_INTERRUPT = previous;
}
child.kill("SIGTERM");
await new Promise((resolve) => {
child.once("close", resolve);
}).catch(() => null);
controlPlane.close();
await rm(runtimeRoot, { recursive: true, force: true });
}
});