899 lines
35 KiB
TypeScript
899 lines
35 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { createServer as createHttpServer } from "node:http";
|
|
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { test } from "bun:test";
|
|
|
|
import { createCloudApiServer } from "./server.ts";
|
|
import { validateCodeAgentChatSchema } from "./code-agent-chat.ts";
|
|
import { createCodexStdioSessionManager } from "./codex-stdio-session.ts";
|
|
import { createCodeAgentTraceStore } from "./code-agent-trace-store.ts";
|
|
import {
|
|
codexStdioChatFixture,
|
|
codexStdioReadyFixture,
|
|
createFakeAppServerClient,
|
|
createFakeCodexCommand,
|
|
delay,
|
|
pollAgentResult,
|
|
postAgent,
|
|
postAgentRaw,
|
|
prepareFakeCodexHome
|
|
} from "./server-test-helpers.ts";
|
|
|
|
test("cloud api /v1/agent/chat parse and params errors use structured blocker envelope", async () => {
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
HWLAB_CODE_AGENT_MODEL: "gpt-test"
|
|
}
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const parse = await postAgentRaw(port, "{", { "x-trace-id": "trc_parse_error" });
|
|
assert.equal(parse.status, 400);
|
|
assert.equal(parse.body.status, "failed");
|
|
assert.equal(parse.body.error.code, "parse_error");
|
|
assert.equal(parse.body.error.layer, "api");
|
|
assert.equal(parse.body.error.retryable, true);
|
|
assert.equal(parse.body.error.traceId, "trc_parse_error");
|
|
assert.equal(parse.body.error.route, "/v1/agent/chat");
|
|
assert.match(parse.body.error.userMessage, /JSON/u);
|
|
assert.equal(Object.hasOwn(parse.body, "reply"), false);
|
|
assert.equal(JSON.stringify(parse.body).includes("sk-"), false);
|
|
|
|
const invalid = await postAgentRaw(port, JSON.stringify([]), { "x-trace-id": "trc_invalid_params" });
|
|
assert.equal(invalid.status, 400);
|
|
assert.equal(invalid.body.error.code, "invalid_params");
|
|
assert.equal(invalid.body.error.layer, "api");
|
|
assert.equal(invalid.body.error.retryable, true);
|
|
assert.equal(invalid.body.error.blocker.code, "invalid_params");
|
|
} finally {
|
|
await new Promise((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
});
|
|
|
|
test("cloud api /v1/agent/chat supports short submit and result polling", async () => {
|
|
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-short-"));
|
|
const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-short-codex-home-"));
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
CODEX_HOME: codexHome,
|
|
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace,
|
|
HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access",
|
|
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
|
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
|
OPENAI_API_KEY: "test-openai-key-material",
|
|
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses"
|
|
},
|
|
codexStdioManager: {
|
|
describe() {
|
|
return {
|
|
...codexStdioReadyFixture({ workspace, codexHome }),
|
|
sandbox: "danger-full-access"
|
|
};
|
|
},
|
|
async probe() {
|
|
return {
|
|
...codexStdioReadyFixture({ workspace, codexHome }),
|
|
sandbox: "danger-full-access"
|
|
};
|
|
},
|
|
async chat(params = {}) {
|
|
return {
|
|
...codexStdioChatFixture({ workspace, codexHome, params }),
|
|
sandbox: "danger-full-access",
|
|
session: {
|
|
...codexStdioChatFixture({ workspace, codexHome, params }).session,
|
|
sandbox: "danger-full-access"
|
|
}
|
|
};
|
|
},
|
|
cancel() {},
|
|
reapIdle() {}
|
|
}
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const traceId = "trc_server-test-short-submit";
|
|
const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-trace-id": traceId,
|
|
"prefer": "respond-async",
|
|
"x-hwlab-short-connection": "1"
|
|
},
|
|
body: JSON.stringify({
|
|
conversationId: "cnv_server-test-short-submit",
|
|
message: "用pwd列出你当前的工作目录"
|
|
})
|
|
});
|
|
assert.equal(submit.status, 202);
|
|
const accepted = await submit.json();
|
|
assert.equal(accepted.accepted, true);
|
|
assert.equal(accepted.shortConnection, true);
|
|
assert.equal(accepted.traceId, traceId);
|
|
assert.equal(accepted.resultUrl, `/v1/agent/chat/result/${traceId}`);
|
|
|
|
const payload = await pollAgentResult(port, traceId);
|
|
validateCodeAgentChatSchema(payload);
|
|
assert.equal(payload.status, "completed");
|
|
assert.equal(payload.traceId, traceId);
|
|
assert.equal(payload.sandbox, "danger-full-access");
|
|
assert.match(payload.reply.content, new RegExp(workspace.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")));
|
|
} finally {
|
|
await new Promise((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
await rm(workspace, { recursive: true, force: true });
|
|
await rm(codexHome, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("cloud api /v1/agent/chat records authenticated owner on agent session", async () => {
|
|
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-owner-"));
|
|
const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-owner-codex-home-"));
|
|
const ownerRecords = [];
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
CODEX_HOME: codexHome,
|
|
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace,
|
|
HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access",
|
|
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
|
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
|
OPENAI_API_KEY: "test-openai-key-material",
|
|
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses"
|
|
},
|
|
accessController: {
|
|
required: true,
|
|
async authenticate() {
|
|
return { ok: true, actor: { id: "usr_agent_owner", username: "agent-owner", displayName: "Agent Owner", role: "user", status: "active" } };
|
|
},
|
|
async recordAgentSessionOwner(input) {
|
|
ownerRecords.push(input);
|
|
return input;
|
|
}
|
|
},
|
|
codexStdioManager: {
|
|
describe() { return { ...codexStdioReadyFixture({ workspace, codexHome }), sandbox: "danger-full-access" }; },
|
|
async probe() { return { ...codexStdioReadyFixture({ workspace, codexHome }), sandbox: "danger-full-access" }; },
|
|
async chat(params = {}) {
|
|
return {
|
|
...codexStdioChatFixture({ workspace, codexHome, params }),
|
|
sandbox: "danger-full-access"
|
|
};
|
|
},
|
|
cancel() {},
|
|
reapIdle() {}
|
|
}
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const payload = await postAgent(port, {
|
|
conversationId: "cnv_server-test-owner",
|
|
traceId: "trc_server-test-owner",
|
|
message: "owner binding smoke"
|
|
});
|
|
validateCodeAgentChatSchema(payload);
|
|
assert.equal(payload.ownerUserId, "usr_agent_owner");
|
|
assert.equal(ownerRecords.length, 1);
|
|
assert.equal(ownerRecords[0].ownerUserId, "usr_agent_owner");
|
|
assert.equal(ownerRecords[0].sessionId, "ses_server_stdio_pwd");
|
|
assert.equal(ownerRecords[0].conversationId, "cnv_server-test-owner");
|
|
assert.equal(ownerRecords[0].traceId, "trc_server-test-owner");
|
|
assert.equal(ownerRecords[0].session.valuesRedacted, true);
|
|
assert.equal(JSON.stringify(ownerRecords).includes("test-openai-key-material"), false);
|
|
} finally {
|
|
await new Promise((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
await rm(workspace, { recursive: true, force: true });
|
|
await rm(codexHome, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("cloud api /v1/agent/chat/inspect maps conversation session and thread details to trace evidence", async () => {
|
|
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-inspect-"));
|
|
const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-inspect-codex-home-"));
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
CODEX_HOME: codexHome,
|
|
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace,
|
|
HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access",
|
|
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
|
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
|
OPENAI_API_KEY: "test-openai-key-material",
|
|
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses"
|
|
},
|
|
codexStdioManager: {
|
|
describe() {
|
|
return {
|
|
...codexStdioReadyFixture({ workspace, codexHome }),
|
|
sandbox: "danger-full-access",
|
|
recentSessions: [{
|
|
sessionId: "ses_server_stdio_pwd",
|
|
conversationId: "cnv_server-test-inspect",
|
|
threadId: "019e6c72-2373-75e3-9856-eff286db7163",
|
|
status: "idle",
|
|
currentTraceId: null,
|
|
lastTraceId: "trc_server-test-inspect",
|
|
workspace,
|
|
sandbox: "danger-full-access",
|
|
secretMaterialStored: false,
|
|
valuesRedacted: true
|
|
}]
|
|
};
|
|
},
|
|
async probe() {
|
|
return {
|
|
...codexStdioReadyFixture({ workspace, codexHome }),
|
|
sandbox: "danger-full-access"
|
|
};
|
|
},
|
|
async chat(params = {}) {
|
|
const base = codexStdioChatFixture({ workspace, codexHome, params });
|
|
return {
|
|
...base,
|
|
sandbox: "danger-full-access",
|
|
session: {
|
|
...base.session,
|
|
threadId: "019e6c72-2373-75e3-9856-eff286db7163",
|
|
sandbox: "danger-full-access"
|
|
},
|
|
sessionReuse: {
|
|
...base.sessionReuse,
|
|
threadId: "019e6c72-2373-75e3-9856-eff286db7163"
|
|
}
|
|
};
|
|
},
|
|
get(sessionId) {
|
|
if (sessionId !== "ses_server_stdio_pwd") return null;
|
|
return {
|
|
sessionId,
|
|
conversationId: "cnv_server-test-inspect",
|
|
threadId: "019e6c72-2373-75e3-9856-eff286db7163",
|
|
status: "idle",
|
|
lastTraceId: "trc_server-test-inspect",
|
|
workspace,
|
|
sandbox: "danger-full-access",
|
|
secretMaterialStored: false,
|
|
valuesRedacted: true
|
|
};
|
|
},
|
|
cancel() {},
|
|
reapIdle() {}
|
|
}
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const traceId = "trc_server-test-inspect";
|
|
const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-trace-id": traceId,
|
|
"prefer": "respond-async",
|
|
"x-hwlab-short-connection": "1"
|
|
},
|
|
body: JSON.stringify({
|
|
conversationId: "cnv_server-test-inspect",
|
|
message: "inspect mapping smoke"
|
|
})
|
|
});
|
|
assert.equal(submit.status, 202);
|
|
const payload = await pollAgentResult(port, traceId);
|
|
assert.equal(payload.status, "completed");
|
|
|
|
const inspect = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/inspect?conversationId=cnv_server-test-inspect&sessionId=ses_server_stdio_pwd&threadId=019e6c72-2373-75e3-9856-eff286db7163`);
|
|
assert.equal(inspect.status, 200);
|
|
const body = await inspect.json();
|
|
assert.equal(body.ok, true);
|
|
assert.equal(body.status, "found");
|
|
assert.equal(body.latestTraceId, traceId);
|
|
assert.equal(body.traceUrl, `/v1/agent/chat/trace/${traceId}`);
|
|
assert.equal(body.resultUrl, `/v1/agent/chat/result/${traceId}`);
|
|
assert.equal(body.query.threadId, "019e6c72-2373-75e3-9856-eff286db7163");
|
|
assert.equal(body.session.sessionId, "ses_server_stdio_pwd");
|
|
assert.equal(body.conversationFacts.conversationId, "cnv_server-test-inspect");
|
|
assert.equal(body.runnerTrace.traceId, traceId);
|
|
assert.equal(body.valuesRedacted, true);
|
|
assert.equal(body.secretMaterialStored, false);
|
|
assert.equal(JSON.stringify(body).includes("test-openai-key-material"), false);
|
|
} finally {
|
|
await new Promise((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
await rm(workspace, { recursive: true, force: true });
|
|
await rm(codexHome, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("cloud api /v1/agent/chat/inspect returns not_found for empty conversation facts", async () => {
|
|
const server = createCloudApiServer();
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/inspect?conversationId=cnv_server-test-inspect-missing&sessionId=ses_server_test_missing&threadId=019e6c72-2373-75e3-9856-eff286db7163&traceId=trc_server_test_missing`);
|
|
assert.equal(response.status, 404);
|
|
const body = await response.json();
|
|
assert.equal(body.ok, false);
|
|
assert.equal(body.status, "not_found");
|
|
assert.equal(body.latestTraceId, null);
|
|
assert.deepEqual(body.traceIds, []);
|
|
assert.equal(body.traceUrl, null);
|
|
assert.equal(body.resultUrl, null);
|
|
assert.equal(body.conversationFacts.turnCount, 0);
|
|
} finally {
|
|
await new Promise((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
});
|
|
|
|
test("cloud api result polling compacts large runnerTrace while preserving providerTrace", async () => {
|
|
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-result-compact-"));
|
|
const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-result-compact-codex-home-"));
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
CODEX_HOME: codexHome,
|
|
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace,
|
|
HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access",
|
|
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
|
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
|
HWLAB_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT: "32",
|
|
OPENAI_API_KEY: "test-openai-key-material",
|
|
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses"
|
|
},
|
|
codexStdioManager: {
|
|
describe() {
|
|
return {
|
|
...codexStdioReadyFixture({ workspace, codexHome }),
|
|
sandbox: "danger-full-access"
|
|
};
|
|
},
|
|
async probe() {
|
|
return {
|
|
...codexStdioReadyFixture({ workspace, codexHome }),
|
|
sandbox: "danger-full-access"
|
|
};
|
|
},
|
|
async chat(params = {}) {
|
|
const base = codexStdioChatFixture({ workspace, codexHome, params });
|
|
const events = Array.from({ length: 160 }, (_, index) => ({
|
|
seq: index + 1,
|
|
traceId: params.traceId,
|
|
type: "assistant_message",
|
|
status: "chunk",
|
|
label: index === 159 ? "assistant:completed" : "assistant:chunk",
|
|
createdAt: "2026-05-23T00:00:00.000Z",
|
|
waitingFor: index === 159 ? null : "turn/completed",
|
|
valuesPrinted: false
|
|
}));
|
|
return {
|
|
...base,
|
|
sandbox: "danger-full-access",
|
|
session: {
|
|
...base.session,
|
|
sandbox: "danger-full-access"
|
|
},
|
|
runnerTrace: {
|
|
...base.runnerTrace,
|
|
events,
|
|
eventLabels: events.map((event) => event.label),
|
|
eventCount: events.length,
|
|
lastEvent: events.at(-1)
|
|
}
|
|
};
|
|
},
|
|
cancel() {},
|
|
reapIdle() {}
|
|
}
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const traceId = "trc_server-test-result-compact";
|
|
const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-trace-id": traceId,
|
|
"prefer": "respond-async",
|
|
"x-hwlab-short-connection": "1"
|
|
},
|
|
body: JSON.stringify({
|
|
conversationId: "cnv_server-test-result-compact",
|
|
message: "生成大量 trace 后返回"
|
|
})
|
|
});
|
|
assert.equal(submit.status, 202);
|
|
|
|
const payload = await pollAgentResult(port, traceId);
|
|
validateCodeAgentChatSchema(payload);
|
|
assert.equal(payload.status, "completed");
|
|
assert.equal(payload.traceId, traceId);
|
|
assert.equal(payload.providerTrace.protocol, "codex-app-server-jsonrpc-stdio");
|
|
assert.equal(payload.providerTrace.command, "codex app-server --listen stdio://");
|
|
assert.equal(payload.runnerTrace.eventCount, 160);
|
|
assert.equal(payload.runnerTrace.eventsCompacted, true);
|
|
assert.equal(payload.runnerTrace.events.length, 32);
|
|
assert.equal(payload.runnerTrace.events.some((event) => event.label === "trace:compacted"), true);
|
|
assert.equal(payload.runnerTrace.lastEvent.label, "assistant:completed");
|
|
assert.equal(payload.runnerTrace.eventLabels.includes("trace:compacted"), true);
|
|
} finally {
|
|
await new Promise((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
await rm(workspace, { recursive: true, force: true });
|
|
await rm(codexHome, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("cloud api /v1/agent/chat answers skills prompt through real Codex stdio and retains trace", async () => {
|
|
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-skills-"));
|
|
const codexHome = path.join(workspace, "codex-home");
|
|
const skillsDir = path.join(workspace, "skills");
|
|
const fakeCodex = await createFakeCodexCommand();
|
|
await mkdir(path.join(skillsDir, "hwlab-test-skill"), { recursive: true });
|
|
await prepareFakeCodexHome(codexHome);
|
|
await writeFile(path.join(skillsDir, "hwlab-test-skill", "SKILL.md"), [
|
|
"---",
|
|
"name: hwlab-test-skill",
|
|
"description: Test skill mounted for Codex stdio discovery.",
|
|
"version: v-test",
|
|
"---",
|
|
"",
|
|
"# HWLAB Test Skill"
|
|
].join("\n"));
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
OPENAI_API_KEY: "test-openai-key-material",
|
|
CODEX_HOME: codexHome,
|
|
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
|
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
|
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
|
|
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
|
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
|
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace,
|
|
HWLAB_CODE_AGENT_SKILLS_DIRS: skillsDir,
|
|
HWLAB_CODE_AGENT_SKILLS_STRICT: "1",
|
|
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses"
|
|
},
|
|
codexStdioManager: createCodexStdioSessionManager({
|
|
idFactory: () => "ses_server_stdio_skills",
|
|
createRpcClient: async () => createFakeAppServerClient({
|
|
text: "模型泛化回答不应成为 skills 最终回复。"
|
|
})
|
|
})
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const payload = await postAgent(port, {
|
|
conversationId: "cnv_server_stdio_skills",
|
|
traceId: "trc_server_stdio_skills",
|
|
message: "列出你可用的skills"
|
|
});
|
|
|
|
validateCodeAgentChatSchema(payload);
|
|
assert.equal(payload.status, "completed");
|
|
assert.equal(payload.provider, "codex-stdio");
|
|
assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio");
|
|
assert.equal(payload.capabilityLevel, "long-lived-codex-stdio-session");
|
|
assert.equal(payload.providerTrace.sidecarOnly, undefined);
|
|
assert.equal(payload.providerTrace.toolName, "codex-app-server.thread/start+turn/start");
|
|
assert.equal(payload.skills.status, "ready");
|
|
const discovered = payload.skills.items.find((skill) => skill.name === "hwlab-test-skill");
|
|
assert.ok(discovered);
|
|
assert.equal(discovered.source, path.join(skillsDir, "hwlab-test-skill", "SKILL.md"));
|
|
assert.equal(discovered.manifest.path, path.join(skillsDir, "hwlab-test-skill", "SKILL.md"));
|
|
assert.equal(discovered.version, "v-test");
|
|
assert.ok(payload.toolCalls.some((toolCall) => toolCall.name === "skills.discover" && toolCall.status === "completed"));
|
|
assert.match(payload.reply.content, /hwlab-test-skill/u);
|
|
assert.match(payload.reply.content, /真实 skill manifest 清单/u);
|
|
assert.doesNotMatch(payload.reply.content, /模型泛化回答/u);
|
|
assert.ok(payload.runnerTrace.events.some((event) => event.label === "prompt:sent"));
|
|
assert.ok(payload.runnerTrace.events.some((event) => event.label === "tool:codex-app-server.thread/start+turn/start:started"));
|
|
assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false);
|
|
} finally {
|
|
await new Promise((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
await rm(workspace, { recursive: true, force: true });
|
|
await rm(fakeCodex.root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("cloud api /v1/agent/chat returns structured skills blocker without local skills manifest", async () => {
|
|
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-skills-missing-"));
|
|
const codexHome = path.join(workspace, "codex-home");
|
|
const fakeCodex = await createFakeCodexCommand();
|
|
await prepareFakeCodexHome(codexHome);
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
OPENAI_API_KEY: "test-openai-key-material",
|
|
CODEX_HOME: codexHome,
|
|
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
|
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
|
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
|
|
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
|
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
|
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace,
|
|
HWLAB_CODE_AGENT_SKILLS_DIRS: path.join(workspace, "missing-skills"),
|
|
HWLAB_CODE_AGENT_SKILLS_STRICT: "1",
|
|
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses"
|
|
},
|
|
codexStdioManager: createCodexStdioSessionManager({
|
|
idFactory: () => "ses_server_stdio_skills_missing",
|
|
createRpcClient: async () => createFakeAppServerClient({
|
|
text: "模型泛化回答:alpha-skill"
|
|
})
|
|
})
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const payload = await postAgent(port, {
|
|
conversationId: "cnv_server_stdio_skills_missing",
|
|
traceId: "trc_server_stdio_skills_missing",
|
|
message: "列出你可用的skills"
|
|
});
|
|
|
|
validateCodeAgentChatSchema(payload);
|
|
assert.equal(payload.status, "failed");
|
|
assert.equal(payload.provider, "codex-stdio");
|
|
assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio");
|
|
assert.equal(payload.capabilityLevel, "blocked");
|
|
assert.equal(payload.error.code, "skills_unavailable");
|
|
assert.equal(payload.skills.status, "blocked");
|
|
assert.equal(payload.skills.blockers[0].code, "skills_unavailable");
|
|
assert.deepEqual(payload.skills.sourceIssues, ["pikasTech/HWLAB#136", "pikasTech/HWLAB#237"]);
|
|
assert.ok(payload.toolCalls.some((toolCall) => toolCall.name === "skills.discover" && toolCall.status === "blocked"));
|
|
assert.equal(payload.toolCalls.some((toolCall) => toolCall.name === "codex-app-server.thread/start+turn/start"), false);
|
|
assert.equal(payload.session.status, "idle");
|
|
assert.equal(Object.hasOwn(payload, "reply"), false);
|
|
assert.equal(JSON.stringify(payload).includes("alpha-skill"), false);
|
|
assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false);
|
|
} finally {
|
|
await new Promise((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
await rm(workspace, { recursive: true, force: true });
|
|
await rm(fakeCodex.root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("cloud api /v1/agent/chat blocks forbidden file paths without leaking values", async () => {
|
|
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-file-block-"));
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWLAB_CODE_AGENT_WORKSPACE: workspace
|
|
}
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const payload = await postAgent(port, {
|
|
conversationId: "cnv_server-test-security-block",
|
|
traceId: "trc_server-test-security-block",
|
|
message: "cat ../outside.txt"
|
|
});
|
|
assert.equal(payload.status, "failed");
|
|
assert.match(payload.error.code, /^(codex_cli_binary_missing|stdio_protocol_not_wired)$/u);
|
|
assert.deepEqual(payload.toolCalls, []);
|
|
assert.equal(payload.capabilityLevel, "blocked");
|
|
assert.ok(payload.runnerTrace.events.some((event) => event.label === "codex-stdio:blocked"));
|
|
assert.equal(Object.hasOwn(payload, "reply"), false);
|
|
assert.equal(JSON.stringify(payload).includes("sk-"), false);
|
|
} finally {
|
|
await new Promise((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
});
|
|
|
|
test("cloud api /v1/agent/chat/cancel reports unsupported lifecycle degradation", async () => {
|
|
const traceStore = createCodeAgentTraceStore();
|
|
traceStore.append("trc_cancel_unsupported", {
|
|
type: "request",
|
|
status: "running",
|
|
label: "request:running",
|
|
sessionId: "ses_cancel_unsupported",
|
|
sessionStatus: "busy",
|
|
sessionLifecycleStatus: "busy"
|
|
});
|
|
const server = createCloudApiServer({
|
|
traceStore,
|
|
codexStdioManager: {
|
|
get() {
|
|
return null;
|
|
}
|
|
},
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio"
|
|
}
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/cancel`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-trace-id": "trc_cancel_unsupported"
|
|
},
|
|
body: JSON.stringify({
|
|
conversationId: "cnv_cancel_unsupported",
|
|
sessionId: "ses_cancel_unsupported",
|
|
traceId: "trc_cancel_unsupported"
|
|
})
|
|
});
|
|
assert.equal(response.status, 501);
|
|
const payload = await response.json();
|
|
assert.equal(payload.status, "degraded");
|
|
assert.equal(payload.unsupported, true);
|
|
assert.equal(payload.degraded, true);
|
|
assert.equal(payload.error.code, "cancel_unsupported");
|
|
assert.equal(payload.sessionLifecycleStatus, "failed");
|
|
assert.equal(payload.sessionSummary.unsupported, true);
|
|
assert.match(payload.sessionSummary.userMessage, /unsupported\/degraded/u);
|
|
} finally {
|
|
await new Promise((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
});
|
|
|
|
test("cloud api /v1/agent/chat reports Codex stdio blocker instead of structured skills fallback", async () => {
|
|
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-no-skills-"));
|
|
const skillsDir = path.join(root, "missing-skills");
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWLAB_CODE_AGENT_WORKSPACE: root
|
|
},
|
|
skillsDirs: [skillsDir],
|
|
skillsDirsExact: true
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
conversationId: "cnv_server-test-skills-missing",
|
|
message: "列出你可用的skills"
|
|
})
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const payload = await response.json();
|
|
assert.equal(payload.status, "failed");
|
|
assert.equal(payload.provider, "codex-stdio");
|
|
assert.notEqual(payload.error.code, "skills_unavailable");
|
|
assert.equal(payload.capabilityLevel, "blocked");
|
|
assert.equal(payload.skills.status, "not_requested");
|
|
assert.deepEqual(payload.toolCalls, []);
|
|
assert.ok(payload.runnerLimitations.includes("no-controlled-readonly-fallback"));
|
|
assert.equal(Object.hasOwn(payload, "reply"), false);
|
|
} finally {
|
|
await new Promise((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
});
|
|
|
|
test("cloud api /v1/agent/chat does not run unsupported local grep fallback", async () => {
|
|
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-tool-blocked-"));
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWLAB_CODE_AGENT_WORKSPACE: workspace
|
|
}
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
conversationId: "cnv_server-test-tool-unavailable",
|
|
message: "请用grep搜索 package.json"
|
|
})
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const payload = await response.json();
|
|
assert.equal(payload.status, "failed");
|
|
assert.notEqual(payload.error.code, "tool_unavailable");
|
|
assert.equal(payload.provider, "codex-stdio");
|
|
assert.equal(payload.capabilityLevel, "blocked");
|
|
assert.deepEqual(payload.toolCalls, []);
|
|
assert.ok(payload.runnerLimitations.includes("no-controlled-readonly-fallback"));
|
|
assert.equal(Object.hasOwn(payload, "reply"), false);
|
|
} finally {
|
|
await new Promise((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
});
|
|
|
|
test("cloud api /v1/agent/chat does not call delayed provider fallback beyond legacy 4500ms UI timeout", async () => {
|
|
let providerCalled = false;
|
|
const server = createCloudApiServer({
|
|
callCodeAgentProvider: async ({ providerPlan, message, traceId }) => {
|
|
providerCalled = true;
|
|
await delay(4700);
|
|
return {
|
|
provider: providerPlan.provider,
|
|
model: providerPlan.model,
|
|
backend: providerPlan.backend,
|
|
content: `延迟真实 provider stub: ${message} / ${traceId}`,
|
|
usage: null,
|
|
providerTrace: {
|
|
source: "delayed-test-provider"
|
|
}
|
|
};
|
|
}
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const startedAt = Date.now();
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-trace-id": "trc_server-test-agent-chat-delayed"
|
|
},
|
|
body: JSON.stringify({
|
|
conversationId: "cnv_server-test-agent-chat-delayed",
|
|
message: "请用一句话说明当前 HWLAB 工作台可以做什么,稍后回答"
|
|
})
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const payload = await response.json();
|
|
assert.equal(Date.now() - startedAt < 4500, true);
|
|
assert.equal(payload.status, "failed");
|
|
assert.equal(payload.conversationId, "cnv_server-test-agent-chat-delayed");
|
|
assert.equal(payload.traceId, "trc_server-test-agent-chat-delayed");
|
|
assert.equal(payload.provider, "codex-stdio");
|
|
assert.equal(payload.capabilityLevel, "blocked");
|
|
assert.equal(providerCalled, false);
|
|
assert.equal(Object.hasOwn(payload, "reply"), false);
|
|
} finally {
|
|
await new Promise((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
});
|
|
|
|
test("cloud api /v1/agent/chat does not call OpenAI provider 502/503 fallback", async () => {
|
|
for (const status of [502, 503]) {
|
|
const providerServer = createHttpServer((request, response) => {
|
|
request.resume();
|
|
response.writeHead(status, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({
|
|
error: {
|
|
message: `upstream ${status}`
|
|
}
|
|
}));
|
|
});
|
|
await new Promise((resolve) => providerServer.listen(0, "127.0.0.1", resolve));
|
|
const providerPort = providerServer.address().port;
|
|
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
OPENAI_API_KEY: "test-openai-key-material",
|
|
HWLAB_CODE_AGENT_PROVIDER: "openai",
|
|
HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK: "true",
|
|
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
|
HWLAB_CODE_AGENT_OPENAI_BASE_URL: `http://127.0.0.1:${providerPort}/v1/responses`
|
|
}
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-trace-id": `trc_server-test-agent-chat-provider-${status}`
|
|
},
|
|
body: JSON.stringify({
|
|
conversationId: `cnv_server-test-agent-chat-provider-${status}`,
|
|
message: `provider ${status}`
|
|
})
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const payload = await response.json();
|
|
assert.equal(payload.status, "failed");
|
|
assert.equal(payload.traceId, `trc_server-test-agent-chat-provider-${status}`);
|
|
assert.match(payload.error.code, /^(codex_cli_binary_missing|stdio_protocol_not_wired)$/u);
|
|
assert.match(payload.error.layer, /^(runner|api)$/u);
|
|
assert.equal(typeof payload.error.retryable, "boolean");
|
|
assert.equal(payload.error.blocker.traceId, `trc_server-test-agent-chat-provider-${status}`);
|
|
assert.equal(typeof payload.error.blocker.retryable, "boolean");
|
|
assert.equal(payload.error.blocker.capabilityLevel, "blocked");
|
|
assert.equal(payload.provider, "codex-stdio");
|
|
assert.equal(Object.hasOwn(payload, "reply"), false);
|
|
} finally {
|
|
await new Promise((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
await new Promise((resolve, reject) => {
|
|
providerServer.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
test("cloud api /v1/agent/chat does not call empty provider text fallback", async () => {
|
|
let providerCalled = false;
|
|
const server = createCloudApiServer({
|
|
callCodeAgentProvider: async () => {
|
|
providerCalled = true;
|
|
throw new Error("empty provider fallback must not be used");
|
|
}
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
conversationId: "cnv_empty-provider-text",
|
|
message: "你好"
|
|
})
|
|
});
|
|
assert.equal(response.status, 200);
|
|
const payload = await response.json();
|
|
assert.equal(payload.conversationId, "cnv_empty-provider-text");
|
|
assert.equal(payload.status, "failed");
|
|
assert.match(payload.error.code, /^(codex_cli_binary_missing|stdio_protocol_not_wired)$/u);
|
|
assert.equal(payload.provider, "codex-stdio");
|
|
assert.equal(Object.hasOwn(payload, "reply"), false);
|
|
assert.equal(providerCalled, false);
|
|
} finally {
|
|
await new Promise((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
});
|