feat: adapt codex app-server protocol updates
This commit is contained in:
87
tests/codex-app-server-protocol-snapshot-script.test.mjs
Normal file
87
tests/codex-app-server-protocol-snapshot-script.test.mjs
Normal file
@@ -0,0 +1,87 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
test("codex app-server protocol snapshot script records schema, help and method inventory", async () => {
|
||||
const runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-codex-protocol-snapshot-"));
|
||||
const fakeBin = path.join(runtimeRoot, "codex");
|
||||
const outDir = path.join(runtimeRoot, "snapshot");
|
||||
await writeFile(
|
||||
fakeBin,
|
||||
`#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const args = process.argv.slice(2);
|
||||
function writeGenerated(out, name, content) {
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
fs.writeFileSync(path.join(out, name), content);
|
||||
}
|
||||
if (args.includes("--version")) {
|
||||
console.log("codex-cli 0.135.0-alpha.1");
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === "app-server" && args.includes("--help")) {
|
||||
console.log("Usage: codex app-server [OPTIONS]");
|
||||
console.log("--listen <URL> stdio:// unix:// ws://IP:PORT off");
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === "app-server" && args[1] === "generate-json-schema") {
|
||||
const out = args[args.indexOf("--out") + 1];
|
||||
writeGenerated(out, "codex_app_server_protocol.schemas.json", JSON.stringify({
|
||||
anyOf: [
|
||||
{ properties: { method: { const: "thread/start" } } },
|
||||
{ properties: { method: { const: "thread/inject_items" } } },
|
||||
{ properties: { method: { const: "turn/start" } } }
|
||||
]
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === "app-server" && args[1] === "generate-ts") {
|
||||
const out = args[args.indexOf("--out") + 1];
|
||||
writeGenerated(out, "ClientRequest.ts", 'export type ClientRequest = { "method": "thread/start" } | { "method": "turn/start" };\\n');
|
||||
process.exit(0);
|
||||
}
|
||||
console.error("unexpected args " + args.join(" "));
|
||||
process.exit(2);
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
await chmod(fakeBin, 0o755);
|
||||
|
||||
try {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"scripts/codex-app-server-protocol-snapshot.mjs",
|
||||
"--codex-bin",
|
||||
fakeBin,
|
||||
"--out-dir",
|
||||
outDir,
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const manifest = JSON.parse(await readFile(path.join(outDir, "0.135.0-alpha.1", "manifest.json"), "utf8"));
|
||||
assert.equal(manifest.codexVersion, "0.135.0-alpha.1");
|
||||
assert.equal(manifest.supports.wsTransport, true);
|
||||
assert.equal(manifest.supports.unixTransport, true);
|
||||
assert.equal(manifest.supports.threadInjectItems, true);
|
||||
assert.deepEqual(manifest.methods, ["thread/inject_items", "thread/start", "turn/start"]);
|
||||
assert.match(
|
||||
await readFile(path.join(outDir, "0.135.0-alpha.1", "app-server-help.txt"), "utf8"),
|
||||
/ws:\/\/IP:PORT/,
|
||||
);
|
||||
} finally {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
148
tests/fixtures/codex-app-server-runtime.mjs
vendored
148
tests/fixtures/codex-app-server-runtime.mjs
vendored
@@ -4,6 +4,8 @@ import readline from "node:readline";
|
||||
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
const received = [];
|
||||
let injectedItems = [];
|
||||
let overloadedTurnStartEmitted = false;
|
||||
|
||||
function send(message) {
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
@@ -42,6 +44,42 @@ rl.on("line", (line) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method === "thread/read") {
|
||||
send({
|
||||
id: message.id,
|
||||
result: {
|
||||
thread: {
|
||||
id: message.params?.threadId,
|
||||
name: "源线程",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "output_text",
|
||||
text: "源线程最近结论:优先使用 app-server 协议。",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method === "thread/inject_items") {
|
||||
injectedItems = message.params?.items ?? [];
|
||||
send({
|
||||
id: message.id,
|
||||
result: {
|
||||
injected: injectedItems.length,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method === "thread/start") {
|
||||
send({
|
||||
id: message.id,
|
||||
@@ -56,6 +94,20 @@ rl.on("line", (line) => {
|
||||
}
|
||||
|
||||
if (message.method === "turn/start") {
|
||||
if (
|
||||
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_OVERLOAD_ON_TURN_START === "1" &&
|
||||
!overloadedTurnStartEmitted
|
||||
) {
|
||||
overloadedTurnStartEmitted = true;
|
||||
send({
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32001,
|
||||
message: "Server overloaded; retry later.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const text = message.params?.input?.find?.((item) => item?.type === "text")?.text ?? "";
|
||||
send({
|
||||
id: message.id,
|
||||
@@ -69,12 +121,70 @@ rl.on("line", (line) => {
|
||||
if (process.env.BOSS_CODEX_APP_SERVER_FIXTURE_EXIT_AFTER_TURN_START === "1") {
|
||||
process.exit(0);
|
||||
}
|
||||
if (process.env.BOSS_CODEX_APP_SERVER_FIXTURE_EMIT_PROGRESS === "1") {
|
||||
send({
|
||||
method: "turn/plan/updated",
|
||||
params: {
|
||||
threadId: message.params?.threadId,
|
||||
turnId: "turn-fixture",
|
||||
plan: [
|
||||
{ text: "读取 Codex 官方 app-server 协议", status: "completed" },
|
||||
{ text: "执行 targeted/full test", status: "in_progress" },
|
||||
],
|
||||
},
|
||||
});
|
||||
send({
|
||||
method: "turn/diff/updated",
|
||||
params: {
|
||||
threadId: message.params?.threadId,
|
||||
turnId: "turn-fixture",
|
||||
diff: {
|
||||
changedFiles: 3,
|
||||
additions: 181,
|
||||
deletions: 52,
|
||||
},
|
||||
},
|
||||
});
|
||||
send({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: message.params?.threadId,
|
||||
turnId: "turn-fixture",
|
||||
item: {
|
||||
type: "fileChange",
|
||||
id: "file-change-1",
|
||||
changes: [
|
||||
{ path: "docs/protocol-snapshots/codex-app-server/codex-app-server-protocol-0.135.0.json" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
send({
|
||||
method: "thread/started",
|
||||
params: {
|
||||
thread: {
|
||||
id: "subagent-thread",
|
||||
source: {
|
||||
subAgent: {
|
||||
thread_spawn: {
|
||||
agent_nickname: "Mendel",
|
||||
agent_role: "explorer",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
send({
|
||||
method: "item/agentMessage/delta",
|
||||
params: {
|
||||
threadId: message.params?.threadId,
|
||||
turnId: "turn-fixture",
|
||||
delta: `APP_SERVER_REPLY:${text}`,
|
||||
delta:
|
||||
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_INTER_THREAD === "1"
|
||||
? `INTER_THREAD_INJECTED:${JSON.stringify(injectedItems)}`
|
||||
: `APP_SERVER_REPLY:${text}`,
|
||||
},
|
||||
});
|
||||
send({
|
||||
@@ -91,6 +201,42 @@ rl.on("line", (line) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method === "turn/steer") {
|
||||
const text = message.params?.input?.find?.((item) => item?.type === "text")?.text ?? "";
|
||||
send({
|
||||
id: message.id,
|
||||
result: {
|
||||
turn: {
|
||||
id: message.params?.expectedTurnId,
|
||||
threadId: message.params?.threadId,
|
||||
},
|
||||
},
|
||||
});
|
||||
send({
|
||||
method: "item/agentMessage/delta",
|
||||
params: {
|
||||
threadId: message.params?.threadId,
|
||||
turnId: message.params?.expectedTurnId,
|
||||
delta:
|
||||
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_STEER === "1"
|
||||
? `STEERED:${text}`
|
||||
: `APP_SERVER_STEERED:${text}`,
|
||||
},
|
||||
});
|
||||
send({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: message.params?.threadId,
|
||||
turn: {
|
||||
id: message.params?.expectedTurnId,
|
||||
status: "completed",
|
||||
},
|
||||
},
|
||||
});
|
||||
process.stderr.write(`${JSON.stringify({ received })}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
send({
|
||||
id: message.id,
|
||||
error: {
|
||||
|
||||
145
tests/inter-thread-collaboration-route.test.ts
Normal file
145
tests/inter-thread-collaboration-route.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let postRoute: (typeof import("../src/app/api/v1/projects/[projectId]/thread-collaboration/route"))["POST"];
|
||||
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
||||
let AUTH_SESSION_COOKIE = "";
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-inter-thread-collaboration-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const [route, data, auth] = await Promise.all([
|
||||
import("../src/app/api/v1/projects/[projectId]/thread-collaboration/route.ts"),
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
]);
|
||||
postRoute = route.POST;
|
||||
createAuthSession = data.createAuthSession;
|
||||
readState = data.readState;
|
||||
writeState = data.writeState;
|
||||
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function createAuthedRequest(projectId: string, body: unknown) {
|
||||
const session = await createAuthSession({
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
});
|
||||
return new NextRequest(`http://127.0.0.1:3000/api/v1/projects/${projectId}/thread-collaboration`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function buildThreadProject(input: {
|
||||
id: string;
|
||||
name: string;
|
||||
threadId: string;
|
||||
codexThreadRef: string;
|
||||
codexFolderRef: string;
|
||||
}) {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
pinned: false,
|
||||
systemPinned: false,
|
||||
deviceIds: ["mac-studio"],
|
||||
preview: "",
|
||||
updatedAt: "2026-05-31T10:00:00+08:00",
|
||||
lastMessageAt: "2026-05-31T10:00:00+08:00",
|
||||
isGroup: false,
|
||||
threadMeta: {
|
||||
projectId: input.id,
|
||||
threadId: input.threadId,
|
||||
threadDisplayName: input.name,
|
||||
folderName: input.codexFolderRef.split("/").pop() || input.codexFolderRef,
|
||||
codexFolderRef: input.codexFolderRef,
|
||||
codexThreadRef: input.codexThreadRef,
|
||||
updatedAt: "2026-05-31T10:00:00+08:00",
|
||||
},
|
||||
groupMembers: [],
|
||||
createdByAgent: false,
|
||||
collaborationMode: "development" as const,
|
||||
approvalState: "not_required" as const,
|
||||
unreadCount: 0,
|
||||
riskLevel: "low" as const,
|
||||
messages: [],
|
||||
goals: [],
|
||||
versions: [],
|
||||
};
|
||||
}
|
||||
|
||||
test("POST /thread-collaboration queues a source-to-target Codex app-server collaboration task", async () => {
|
||||
const state = await readState();
|
||||
const source = buildThreadProject({
|
||||
id: "source-project",
|
||||
name: "源线程",
|
||||
threadId: "source-thread",
|
||||
codexThreadRef: "codex-source-thread",
|
||||
codexFolderRef: "/Users/kris/code/source",
|
||||
});
|
||||
const target = buildThreadProject({
|
||||
id: "target-project",
|
||||
name: "目标线程",
|
||||
threadId: "target-thread",
|
||||
codexThreadRef: "codex-target-thread",
|
||||
codexFolderRef: "/Users/kris/code/target",
|
||||
});
|
||||
state.projects = [
|
||||
...state.projects.filter((project) => project.id === "master-agent"),
|
||||
source,
|
||||
target,
|
||||
];
|
||||
await writeState(state);
|
||||
|
||||
const response = await postRoute(
|
||||
await createAuthedRequest(source.id, {
|
||||
targetProjectId: target.id,
|
||||
body: "把源线程的最新方案同步给目标线程,并让目标线程继续实现。",
|
||||
}),
|
||||
{ params: Promise.resolve({ projectId: source.id }) },
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.task.intentCategory, "thread_collaboration");
|
||||
assert.equal(payload.task.sourceProjectId, source.id);
|
||||
assert.equal(payload.task.sourceCodexThreadRef, "codex-source-thread");
|
||||
assert.equal(payload.task.targetProjectId, target.id);
|
||||
assert.equal(payload.task.targetCodexThreadRef, "codex-target-thread");
|
||||
assert.equal(payload.task.targetCodexFolderRef, "/Users/kris/code/target");
|
||||
|
||||
const persisted = (await readState()).masterAgentTasks.find(
|
||||
(task) => task.taskId === payload.task.taskId,
|
||||
);
|
||||
assert.equal(persisted?.deviceId, "mac-studio");
|
||||
assert.equal(persisted?.status, "queued");
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -11,6 +15,198 @@ import {
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
function encodeWsTextFrame(value) {
|
||||
const payload = Buffer.from(value);
|
||||
if (payload.length < 126) {
|
||||
return Buffer.concat([Buffer.from([0x81, payload.length]), payload]);
|
||||
}
|
||||
if (payload.length <= 0xffff) {
|
||||
const header = Buffer.alloc(4);
|
||||
header[0] = 0x81;
|
||||
header[1] = 126;
|
||||
header.writeUInt16BE(payload.length, 2);
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
const header = Buffer.alloc(10);
|
||||
header[0] = 0x81;
|
||||
header[1] = 127;
|
||||
header.writeBigUInt64BE(BigInt(payload.length), 2);
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
|
||||
function decodeWsFrames(buffer, socket, onText) {
|
||||
let offset = 0;
|
||||
while (buffer.length - offset >= 2) {
|
||||
const first = buffer[offset];
|
||||
const second = buffer[offset + 1];
|
||||
const opcode = first & 0x0f;
|
||||
const masked = (second & 0x80) !== 0;
|
||||
let payloadLength = second & 0x7f;
|
||||
let headerLength = 2;
|
||||
if (payloadLength === 126) {
|
||||
if (buffer.length - offset < 4) break;
|
||||
payloadLength = buffer.readUInt16BE(offset + 2);
|
||||
headerLength = 4;
|
||||
} else if (payloadLength === 127) {
|
||||
if (buffer.length - offset < 10) break;
|
||||
payloadLength = Number(buffer.readBigUInt64BE(offset + 2));
|
||||
headerLength = 10;
|
||||
}
|
||||
const maskLength = masked ? 4 : 0;
|
||||
const frameLength = headerLength + maskLength + payloadLength;
|
||||
if (buffer.length - offset < frameLength) break;
|
||||
|
||||
let payload = buffer.subarray(
|
||||
offset + headerLength + maskLength,
|
||||
offset + frameLength,
|
||||
);
|
||||
if (masked) {
|
||||
const mask = buffer.subarray(offset + headerLength, offset + headerLength + 4);
|
||||
const unmaskedPayload = Buffer.alloc(payload.length);
|
||||
for (let index = 0; index < payload.length; index += 1) {
|
||||
unmaskedPayload[index] = payload[index] ^ mask[index % 4];
|
||||
}
|
||||
payload = unmaskedPayload;
|
||||
}
|
||||
if (opcode === 0x1) {
|
||||
onText(payload.toString("utf8"));
|
||||
} else if (opcode === 0x8) {
|
||||
socket.end();
|
||||
} else if (opcode === 0x9) {
|
||||
socket.write(Buffer.concat([Buffer.from([0x8a, payload.length]), payload]));
|
||||
}
|
||||
offset += frameLength;
|
||||
}
|
||||
return buffer.subarray(offset);
|
||||
}
|
||||
|
||||
async function createCodexAppServerWebSocketFixture(options = {}) {
|
||||
const server = http.createServer();
|
||||
const sockets = new Set();
|
||||
let lastAuthorization = "";
|
||||
server.on("upgrade", (request, socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => {
|
||||
sockets.delete(socket);
|
||||
});
|
||||
lastAuthorization = String(request.headers.authorization || "");
|
||||
const key = request.headers["sec-websocket-key"];
|
||||
const accept = crypto
|
||||
.createHash("sha1")
|
||||
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
||||
.digest("base64");
|
||||
socket.write(
|
||||
[
|
||||
"HTTP/1.1 101 Switching Protocols",
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
`Sec-WebSocket-Accept: ${accept}`,
|
||||
"",
|
||||
"",
|
||||
].join("\r\n"),
|
||||
);
|
||||
|
||||
let buffered = Buffer.alloc(0);
|
||||
const send = (message) => {
|
||||
socket.write(encodeWsTextFrame(JSON.stringify(message)));
|
||||
};
|
||||
socket.on("data", (chunk) => {
|
||||
buffered = decodeWsFrames(Buffer.concat([buffered, chunk]), socket, (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.method === "initialize") {
|
||||
send({
|
||||
id: message.id,
|
||||
result: {
|
||||
userAgent: "boss-test-codex-ws-app-server",
|
||||
platformFamily: "mac",
|
||||
platformOs: "darwin",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.method === "initialized") {
|
||||
return;
|
||||
}
|
||||
if (message.method === "thread/resume") {
|
||||
send({
|
||||
id: message.id,
|
||||
result: {
|
||||
thread: {
|
||||
id: message.params?.threadId,
|
||||
name: "ws fixture thread",
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.method === "turn/start") {
|
||||
const text = message.params?.input?.find?.((item) => item?.type === "text")?.text ?? "";
|
||||
send({
|
||||
id: message.id,
|
||||
result: {
|
||||
turn: {
|
||||
id: "ws-turn-fixture",
|
||||
threadId: message.params?.threadId,
|
||||
},
|
||||
},
|
||||
});
|
||||
send({
|
||||
method: "turn/plan/updated",
|
||||
params: {
|
||||
threadId: message.params?.threadId,
|
||||
turnId: "ws-turn-fixture",
|
||||
plan: [{ text: "通过 WebSocket 接入 Codex App Server", status: "completed" }],
|
||||
},
|
||||
});
|
||||
send({
|
||||
method: "item/agentMessage/delta",
|
||||
params: {
|
||||
threadId: message.params?.threadId,
|
||||
turnId: "ws-turn-fixture",
|
||||
delta: `WS_APP_SERVER_REPLY:${text}`,
|
||||
},
|
||||
});
|
||||
send({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: message.params?.threadId,
|
||||
turn: { id: "ws-turn-fixture", status: "completed" },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
send({
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: `unknown method ${message.method}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
let socketPath;
|
||||
if (options.unixSocketPath) {
|
||||
socketPath = options.unixSocketPath;
|
||||
await new Promise((resolveServer) => server.listen(socketPath, resolveServer));
|
||||
} else {
|
||||
await new Promise((resolveServer) => server.listen(0, "127.0.0.1", resolveServer));
|
||||
}
|
||||
const address = server.address();
|
||||
return {
|
||||
url: socketPath ? `unix://${socketPath}` : `ws://127.0.0.1:${address.port}`,
|
||||
getLastAuthorization: () => lastAuthorization,
|
||||
close: () =>
|
||||
new Promise((resolveServer) => {
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
server.close(resolveServer);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
test("codex app-server runner resumes a thread and collects streamed agent text", async () => {
|
||||
const runnerConfig = getCodexAppServerRunnerConfig(process.env, {
|
||||
codexAppServerEnabled: true,
|
||||
@@ -39,6 +235,317 @@ test("codex app-server runner resumes a thread and collects streamed agent text"
|
||||
assert.equal(result.transport, "stdio");
|
||||
});
|
||||
|
||||
test("codex app-server runner converts protocol progress events into Boss execution progress", async () => {
|
||||
const previous = process.env.BOSS_CODEX_APP_SERVER_FIXTURE_EMIT_PROGRESS;
|
||||
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_EMIT_PROGRESS = "1";
|
||||
try {
|
||||
const runnerConfig = getCodexAppServerRunnerConfig(process.env, {
|
||||
codexAppServerEnabled: true,
|
||||
codexAppServerCommand: process.execPath,
|
||||
codexAppServerArgs: ["tests/fixtures/codex-app-server-runtime.mjs"],
|
||||
codexAppServerWorkdir: repoRoot,
|
||||
codexAppServerTimeoutMs: 5000,
|
||||
masterAgentModel: "gpt-5.4",
|
||||
});
|
||||
|
||||
const result = await executeCodexAppServerTask(runnerConfig, {
|
||||
taskId: "task-app-server-progress",
|
||||
taskType: "dispatch_execution",
|
||||
targetCodexThreadRef: "019d-app-server-thread",
|
||||
targetCodexFolderRef: repoRoot,
|
||||
executionPrompt: "实现并回归",
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(result.executionProgress.steps[0].text, "读取 Codex 官方 app-server 协议");
|
||||
assert.equal(result.executionProgress.steps[0].status, "done");
|
||||
assert.equal(result.executionProgress.steps[1].text, "执行 targeted/full test");
|
||||
assert.equal(result.executionProgress.steps[1].status, "running");
|
||||
assert.equal(result.executionProgress.branch.changedFiles, 3);
|
||||
assert.equal(result.executionProgress.branch.additions, 181);
|
||||
assert.equal(result.executionProgress.branch.deletions, 52);
|
||||
assert.deepEqual(result.executionProgress.artifacts, [
|
||||
{
|
||||
id: "artifact-1",
|
||||
label: "codex-app-server-protocol-0.135.0.json",
|
||||
kind: "file",
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(result.executionProgress.agents, [
|
||||
{
|
||||
name: "Mendel",
|
||||
role: "explorer",
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.BOSS_CODEX_APP_SERVER_FIXTURE_EMIT_PROGRESS;
|
||||
} else {
|
||||
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_EMIT_PROGRESS = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("codex app-server runner bridges source thread context into target thread through inject_items", async () => {
|
||||
const previous = process.env.BOSS_CODEX_APP_SERVER_FIXTURE_INTER_THREAD;
|
||||
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_INTER_THREAD = "1";
|
||||
try {
|
||||
const runnerConfig = getCodexAppServerRunnerConfig(process.env, {
|
||||
codexAppServerEnabled: true,
|
||||
codexAppServerCommand: process.execPath,
|
||||
codexAppServerArgs: ["tests/fixtures/codex-app-server-runtime.mjs"],
|
||||
codexAppServerWorkdir: repoRoot,
|
||||
codexAppServerTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
const result = await executeCodexAppServerTask(runnerConfig, {
|
||||
taskId: "task-inter-thread",
|
||||
taskType: "conversation_reply",
|
||||
intentCategory: "thread_collaboration",
|
||||
sourceCodexThreadRef: "source-thread-1",
|
||||
sourceThreadDisplayName: "源线程",
|
||||
targetCodexThreadRef: "target-thread-2",
|
||||
targetThreadDisplayName: "目标线程",
|
||||
targetCodexFolderRef: repoRoot,
|
||||
executionPrompt: "请基于源线程结论继续实现",
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(result.threadId, "target-thread-2");
|
||||
assert.equal(result.interThreadBroker.sourceThreadId, "source-thread-1");
|
||||
assert.equal(result.interThreadBroker.targetThreadId, "target-thread-2");
|
||||
assert.equal(result.interThreadBroker.injectedItemCount, 1);
|
||||
assert.match(result.replyBody, /INTER_THREAD_INJECTED/);
|
||||
assert.match(result.replyBody, /源线程最近结论:优先使用 app-server 协议/);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.BOSS_CODEX_APP_SERVER_FIXTURE_INTER_THREAD;
|
||||
} else {
|
||||
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_INTER_THREAD = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("codex app-server runner steers an active turn when a target turn id is present", async () => {
|
||||
const previous = process.env.BOSS_CODEX_APP_SERVER_FIXTURE_STEER;
|
||||
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_STEER = "1";
|
||||
try {
|
||||
const runnerConfig = getCodexAppServerRunnerConfig(process.env, {
|
||||
codexAppServerEnabled: true,
|
||||
codexAppServerCommand: process.execPath,
|
||||
codexAppServerArgs: ["tests/fixtures/codex-app-server-runtime.mjs"],
|
||||
codexAppServerWorkdir: repoRoot,
|
||||
codexAppServerTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
const result = await executeCodexAppServerTask(runnerConfig, {
|
||||
taskId: "task-turn-steer",
|
||||
taskType: "conversation_reply",
|
||||
targetCodexThreadRef: "active-thread-1",
|
||||
targetCodexTurnId: "active-turn-1",
|
||||
targetCodexFolderRef: repoRoot,
|
||||
executionPrompt: "先暂停写入,改为只做 diff 检查",
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(result.threadId, "active-thread-1");
|
||||
assert.equal(result.turnId, "active-turn-1");
|
||||
assert.equal(result.turnControl, "steer");
|
||||
assert.equal(result.replyBody, "STEERED:先暂停写入,改为只做 diff 检查");
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.BOSS_CODEX_APP_SERVER_FIXTURE_STEER;
|
||||
} else {
|
||||
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_STEER = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("codex app-server config exposes ws transport without mutating stdio defaults", () => {
|
||||
const previousTransport = process.env.BOSS_CODEX_APP_SERVER_TRANSPORT;
|
||||
const previousUrl = process.env.BOSS_CODEX_APP_SERVER_URL;
|
||||
process.env.BOSS_CODEX_APP_SERVER_TRANSPORT = "ws";
|
||||
process.env.BOSS_CODEX_APP_SERVER_URL = "ws://127.0.0.1:4500";
|
||||
try {
|
||||
const runnerConfig = getCodexAppServerRunnerConfig(process.env, {
|
||||
codexAppServerEnabled: true,
|
||||
});
|
||||
|
||||
assert.equal(runnerConfig.transport, "ws");
|
||||
assert.equal(runnerConfig.url, "ws://127.0.0.1:4500");
|
||||
assert.equal(runnerConfig.args[0], "app-server");
|
||||
} finally {
|
||||
if (previousTransport === undefined) {
|
||||
delete process.env.BOSS_CODEX_APP_SERVER_TRANSPORT;
|
||||
} else {
|
||||
process.env.BOSS_CODEX_APP_SERVER_TRANSPORT = previousTransport;
|
||||
}
|
||||
if (previousUrl === undefined) {
|
||||
delete process.env.BOSS_CODEX_APP_SERVER_URL;
|
||||
} else {
|
||||
process.env.BOSS_CODEX_APP_SERVER_URL = previousUrl;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("codex app-server runner connects to a ws app-server endpoint without spawning stdio", async () => {
|
||||
const fixture = await createCodexAppServerWebSocketFixture();
|
||||
try {
|
||||
const runnerConfig = getCodexAppServerRunnerConfig(process.env, {
|
||||
codexAppServerEnabled: true,
|
||||
codexAppServerTransport: "ws",
|
||||
codexAppServerUrl: fixture.url,
|
||||
codexAppServerCommand: "definitely-not-a-real-codex-app-server",
|
||||
codexAppServerWorkdir: repoRoot,
|
||||
codexAppServerTimeoutMs: 5000,
|
||||
masterAgentModel: "gpt-5.4",
|
||||
});
|
||||
|
||||
const result = await executeCodexAppServerTask(runnerConfig, {
|
||||
taskId: "task-ws-app-server",
|
||||
taskType: "conversation_reply",
|
||||
targetCodexThreadRef: "ws-thread-1",
|
||||
targetCodexFolderRef: repoRoot,
|
||||
executionPrompt: "用 ws 路径回复",
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(result.transport, "ws");
|
||||
assert.equal(result.threadId, "ws-thread-1");
|
||||
assert.equal(result.turnId, "ws-turn-fixture");
|
||||
assert.equal(result.replyBody, "WS_APP_SERVER_REPLY:用 ws 路径回复");
|
||||
assert.equal(result.executionProgress.steps[0].text, "通过 WebSocket 接入 Codex App Server");
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("codex app-server runner sends bearer auth during ws handshake", async () => {
|
||||
const fixture = await createCodexAppServerWebSocketFixture();
|
||||
try {
|
||||
const runnerConfig = getCodexAppServerRunnerConfig(process.env, {
|
||||
codexAppServerEnabled: true,
|
||||
codexAppServerTransport: "ws",
|
||||
codexAppServerUrl: fixture.url,
|
||||
codexAppServerAuthToken: "boss-ws-token",
|
||||
codexAppServerCommand: "definitely-not-a-real-codex-app-server",
|
||||
codexAppServerWorkdir: repoRoot,
|
||||
codexAppServerTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
const result = await executeCodexAppServerTask(runnerConfig, {
|
||||
taskId: "task-ws-auth",
|
||||
taskType: "conversation_reply",
|
||||
targetCodexThreadRef: "ws-auth-thread",
|
||||
targetCodexFolderRef: repoRoot,
|
||||
executionPrompt: "验证 ws 鉴权",
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(result.replyBody, "WS_APP_SERVER_REPLY:验证 ws 鉴权");
|
||||
assert.equal(fixture.getLastAuthorization(), "Bearer boss-ws-token");
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("codex app-server runner can load bearer auth from a local token file", async () => {
|
||||
const runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-codex-app-server-auth-"));
|
||||
const tokenPath = path.join(runtimeRoot, "ws-token.txt");
|
||||
await writeFile(tokenPath, "boss-token-from-file\n", "utf8");
|
||||
const fixture = await createCodexAppServerWebSocketFixture();
|
||||
try {
|
||||
const runnerConfig = getCodexAppServerRunnerConfig(process.env, {
|
||||
codexAppServerEnabled: true,
|
||||
codexAppServerTransport: "ws",
|
||||
codexAppServerUrl: fixture.url,
|
||||
codexAppServerAuthTokenFile: tokenPath,
|
||||
codexAppServerCommand: "definitely-not-a-real-codex-app-server",
|
||||
codexAppServerWorkdir: repoRoot,
|
||||
codexAppServerTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
const result = await executeCodexAppServerTask(runnerConfig, {
|
||||
taskId: "task-ws-auth-file",
|
||||
taskType: "conversation_reply",
|
||||
targetCodexThreadRef: "ws-auth-file-thread",
|
||||
targetCodexFolderRef: repoRoot,
|
||||
executionPrompt: "验证 token file",
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(fixture.getLastAuthorization(), "Bearer boss-token-from-file");
|
||||
} finally {
|
||||
await fixture.close();
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("codex app-server runner connects to a unix socket app-server endpoint", async () => {
|
||||
const runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-codex-app-server-unix-"));
|
||||
const fixture = await createCodexAppServerWebSocketFixture({
|
||||
unixSocketPath: path.join(runtimeRoot, "codex-app-server.sock"),
|
||||
});
|
||||
try {
|
||||
const runnerConfig = getCodexAppServerRunnerConfig(process.env, {
|
||||
codexAppServerEnabled: true,
|
||||
codexAppServerTransport: "unix",
|
||||
codexAppServerUrl: fixture.url,
|
||||
codexAppServerCommand: "definitely-not-a-real-codex-app-server",
|
||||
codexAppServerWorkdir: repoRoot,
|
||||
codexAppServerTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
const result = await executeCodexAppServerTask(runnerConfig, {
|
||||
taskId: "task-unix-app-server",
|
||||
taskType: "conversation_reply",
|
||||
targetCodexThreadRef: "unix-thread-1",
|
||||
targetCodexFolderRef: repoRoot,
|
||||
executionPrompt: "用 unix socket 回复",
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(result.transport, "unix");
|
||||
assert.equal(result.threadId, "unix-thread-1");
|
||||
assert.equal(result.replyBody, "WS_APP_SERVER_REPLY:用 unix socket 回复");
|
||||
} finally {
|
||||
await fixture.close();
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("codex app-server runner retries transient overloaded JSON-RPC requests", async () => {
|
||||
const previous = process.env.BOSS_CODEX_APP_SERVER_FIXTURE_OVERLOAD_ON_TURN_START;
|
||||
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_OVERLOAD_ON_TURN_START = "1";
|
||||
try {
|
||||
const runnerConfig = getCodexAppServerRunnerConfig(process.env, {
|
||||
codexAppServerEnabled: true,
|
||||
codexAppServerCommand: process.execPath,
|
||||
codexAppServerArgs: ["tests/fixtures/codex-app-server-runtime.mjs"],
|
||||
codexAppServerWorkdir: repoRoot,
|
||||
codexAppServerTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
const result = await executeCodexAppServerTask(runnerConfig, {
|
||||
taskId: "task-app-server-overloaded",
|
||||
taskType: "conversation_reply",
|
||||
targetCodexThreadRef: "019d-app-server-thread",
|
||||
targetCodexFolderRef: repoRoot,
|
||||
executionPrompt: "拥塞后重试",
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(result.replyBody, "APP_SERVER_REPLY:拥塞后重试");
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.BOSS_CODEX_APP_SERVER_FIXTURE_OVERLOAD_ON_TURN_START;
|
||||
} else {
|
||||
process.env.BOSS_CODEX_APP_SERVER_FIXTURE_OVERLOAD_ON_TURN_START = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("codex app-server runner stays disabled unless feature flag is explicit", () => {
|
||||
const runnerConfig = getCodexAppServerRunnerConfig(process.env, {
|
||||
codexAppServerCommand: process.execPath,
|
||||
|
||||
@@ -105,6 +105,7 @@ process.exit(0);
|
||||
await chmod(fakeCodexPath, 0o755);
|
||||
|
||||
const completeBodies = [];
|
||||
const progressBodies = [];
|
||||
let claimCount = 0;
|
||||
const controlPlane = createServer(async (request, response) => {
|
||||
const url = request.url || "";
|
||||
@@ -134,6 +135,15 @@ process.exit(0);
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
request.method === "POST" &&
|
||||
url === "/api/v1/master-agent/tasks/conversation-app-server-task/progress"
|
||||
) {
|
||||
progressBodies.push(await readJsonBody(request));
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
request.method === "POST" &&
|
||||
url === "/api/v1/master-agent/tasks/conversation-app-server-task/complete"
|
||||
@@ -201,6 +211,7 @@ process.exit(0);
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
BOSS_CODEX_APP_SERVER_FIXTURE_EMIT_PROGRESS: "1",
|
||||
PATH: `${fakeBinDir}${path.delimiter}${process.env.PATH || ""}`,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
@@ -212,6 +223,16 @@ process.exit(0);
|
||||
assert.equal(body.status, "completed");
|
||||
assert.equal(body.replyBody, "APP_SERVER_REPLY:用 app server 回复");
|
||||
assert.notEqual(body.replyBody, "CLI_FALLBACK_USED");
|
||||
assert.ok(progressBodies.length >= 1, "expected local-agent to stream app-server progress before completion");
|
||||
assert.equal(progressBodies[0].status, "running");
|
||||
assert.equal(progressBodies[0].executionProgress.steps[0].text, "读取 Codex 官方 app-server 协议");
|
||||
assert.ok(
|
||||
progressBodies.some((progress) => progress.executionProgress?.branch?.additions === 181),
|
||||
"expected one streamed progress update to include diff stats",
|
||||
);
|
||||
assert.equal(body.executionProgress.steps[0].text, "读取 Codex 官方 app-server 协议");
|
||||
assert.equal(body.executionProgress.branch.additions, 181);
|
||||
assert.equal(body.executionProgress.artifacts[0].label, "codex-app-server-protocol-0.135.0.json");
|
||||
} finally {
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => {
|
||||
|
||||
86
tests/master-agent-task-progress-route.test.ts
Normal file
86
tests/master-agent-task-progress-route.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let data: typeof import("../src/lib/boss-data.ts");
|
||||
let postProgress: (typeof import("../src/app/api/v1/master-agent/tasks/[taskId]/progress/route.ts"))["POST"];
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-master-task-progress-route-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const [dataModule, routeModule] = await Promise.all([
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/app/api/v1/master-agent/tasks/[taskId]/progress/route.ts"),
|
||||
]);
|
||||
data = dataModule;
|
||||
postProgress = routeModule.POST;
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("POST task progress accepts device-token updates and keeps task running", async () => {
|
||||
const task = await data.queueMasterAgentTask({
|
||||
taskId: "route-progress-task",
|
||||
projectId: "group-progress-test",
|
||||
taskType: "dispatch_execution",
|
||||
requestMessageId: "msg-route-progress",
|
||||
requestText: "让目标线程继续开发",
|
||||
executionPrompt: "让目标线程继续开发",
|
||||
requestedBy: "krisolo",
|
||||
requestedByAccount: "krisolo",
|
||||
deviceId: "mac-studio",
|
||||
targetProjectId: "master-agent",
|
||||
targetThreadId: "master-agent-thread",
|
||||
});
|
||||
await data.claimNextMasterAgentTask("mac-studio");
|
||||
|
||||
const response = await postProgress(
|
||||
new NextRequest(`http://127.0.0.1:3000/api/v1/master-agent/tasks/${task.taskId}/progress`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-boss-device-token": "boss-mac-studio-token",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
deviceId: "mac-studio",
|
||||
status: "running",
|
||||
executionProgress: {
|
||||
steps: [
|
||||
{ text: "连接 Codex App Server", status: "done" },
|
||||
{ text: "启动目标线程 turn", status: "running" },
|
||||
],
|
||||
branch: { additions: 12, deletions: 1 },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{ params: Promise.resolve({ taskId: task.taskId }) },
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.task.status, "running");
|
||||
assert.equal(payload.task.completedAt, undefined);
|
||||
|
||||
const state = await data.readState();
|
||||
const progressMessage = state.projects
|
||||
.find((project) => project.id === "master-agent")
|
||||
?.messages.find((message) => message.executionProgress?.taskId === task.taskId);
|
||||
assert.equal(progressMessage?.executionProgress?.status, "running");
|
||||
assert.equal(progressMessage?.executionProgress?.steps[0]?.text, "连接 Codex App Server");
|
||||
assert.equal(progressMessage?.executionProgress?.branch?.additions, 12);
|
||||
});
|
||||
@@ -119,3 +119,64 @@ test("canceling a running task prevents late success completion from overwriting
|
||||
assert.equal(finalTask?.status, "canceled");
|
||||
assert.equal(finalTask?.replyBody, undefined);
|
||||
});
|
||||
|
||||
test("streaming task progress updates mutate the progress card without completing the task", async () => {
|
||||
await queueDesktopTask("live-progress-task");
|
||||
const claimed = await data.claimNextMasterAgentTask("mac-studio");
|
||||
assert.equal(claimed?.status, "running");
|
||||
|
||||
const updated = await data.updateMasterAgentTaskProgress({
|
||||
taskId: "live-progress-task",
|
||||
deviceId: "mac-studio",
|
||||
status: "running",
|
||||
executionProgress: {
|
||||
steps: [
|
||||
{ text: "读取 app-server 事件流", status: "done" },
|
||||
{ text: "等待目标线程回复", status: "running" },
|
||||
],
|
||||
artifacts: [{ label: "codex_app_server_protocol.schemas.json", kind: "file" }],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(updated.status, "running");
|
||||
assert.equal(updated.completedAt, undefined);
|
||||
|
||||
const state = await data.readState();
|
||||
const progressMessage = state.projects
|
||||
.find((project) => project.id === "master-agent")
|
||||
?.messages.find((message) => message.executionProgress?.taskId === "live-progress-task");
|
||||
assert.equal(progressMessage?.executionProgress?.status, "running");
|
||||
assert.equal(progressMessage?.executionProgress?.steps[0]?.text, "读取 app-server 事件流");
|
||||
assert.equal(progressMessage?.executionProgress?.steps[1]?.status, "running");
|
||||
assert.equal(progressMessage?.executionProgress?.artifacts?.[0]?.label, "codex_app_server_protocol.schemas.json");
|
||||
});
|
||||
|
||||
test("queued thread collaboration tasks retain source and target thread references", async () => {
|
||||
const task = await data.queueMasterAgentTask({
|
||||
taskId: "thread-collaboration-task",
|
||||
projectId: "master-agent",
|
||||
taskType: "conversation_reply",
|
||||
requestMessageId: "msg-thread-collaboration",
|
||||
requestText: "让源线程和目标线程对一下方案",
|
||||
executionPrompt: "让源线程和目标线程对一下方案",
|
||||
requestedBy: "krisolo",
|
||||
requestedByAccount: "krisolo",
|
||||
deviceId: "mac-studio",
|
||||
intentCategory: "thread_collaboration",
|
||||
sourceThreadId: "source-thread-id",
|
||||
sourceThreadDisplayName: "源线程",
|
||||
sourceCodexThreadRef: "019d-source-codex",
|
||||
targetThreadId: "target-thread-id",
|
||||
targetThreadDisplayName: "目标线程",
|
||||
targetCodexThreadRef: "019d-target-codex",
|
||||
});
|
||||
|
||||
assert.equal(task.intentCategory, "thread_collaboration");
|
||||
assert.equal(task.sourceThreadId, "source-thread-id");
|
||||
assert.equal(task.sourceThreadDisplayName, "源线程");
|
||||
assert.equal(task.sourceCodexThreadRef, "019d-source-codex");
|
||||
|
||||
const claimed = await data.claimNextMasterAgentTask("mac-studio");
|
||||
assert.equal(claimed?.sourceCodexThreadRef, "019d-source-codex");
|
||||
assert.equal(claimed?.targetCodexThreadRef, "019d-target-codex");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user