feat: interrupt canceled codex app-server turns
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user