1101 lines
42 KiB
JavaScript
1101 lines
42 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import { createCodeAgentSessionRegistry } from "./code-agent-session-registry.mjs";
|
|
import { handleCodeAgentChat, validateCodeAgentChatSchema } from "./code-agent-chat.mjs";
|
|
import { createCodexStdioSessionManager } from "./codex-stdio-session.mjs";
|
|
import {
|
|
classifyCodexRunnerCapability,
|
|
classifyCodeAgentChatReadiness
|
|
} from "../../scripts/src/code-agent-response-contract.mjs";
|
|
import {
|
|
HWLAB_M3_IO_API_BASE_URL_ENV,
|
|
HWLAB_M3_IO_CAPABILITY_LEVELS,
|
|
HWLAB_M3_IO_API_ROUTE
|
|
} from "../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs";
|
|
|
|
test("code agent session registry creates, reuses, and expires sessions without storing secrets", () => {
|
|
const registry = createCodeAgentSessionRegistry({
|
|
idleTimeoutMs: 1000,
|
|
idFactory: () => "ses_test-registry"
|
|
});
|
|
const first = registry.acquire({
|
|
conversationId: "cnv_registry",
|
|
workspace: "/workspace/hwlab",
|
|
sandbox: "read-only",
|
|
runnerKind: "hwlab-readonly-runner",
|
|
capabilityLevel: "read-only-session-tools",
|
|
traceId: "trc_registry_1",
|
|
now: "2026-05-23T00:00:00.000Z"
|
|
});
|
|
assert.equal(first.ok, true);
|
|
assert.equal(first.reused, false);
|
|
assert.equal(first.session.sessionId, "ses_test-registry");
|
|
assert.equal(first.session.status, "busy");
|
|
assert.equal(first.session.idleTimeoutMs, 1000);
|
|
assert.equal(first.session.lastTraceId, "trc_registry_1");
|
|
assert.equal(first.session.secretMaterialStored, false);
|
|
assert.equal(first.session.valuesRedacted, true);
|
|
registry.release(first.session.sessionId, {
|
|
conversationId: "cnv_registry",
|
|
traceId: "trc_registry_1",
|
|
reused: first.session.reused,
|
|
now: "2026-05-23T00:00:00.010Z"
|
|
});
|
|
|
|
const second = registry.acquire({
|
|
conversationId: "cnv_registry",
|
|
workspace: "/workspace/hwlab",
|
|
sandbox: "read-only",
|
|
runnerKind: "hwlab-readonly-runner",
|
|
capabilityLevel: "read-only-session-tools",
|
|
traceId: "trc_registry_2",
|
|
now: "2026-05-23T00:00:00.500Z"
|
|
});
|
|
assert.equal(second.ok, true);
|
|
assert.equal(second.reused, true);
|
|
assert.equal(second.session.sessionId, "ses_test-registry");
|
|
assert.equal(second.session.turn, 2);
|
|
assert.equal(second.session.lastTraceId, "trc_registry_2");
|
|
registry.release(second.session.sessionId, {
|
|
conversationId: "cnv_registry",
|
|
traceId: "trc_registry_2",
|
|
reused: second.session.reused,
|
|
now: "2026-05-23T00:00:00.510Z"
|
|
});
|
|
|
|
const expired = registry.acquire({
|
|
conversationId: "cnv_registry",
|
|
workspace: "/workspace/hwlab",
|
|
sandbox: "read-only",
|
|
runnerKind: "hwlab-readonly-runner",
|
|
capabilityLevel: "read-only-session-tools",
|
|
traceId: "trc_registry_expired",
|
|
now: "2026-05-23T00:00:02.000Z"
|
|
});
|
|
assert.equal(expired.ok, false);
|
|
assert.equal(expired.code, "session_expired");
|
|
assert.equal(expired.session.status, "expired");
|
|
assert.equal(expired.blocker.sourceIssue, "pikasTech/HWLAB#317");
|
|
});
|
|
|
|
test("code agent session registry reports busy sessions as structured blocker", () => {
|
|
const registry = createCodeAgentSessionRegistry({
|
|
idFactory: () => "ses_busy"
|
|
});
|
|
const first = registry.acquire({
|
|
conversationId: "cnv_busy",
|
|
workspace: "/workspace/hwlab",
|
|
sandbox: "read-only",
|
|
runnerKind: "hwlab-readonly-runner",
|
|
capabilityLevel: "read-only-session-tools",
|
|
traceId: "trc_busy_1",
|
|
now: "2026-05-23T00:00:00.000Z"
|
|
});
|
|
assert.equal(first.ok, true);
|
|
const busy = registry.acquire({
|
|
conversationId: "cnv_busy",
|
|
workspace: "/workspace/hwlab",
|
|
sandbox: "read-only",
|
|
runnerKind: "hwlab-readonly-runner",
|
|
capabilityLevel: "read-only-session-tools",
|
|
traceId: "trc_busy_2",
|
|
now: "2026-05-23T00:00:01.000Z"
|
|
});
|
|
assert.equal(busy.ok, false);
|
|
assert.equal(busy.code, "session_busy");
|
|
assert.equal(busy.session.status, "busy");
|
|
assert.equal(busy.session.currentTraceId, "trc_busy_1");
|
|
});
|
|
|
|
test("read-only runner returns session registry evidence and blocked long-lived gate", async () => {
|
|
const registry = createCodeAgentSessionRegistry({
|
|
idFactory: () => "ses_chat_registry"
|
|
});
|
|
const first = await handleCodeAgentChat(
|
|
{
|
|
conversationId: "cnv_chat_registry",
|
|
traceId: "trc_chat_registry_pwd",
|
|
message: "pwd"
|
|
},
|
|
{
|
|
now: () => "2026-05-23T00:01:00.000Z",
|
|
sessionRegistry: registry,
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
|
|
}
|
|
}
|
|
);
|
|
validateCodeAgentChatSchema(first);
|
|
assert.equal(first.status, "completed");
|
|
assert.equal(first.sessionId, "ses_chat_registry");
|
|
assert.equal(first.session.sessionId, "ses_chat_registry");
|
|
assert.equal(first.session.status, "idle");
|
|
assert.equal(first.session.workspace, process.cwd());
|
|
assert.equal(first.session.lastTraceId, "trc_chat_registry_pwd");
|
|
assert.equal(first.capabilityLevel, "read-only-session-tools");
|
|
assert.equal(first.longLivedSessionGate.status, "blocked");
|
|
assert.equal(first.longLivedSessionGate.pass, false);
|
|
assert.ok(first.longLivedSessionGate.blockers.some((blocker) => blocker.code === "controlled_readonly_not_long_lived_stdio"));
|
|
|
|
const second = await handleCodeAgentChat(
|
|
{
|
|
conversationId: "cnv_chat_registry",
|
|
traceId: "trc_chat_registry_ls",
|
|
message: "ls ."
|
|
},
|
|
{
|
|
now: () => "2026-05-23T00:01:01.000Z",
|
|
sessionRegistry: registry,
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
|
|
}
|
|
}
|
|
);
|
|
assert.equal(second.status, "completed");
|
|
assert.equal(second.sessionId, first.sessionId);
|
|
assert.equal(second.sessionReuse.reused, true);
|
|
assert.equal(second.sessionReuse.turn, 2);
|
|
assert.equal(second.session.lastTraceId, "trc_chat_registry_ls");
|
|
assert.equal(classifyCodexRunnerCapability(second, { httpStatus: 200 }).capabilityPass, false);
|
|
});
|
|
|
|
test("expired and busy session states are surfaced as structured blockers", async () => {
|
|
const expiredRegistry = createCodeAgentSessionRegistry({
|
|
idleTimeoutMs: 10,
|
|
idFactory: () => "ses_expired_chat"
|
|
});
|
|
const first = expiredRegistry.acquire({
|
|
conversationId: "cnv_expired_chat",
|
|
workspace: process.cwd(),
|
|
sandbox: "read-only",
|
|
runnerKind: "hwlab-readonly-runner",
|
|
capabilityLevel: "read-only-session-tools",
|
|
traceId: "trc_expired_seed",
|
|
now: "2026-05-23T00:02:00.000Z"
|
|
});
|
|
expiredRegistry.release(first.session.sessionId, {
|
|
conversationId: "cnv_expired_chat",
|
|
traceId: "trc_expired_seed",
|
|
now: "2026-05-23T00:02:00.001Z"
|
|
});
|
|
const expired = await handleCodeAgentChat(
|
|
{
|
|
conversationId: "cnv_expired_chat",
|
|
traceId: "trc_expired_request",
|
|
message: "pwd"
|
|
},
|
|
{
|
|
now: () => "2026-05-23T00:02:01.000Z",
|
|
sessionRegistry: expiredRegistry,
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
|
|
}
|
|
}
|
|
);
|
|
assert.equal(expired.status, "failed");
|
|
assert.equal(expired.error.code, "session_expired");
|
|
assert.equal(expired.error.layer, "session");
|
|
assert.equal(expired.error.retryable, true);
|
|
assert.match(expired.error.userMessage, /session 已过期/u);
|
|
assert.equal(expired.error.blocker.code, "session_expired");
|
|
assert.equal(expired.error.blocker.traceId, "trc_expired_request");
|
|
assert.equal(expired.session.status, "expired");
|
|
assert.equal(expired.capabilityLevel, "blocked");
|
|
assert.equal(expired.longLivedSessionGate.status, "blocked");
|
|
|
|
const busyRegistry = createCodeAgentSessionRegistry({
|
|
idFactory: () => "ses_busy_chat"
|
|
});
|
|
busyRegistry.acquire({
|
|
conversationId: "cnv_busy_chat",
|
|
workspace: process.cwd(),
|
|
sandbox: "read-only",
|
|
runnerKind: "hwlab-readonly-runner",
|
|
capabilityLevel: "read-only-session-tools",
|
|
traceId: "trc_busy_seed",
|
|
now: "2026-05-23T00:03:00.000Z"
|
|
});
|
|
const busy = await handleCodeAgentChat(
|
|
{
|
|
conversationId: "cnv_busy_chat",
|
|
traceId: "trc_busy_request",
|
|
message: "pwd"
|
|
},
|
|
{
|
|
now: () => "2026-05-23T00:03:01.000Z",
|
|
sessionRegistry: busyRegistry,
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
|
|
}
|
|
}
|
|
);
|
|
assert.equal(busy.status, "failed");
|
|
assert.equal(busy.error.code, "session_busy");
|
|
assert.equal(busy.error.layer, "session");
|
|
assert.equal(busy.error.retryable, true);
|
|
assert.match(busy.error.userMessage, /正在处理上一轮/u);
|
|
assert.equal(busy.error.blocker.code, "session_busy");
|
|
assert.equal(busy.session.status, "busy");
|
|
assert.equal(busy.capabilityLevel, "blocked");
|
|
assert.equal(busy.error.blockers[0].sourceIssue, "pikasTech/HWLAB#317");
|
|
});
|
|
|
|
test("OpenAI fallback and codex one-shot do not pass the long-lived session gate", async () => {
|
|
const fallback = await handleCodeAgentChat(
|
|
{
|
|
conversationId: "cnv_fallback_gate",
|
|
traceId: "trc_fallback_gate",
|
|
message: "你好"
|
|
},
|
|
{
|
|
now: () => "2026-05-23T00:04:00.000Z",
|
|
callProvider: async ({ providerPlan }) => ({
|
|
provider: providerPlan.provider,
|
|
model: providerPlan.model,
|
|
backend: providerPlan.backend,
|
|
content: "普通文本回复。",
|
|
usage: null,
|
|
providerTrace: {
|
|
source: "test-provider"
|
|
}
|
|
})
|
|
}
|
|
);
|
|
assert.equal(fallback.status, "completed");
|
|
assert.equal(fallback.runner.kind, "openai-responses-fallback");
|
|
assert.equal(fallback.capabilityLevel, "text-chat-only");
|
|
assert.equal(fallback.blocker.code, "text_chat_only_fallback");
|
|
assert.equal(fallback.blocker.layer, "provider");
|
|
assert.equal(fallback.blocker.retryable, false);
|
|
assert.match(fallback.blocker.userMessage, /文本 fallback/u);
|
|
assert.equal(fallback.longLivedSessionGate.status, "blocked");
|
|
assert.ok(fallback.longLivedSessionGate.blockers.some((blocker) => blocker.code === "openai_responses_fallback_not_session"));
|
|
assert.equal(classifyCodexRunnerCapability(fallback, { httpStatus: 200 }).capabilityPass, false);
|
|
|
|
const oneShot = {
|
|
...fallback,
|
|
provider: "codex-cli",
|
|
backend: "hwlab-cloud-api/codex-cli",
|
|
runner: {
|
|
kind: "codex-cli-one-shot-ephemeral",
|
|
codexStdio: false,
|
|
writeCapable: false,
|
|
durableSession: false
|
|
},
|
|
sessionMode: "ephemeral-one-shot",
|
|
implementationType: "codex-cli-one-shot-ephemeral",
|
|
longLivedSessionGate: {
|
|
status: "blocked",
|
|
pass: false,
|
|
blockers: [{ code: "one_shot_runner_not_long_lived", sourceIssue: "pikasTech/HWLAB#317" }]
|
|
}
|
|
};
|
|
assert.equal(classifyCodexRunnerCapability(oneShot, { httpStatus: 200 }).capabilityPass, false);
|
|
assert.equal(classifyCodeAgentChatReadiness(oneShot, { realDevLive: true, httpStatus: 200 }).devLiveReplyPass, true);
|
|
});
|
|
|
|
test("Code Agent M3 DO write uses Skill CLI to call only HWLAB API /v1/m3/io", async () => {
|
|
const calls = [];
|
|
const payload = await handleCodeAgentChat(
|
|
{
|
|
conversationId: "cnv_m3_skill_write",
|
|
traceId: "trc_m3_skill_write",
|
|
message: "请通过 M3 DO1 写入 true,并回读 DI1"
|
|
},
|
|
{
|
|
now: () => "2026-05-23T00:05:00.000Z",
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWLAB_CODE_AGENT_WORKSPACE: process.cwd(),
|
|
HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667",
|
|
OPENAI_API_KEY: "must-not-be-used"
|
|
},
|
|
callProvider: async () => {
|
|
throw new Error("OpenAI fallback must not be used for M3 IO control");
|
|
},
|
|
m3IoSkillRequestJson: async (url, request) => {
|
|
calls.push({ url, request });
|
|
const parsed = new URL(url);
|
|
assert.equal(parsed.pathname, HWLAB_M3_IO_API_ROUTE);
|
|
assert.equal(parsed.hostname, "hwlab-cloud-api.hwlab-dev.svc.cluster.local");
|
|
assert.equal(request.method, "POST");
|
|
assert.equal(request.body.action, "do.write");
|
|
assert.equal(request.body.resourceId, "res_boxsimu_1");
|
|
assert.equal(request.body.port, "DO1");
|
|
assert.equal(request.body.value, true);
|
|
assert.equal(request.body.source, "hwlab-agent-runtime.m3-io");
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
body: {
|
|
serviceId: "hwlab-cloud-api",
|
|
contractVersion: "m3-io-control-v1",
|
|
status: "succeeded",
|
|
accepted: true,
|
|
action: "do.write",
|
|
traceId: "trc_m3_skill_write",
|
|
operationId: "op_m3_do_write_skill",
|
|
auditId: "aud_m3_do_write_skill_succeeded",
|
|
evidenceId: "evd_m3_do_write_skill_succeeded",
|
|
auditState: {
|
|
status: "written_non_durable",
|
|
durableStatus: {
|
|
status: "degraded"
|
|
}
|
|
},
|
|
evidenceState: {
|
|
status: "blocked",
|
|
sourceKind: "BLOCKED",
|
|
blocker: "runtime_durable_not_green",
|
|
writeStatus: "written_non_durable"
|
|
},
|
|
durableStatus: {
|
|
status: "degraded",
|
|
durable: false,
|
|
blocker: "runtime_durable_not_green"
|
|
},
|
|
result: {
|
|
value: true,
|
|
targetReadback: {
|
|
status: "succeeded",
|
|
value: true,
|
|
resourceId: "res_boxsimu_2",
|
|
port: "DI1"
|
|
}
|
|
},
|
|
controlPath: {
|
|
status: "succeeded",
|
|
cloudApi: true,
|
|
gatewaySimu: true,
|
|
boxSimu: true,
|
|
patchPanel: true,
|
|
frontendBypass: false
|
|
}
|
|
}
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
validateCodeAgentChatSchema(payload);
|
|
assert.equal(payload.status, "completed");
|
|
assert.equal(payload.provider, "hwlab-skill-cli");
|
|
assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-skill-cli");
|
|
assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready);
|
|
assert.equal(payload.toolCalls.length, 1);
|
|
assert.equal(payload.toolCalls[0].name, "hwlab-agent-runtime.m3-io");
|
|
assert.equal(payload.toolCalls[0].status, "completed");
|
|
assert.equal(payload.toolCalls[0].route, HWLAB_M3_IO_API_ROUTE);
|
|
assert.equal(payload.toolCalls[0].method, "POST");
|
|
assert.equal(payload.toolCalls[0].capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready);
|
|
assert.equal(payload.toolCalls[0].controlReady, true);
|
|
assert.equal(payload.toolCalls[0].accepted, true);
|
|
assert.equal(payload.toolCalls[0].operationId, "op_m3_do_write_skill");
|
|
assert.equal(payload.toolCalls[0].traceId, "trc_m3_skill_write");
|
|
assert.equal(payload.toolCalls[0].auditId, "aud_m3_do_write_skill_succeeded");
|
|
assert.equal(payload.toolCalls[0].evidenceId, "evd_m3_do_write_skill_succeeded");
|
|
assert.equal(payload.toolCalls[0].readback.value, true);
|
|
assert.equal(payload.runnerTrace.route, HWLAB_M3_IO_API_ROUTE);
|
|
assert.equal(payload.runnerTrace.method, "POST");
|
|
assert.equal(payload.runnerTrace.accepted, true);
|
|
assert.equal(payload.runnerTrace.auditId, "aud_m3_do_write_skill_succeeded");
|
|
assert.equal(payload.runnerTrace.evidenceId, "evd_m3_do_write_skill_succeeded");
|
|
assert.equal(payload.runnerTrace.readback.value, true);
|
|
assert.equal(payload.runnerTrace.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready);
|
|
assert.equal(payload.runnerTrace.controlReady, true);
|
|
assert.equal(payload.runner.writeCapable, true);
|
|
assert.deepEqual(payload.runner.toolPolicy.allowed, [`POST ${HWLAB_M3_IO_API_ROUTE}`]);
|
|
assert.equal(payload.runner.safety.directGatewayCallsAllowed, false);
|
|
assert.equal(payload.runner.safety.directBoxSimuCallsAllowed, false);
|
|
assert.equal(payload.runner.safety.directPatchPanelCallsAllowed, false);
|
|
assert.equal(payload.providerTrace.fallbackUsed, false);
|
|
assert.equal(payload.providerTrace.method, "POST");
|
|
assert.equal(payload.providerTrace.auditId, "aud_m3_do_write_skill_succeeded");
|
|
assert.equal(payload.providerTrace.evidenceId, "evd_m3_do_write_skill_succeeded");
|
|
assert.equal(payload.providerTrace.readback.value, true);
|
|
assert.equal(payload.providerTrace.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready);
|
|
assert.equal(payload.providerTrace.controlReady, true);
|
|
assert.equal(payload.skills.blockers.some((blocker) => blocker.code === "runtime_durable_not_green"), true);
|
|
assert.match(payload.reply.content, /Skill CLI -> HWLAB API \/v1\/m3\/io/u);
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(calls[0].url, `http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667${HWLAB_M3_IO_API_ROUTE}`);
|
|
assert.equal(calls[0].url.includes("gateway"), false);
|
|
assert.equal(calls[0].url.includes("box-simu"), false);
|
|
assert.equal(calls[0].url.includes("patch-panel"), false);
|
|
});
|
|
|
|
test("Code Agent M3 DI read returns structured blocker from HWLAB API without fallback", async () => {
|
|
const calls = [];
|
|
const payload = await handleCodeAgentChat(
|
|
{
|
|
conversationId: "cnv_m3_skill_read_blocked",
|
|
traceId: "trc_m3_skill_read_blocked",
|
|
message: "读取 M3 DI1 readback"
|
|
},
|
|
{
|
|
now: () => "2026-05-23T00:06:00.000Z",
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWLAB_CODE_AGENT_WORKSPACE: process.cwd(),
|
|
HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667",
|
|
OPENAI_API_KEY: "must-not-be-used"
|
|
},
|
|
callProvider: async () => {
|
|
throw new Error("OpenAI fallback must not be used for M3 IO read");
|
|
},
|
|
m3IoSkillRequestJson: async (url, request) => {
|
|
calls.push({ url, request });
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
body: {
|
|
serviceId: "hwlab-cloud-api",
|
|
contractVersion: "m3-io-control-v1",
|
|
status: "blocked",
|
|
accepted: false,
|
|
action: "di.read",
|
|
traceId: "trc_m3_skill_read_blocked",
|
|
operationId: "op_m3_di_read_blocked",
|
|
auditId: "aud_m3_di_read_blocked_failed",
|
|
evidenceId: "evd_m3_di_read_blocked_failed",
|
|
blocker: {
|
|
code: "m3_gateway_session_unavailable",
|
|
message: "gateway unavailable",
|
|
zh: "gateway 未注册/不可用"
|
|
},
|
|
blockerClassification: {
|
|
category: "gateway_session"
|
|
},
|
|
auditState: {
|
|
status: "written_non_durable"
|
|
},
|
|
evidenceState: {
|
|
status: "blocked",
|
|
sourceKind: "BLOCKED",
|
|
blocker: "runtime_durable_not_green",
|
|
writeStatus: "written_non_durable"
|
|
},
|
|
durableStatus: {
|
|
status: "degraded",
|
|
durable: false,
|
|
blocker: "runtime_durable_not_green"
|
|
},
|
|
controlPath: {
|
|
cloudApi: true,
|
|
gatewaySimu: false,
|
|
boxSimu: false,
|
|
patchPanel: false,
|
|
frontendBypass: false
|
|
}
|
|
}
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
validateCodeAgentChatSchema(payload);
|
|
assert.equal(payload.status, "completed");
|
|
assert.equal(payload.provider, "hwlab-skill-cli");
|
|
assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked);
|
|
assert.equal(payload.blocker.code, "m3_readiness_blocked");
|
|
assert.equal(payload.blocker.layer, "m3-readiness");
|
|
assert.equal(payload.blocker.retryable, false);
|
|
assert.equal(payload.blocker.route, HWLAB_M3_IO_API_ROUTE);
|
|
assert.equal(payload.blocker.toolName, "hwlab-agent-runtime.m3-io");
|
|
assert.equal(payload.toolCalls[0].status, "blocked");
|
|
assert.equal(payload.toolCalls[0].route, HWLAB_M3_IO_API_ROUTE);
|
|
assert.equal(payload.toolCalls[0].capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked);
|
|
assert.equal(payload.toolCalls[0].controlReady, false);
|
|
assert.equal(payload.toolCalls[0].blocker.code, "m3_gateway_session_unavailable");
|
|
assert.equal(payload.skills.blockers[0].code, "m3_gateway_session_unavailable");
|
|
assert.equal(payload.runnerTrace.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked);
|
|
assert.equal(payload.runnerTrace.blocker.code, "m3_gateway_session_unavailable");
|
|
assert.equal(payload.providerTrace.fallbackUsed, false);
|
|
assert.equal(payload.providerTrace.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked);
|
|
assert.equal(payload.providerTrace.controlReady, false);
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(new URL(calls[0].url).pathname, HWLAB_M3_IO_API_ROUTE);
|
|
assert.equal(calls[0].request.body.action, "di.read");
|
|
assert.equal(calls[0].request.body.resourceId, "res_boxsimu_2");
|
|
assert.equal(calls[0].request.body.port, "DI1");
|
|
});
|
|
|
|
test("Code Agent M3 Skill CLI missing API base returns structured config blocker", async () => {
|
|
const payload = await handleCodeAgentChat(
|
|
{
|
|
conversationId: "cnv_m3_skill_api_base_missing",
|
|
traceId: "trc_m3_skill_api_base_missing",
|
|
message: "读取 M3 DI1 readback"
|
|
},
|
|
{
|
|
now: () => "2026-05-23T00:06:30.000Z",
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWLAB_CODE_AGENT_WORKSPACE: process.cwd(),
|
|
HWLAB_CODE_AGENT_REQUIRE_HWLAB_API_BASE_URL: "1"
|
|
},
|
|
m3IoSkillRequestJson: async () => {
|
|
throw new Error("missing API base must not issue a network request");
|
|
}
|
|
}
|
|
);
|
|
|
|
validateCodeAgentChatSchema(payload);
|
|
assert.equal(payload.status, "completed");
|
|
assert.equal(payload.provider, "hwlab-skill-cli");
|
|
assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked);
|
|
assert.equal(payload.blocker.code, "m3_readiness_blocked");
|
|
assert.equal(payload.toolCalls[0].method, "POST");
|
|
assert.equal(payload.toolCalls[0].blocker.code, "skill_cli_api_base_missing");
|
|
assert.equal(payload.toolCalls[0].blocker.layer, "skill-cli-config");
|
|
assert.equal(payload.toolCalls[0].blocker.retryable, false);
|
|
assert.deepEqual(payload.toolCalls[0].blocker.missingConfig, [
|
|
"HWLAB_CODE_AGENT_HWLAB_API_BASE_URL",
|
|
"HWLAB_API_BASE_URL",
|
|
"HWLAB_CLOUD_API_BASE_URL",
|
|
"contract:hwlab-agent-runtime.m3-io.apiBaseUrl"
|
|
]);
|
|
assert.equal(JSON.stringify(payload).includes("://"), false);
|
|
});
|
|
|
|
test("Code Agent M3 Skill CLI preserves slow structured blocker beyond legacy 4500ms window", async () => {
|
|
const startedAt = Date.now();
|
|
const payload = await handleCodeAgentChat(
|
|
{
|
|
conversationId: "cnv_m3_skill_slow_blocker",
|
|
traceId: "trc_m3_skill_slow_blocker",
|
|
message: "通过 HWLAB API 读取 M3 DI1 并返回结构化 blocker"
|
|
},
|
|
{
|
|
now: () => "2026-05-23T00:06:30.000Z",
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWLAB_CODE_AGENT_WORKSPACE: process.cwd(),
|
|
HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667",
|
|
OPENAI_API_KEY: "must-not-be-used"
|
|
},
|
|
callProvider: async () => {
|
|
throw new Error("OpenAI fallback must not be used for M3 IO blockers");
|
|
},
|
|
m3IoSkillRequestJson: async (url, request) => {
|
|
assert.equal(new URL(url).pathname, HWLAB_M3_IO_API_ROUTE);
|
|
assert.equal(request.timeoutMs, 30000);
|
|
await delay(4600);
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
body: {
|
|
serviceId: "hwlab-cloud-api",
|
|
contractVersion: "m3-io-control-v1",
|
|
status: "blocked",
|
|
accepted: false,
|
|
action: "di.read",
|
|
traceId: "trc_m3_skill_slow_blocker",
|
|
operationId: "op_m3_di_read_slow_blocker",
|
|
auditId: "aud_m3_di_read_slow_blocker_failed",
|
|
evidenceId: "evd_m3_di_read_slow_blocker_failed",
|
|
blocker: {
|
|
code: "runtime_durable_not_green",
|
|
message: "runtime durable evidence is not green",
|
|
zh: "runtime durable evidence 尚未为 green"
|
|
},
|
|
blockerClassification: {
|
|
category: "runtime_durability",
|
|
reason: "durable evidence is blocked"
|
|
},
|
|
durableStatus: {
|
|
status: "degraded",
|
|
durable: false,
|
|
blocker: "runtime_durable_not_green"
|
|
},
|
|
evidenceState: {
|
|
status: "blocked",
|
|
sourceKind: "BLOCKED",
|
|
blocker: "runtime_durable_not_green",
|
|
writeStatus: "not_written"
|
|
},
|
|
controlPath: {
|
|
cloudApi: true,
|
|
gatewaySimu: false,
|
|
boxSimu: false,
|
|
patchPanel: false,
|
|
frontendBypass: false
|
|
}
|
|
}
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
validateCodeAgentChatSchema(payload);
|
|
assert.equal(Date.now() - startedAt >= 4500, true);
|
|
assert.equal(payload.status, "completed");
|
|
assert.equal(payload.provider, "hwlab-skill-cli");
|
|
assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-skill-cli");
|
|
assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked);
|
|
assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli");
|
|
assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli");
|
|
assert.equal(payload.blocker.code, "m3_readiness_blocked");
|
|
assert.equal(payload.blocker.layer, "m3-readiness");
|
|
assert.equal(payload.blocker.retryable, false);
|
|
assert.equal(payload.toolCalls[0].status, "blocked");
|
|
assert.equal(payload.toolCalls[0].blocker.code, "runtime_durable_not_green");
|
|
assert.equal(payload.toolCalls[0].traceId, "trc_m3_skill_slow_blocker");
|
|
assert.equal(payload.skills.status, "used");
|
|
assert.equal(payload.skills.blockers[0].code, "runtime_durable_not_green");
|
|
assert.equal(payload.runnerTrace.traceId, "trc_m3_skill_slow_blocker");
|
|
assert.equal(payload.runnerTrace.blocker.code, "runtime_durable_not_green");
|
|
assert.equal(payload.providerTrace.fallbackUsed, false);
|
|
assert.equal(payload.providerTrace.traceId, "trc_m3_skill_slow_blocker");
|
|
assert.equal(Object.hasOwn(payload, "error"), false);
|
|
assert.match(payload.reply.content, /Blocker: runtime_durable_not_green/u);
|
|
});
|
|
|
|
test("Code Agent M3 Skill CLI HWLAB API unavailable returns retryable structured blocker", async () => {
|
|
const payload = await handleCodeAgentChat(
|
|
{
|
|
conversationId: "cnv_m3_skill_hwlab_api_unavailable",
|
|
traceId: "trc_m3_skill_hwlab_api_unavailable",
|
|
message: "读取 M3 DI1 readback"
|
|
},
|
|
{
|
|
now: () => "2026-05-23T00:06:40.000Z",
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWLAB_CODE_AGENT_WORKSPACE: process.cwd(),
|
|
HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667"
|
|
},
|
|
m3IoSkillRequestJson: async () => ({
|
|
ok: false,
|
|
status: 503,
|
|
body: null,
|
|
error: "service unavailable"
|
|
})
|
|
}
|
|
);
|
|
|
|
validateCodeAgentChatSchema(payload);
|
|
assert.equal(payload.status, "completed");
|
|
assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked);
|
|
assert.equal(payload.blocker.code, "hwlab_api_unavailable");
|
|
assert.equal(payload.blocker.layer, "hwlab-api");
|
|
assert.equal(payload.blocker.retryable, true);
|
|
assert.equal(payload.blocker.route, HWLAB_M3_IO_API_ROUTE);
|
|
assert.equal(payload.toolCalls[0].method, "POST");
|
|
assert.equal(payload.toolCalls[0].blocker.code, "hwlab_api_unavailable");
|
|
assert.equal(payload.toolCalls[0].blocker.retryable, true);
|
|
});
|
|
|
|
test("Code Agent M3 DO false write exposes readback and identifier contract", async () => {
|
|
const calls = [];
|
|
const payload = await handleCodeAgentChat(
|
|
{
|
|
conversationId: "cnv_m3_skill_write_false",
|
|
traceId: "trc_m3_skill_write_false",
|
|
message: "通过 HWLAB API 把 res_boxsimu_1 DO1 写成 false 并读取 res_boxsimu_2 DI1"
|
|
},
|
|
{
|
|
now: () => "2026-05-23T00:06:30.000Z",
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWLAB_CODE_AGENT_WORKSPACE: process.cwd(),
|
|
HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667",
|
|
OPENAI_API_KEY: "must-not-be-used"
|
|
},
|
|
callProvider: async () => {
|
|
throw new Error("OpenAI fallback must not be used for M3 IO control");
|
|
},
|
|
m3IoSkillRequestJson: async (url, request) => {
|
|
calls.push({ url, request });
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
body: {
|
|
serviceId: "hwlab-cloud-api",
|
|
contractVersion: "m3-io-control-v1",
|
|
status: "succeeded",
|
|
accepted: true,
|
|
action: "do.write",
|
|
traceId: "trc_m3_skill_write_false",
|
|
operationId: "op_m3_do_write_skill_false",
|
|
auditId: "aud_m3_do_write_skill_false_succeeded",
|
|
evidenceId: "evd_m3_do_write_skill_false_succeeded",
|
|
auditState: {
|
|
status: "written_non_durable"
|
|
},
|
|
evidenceState: {
|
|
status: "blocked",
|
|
sourceKind: "BLOCKED",
|
|
blocker: "runtime_durable_not_green",
|
|
writeStatus: "written_non_durable"
|
|
},
|
|
durableStatus: {
|
|
status: "degraded",
|
|
durable: false,
|
|
blocker: "runtime_durable_not_green"
|
|
},
|
|
result: {
|
|
value: false,
|
|
targetReadback: {
|
|
status: "succeeded",
|
|
value: false,
|
|
resourceId: "res_boxsimu_2",
|
|
port: "DI1"
|
|
}
|
|
},
|
|
controlPath: {
|
|
status: "succeeded",
|
|
cloudApi: true,
|
|
gatewaySimu: true,
|
|
boxSimu: true,
|
|
patchPanel: true,
|
|
frontendBypass: false
|
|
}
|
|
}
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
validateCodeAgentChatSchema(payload);
|
|
assert.equal(payload.status, "completed");
|
|
assert.equal(payload.provider, "hwlab-skill-cli");
|
|
assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready);
|
|
const tool = payload.toolCalls[0];
|
|
assert.equal(tool.route, HWLAB_M3_IO_API_ROUTE);
|
|
assert.equal(tool.method, "POST");
|
|
assert.equal(tool.accepted, true);
|
|
assert.equal(tool.status, "completed");
|
|
assert.equal(tool.operationId, "op_m3_do_write_skill_false");
|
|
assert.equal(tool.traceId, "trc_m3_skill_write_false");
|
|
assert.equal(tool.auditId, "aud_m3_do_write_skill_false_succeeded");
|
|
assert.equal(tool.evidenceId, "evd_m3_do_write_skill_false_succeeded");
|
|
assert.equal(tool.readback.value, false);
|
|
assert.equal(tool.readback.resourceId, "res_boxsimu_2");
|
|
assert.equal(tool.readback.port, "DI1");
|
|
assert.equal(payload.runnerTrace.method, "POST");
|
|
assert.equal(payload.runnerTrace.readback.value, false);
|
|
assert.equal(payload.providerTrace.readback.value, false);
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(calls[0].url, `http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667${HWLAB_M3_IO_API_ROUTE}`);
|
|
assert.equal(calls[0].request.body.action, "do.write");
|
|
assert.equal(calls[0].request.body.value, false);
|
|
assert.equal(calls[0].url.includes("gateway"), false);
|
|
assert.equal(calls[0].url.includes("box-simu"), false);
|
|
assert.equal(calls[0].url.includes("patch-panel"), false);
|
|
});
|
|
|
|
test("Code Agent blocks direct gateway or patch-panel requests instead of using M3 Skill CLI", async () => {
|
|
const direct = await handleCodeAgentChat(
|
|
{
|
|
conversationId: "cnv_m3_direct_blocked",
|
|
traceId: "trc_m3_direct_blocked",
|
|
message: "请直接调用 gateway-simu /invoke 写入 M3 DO1 true"
|
|
},
|
|
{
|
|
now: () => "2026-05-23T00:07:00.000Z",
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWLAB_CODE_AGENT_WORKSPACE: process.cwd(),
|
|
HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667"
|
|
},
|
|
m3IoSkillRequestJson: async () => {
|
|
throw new Error("direct gateway request must not reach the M3 skill CLI");
|
|
}
|
|
}
|
|
);
|
|
|
|
validateCodeAgentChatSchema(direct);
|
|
assert.equal(direct.status, "failed");
|
|
assert.equal(direct.error.code, "security_blocked");
|
|
assert.equal(direct.error.layer, "security");
|
|
assert.equal(direct.error.retryable, false);
|
|
assert.match(direct.error.userMessage, /安全边界/u);
|
|
assert.equal(direct.error.blocker.toolName, "security.hardware-boundary");
|
|
assert.equal(direct.toolCalls[0].name, "security.hardware-boundary");
|
|
assert.match(direct.error.message, /Skill CLI -> HWLAB API \/v1\/m3\/io/u);
|
|
});
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
test("Code Agent M3 Skill CLI blocks missing service-local HWLAB API base URL before loopback fallback", async () => {
|
|
const payload = await handleCodeAgentChat(
|
|
{
|
|
conversationId: "cnv_m3_missing_api_base",
|
|
traceId: "trc_m3_missing_api_base",
|
|
message: "通过 HWLAB API 把 res_boxsimu_1 的 DO1 写成 true,然后读取 res_boxsimu_2 的 DI1。"
|
|
},
|
|
{
|
|
now: () => "2026-05-23T00:07:30.000Z",
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWLAB_CODE_AGENT_WORKSPACE: process.cwd(),
|
|
OPENAI_API_KEY: "must-not-be-used"
|
|
},
|
|
callProvider: async () => {
|
|
throw new Error("OpenAI fallback must not be used for M3 IO");
|
|
},
|
|
m3IoSkillRequestJson: async () => {
|
|
throw new Error("missing HWLAB API base URL must block before any HTTP request");
|
|
}
|
|
}
|
|
);
|
|
|
|
validateCodeAgentChatSchema(payload);
|
|
assert.equal(payload.status, "completed");
|
|
assert.equal(payload.provider, "hwlab-skill-cli");
|
|
assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked);
|
|
assert.equal(payload.toolCalls.length, 1);
|
|
assert.equal(payload.toolCalls[0].status, "blocked");
|
|
assert.equal(payload.toolCalls[0].blocker.code, "skill_cli_api_base_missing");
|
|
assert.equal(payload.toolCalls[0].blocker.layer, "skill-cli-config");
|
|
assert.equal(payload.toolCalls[0].blocker.retryable, false);
|
|
assert.deepEqual(payload.toolCalls[0].blocker.missingConfig, [
|
|
"HWLAB_CODE_AGENT_HWLAB_API_BASE_URL",
|
|
"HWLAB_API_BASE_URL",
|
|
"HWLAB_CLOUD_API_BASE_URL",
|
|
"contract:hwlab-agent-runtime.m3-io.apiBaseUrl"
|
|
]);
|
|
assert.equal(payload.toolCalls[0].hwlabApi.source, "missing-config");
|
|
assert.equal(payload.toolCalls[0].hwlabApi.redactedUrl, null);
|
|
assert.equal(payload.toolCalls[0].command.includes("127.0.0.1:6667"), false);
|
|
assert.equal(payload.toolCalls[0].accepted, false);
|
|
assert.equal(payload.toolCalls[0].operationId, null);
|
|
assert.equal(payload.session.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked);
|
|
assert.equal(payload.runnerTrace.blocker.code, "skill_cli_api_base_missing");
|
|
assert.equal(payload.runnerTrace.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked);
|
|
assert.equal(payload.providerTrace.fallbackUsed, false);
|
|
assert.equal(payload.skills.blockers[0].code, "skill_cli_api_base_missing");
|
|
});
|
|
|
|
test("Codex stdio manager reports concrete blockers without falling back to readonly", async () => {
|
|
const manager = createCodexStdioSessionManager({
|
|
idFactory: () => "ses_stdio_blocked"
|
|
});
|
|
const blocked = await handleCodeAgentChat(
|
|
{
|
|
conversationId: "cnv_stdio_blocked",
|
|
traceId: "trc_stdio_blocked",
|
|
message: "请用同一个 Code Agent session 继续"
|
|
},
|
|
{
|
|
now: () => "2026-05-23T00:05:00.000Z",
|
|
codexStdioManager: manager,
|
|
env: {
|
|
PATH: "",
|
|
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
|
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
|
HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex",
|
|
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
|
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
|
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
|
|
}
|
|
}
|
|
);
|
|
assert.equal(blocked.status, "failed");
|
|
assert.equal(blocked.provider, "codex-stdio");
|
|
assert.equal(blocked.backend, "hwlab-cloud-api/codex-mcp-stdio");
|
|
assert.equal(blocked.error.code, "codex_stdio_blocked");
|
|
assert.equal(blocked.error.layer, "runner");
|
|
assert.equal(blocked.error.retryable, false);
|
|
assert.match(blocked.error.userMessage, /Codex stdio/u);
|
|
assert.equal(blocked.error.blocker.runner, "codex-mcp-stdio-runner");
|
|
assert.ok(blocked.error.blockers.some((blocker) => blocker.code === "codex_cli_binary_missing"));
|
|
assert.ok(blocked.error.blockers.some((blocker) => blocker.code === "provider_token_boundary"));
|
|
assert.equal(blocked.capabilityLevel, "blocked");
|
|
assert.equal(blocked.longLivedSessionGate.status, "blocked");
|
|
assert.equal(blocked.availability.ready, false);
|
|
assert.equal(blocked.availability.codexStdio.status, "blocked");
|
|
assert.equal(Object.hasOwn(blocked, "reply"), false);
|
|
});
|
|
|
|
test("repo-owned Codex stdio manager creates and reuses long-lived sessions with trace evidence", async () => {
|
|
const calls = [];
|
|
const manager = createCodexStdioSessionManager({
|
|
idFactory: () => "ses_stdio_ready",
|
|
createRpcClient: async () => ({
|
|
async initialize() {
|
|
return { tools: ["codex", "codex-reply"] };
|
|
},
|
|
async listTools() {
|
|
return ["codex", "codex-reply"];
|
|
},
|
|
async callTool(name, args) {
|
|
calls.push({ name, args });
|
|
return {
|
|
structuredContent: {
|
|
threadId: "thread_stdio_ready",
|
|
content: name === "codex" ? "first stdio reply" : "second stdio reply"
|
|
}
|
|
};
|
|
},
|
|
close() {}
|
|
})
|
|
});
|
|
const env = {
|
|
PATH: process.env.PATH,
|
|
OPENAI_API_KEY: "test-openai-key-material",
|
|
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
|
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
|
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
|
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
|
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
|
|
};
|
|
|
|
const first = await handleCodeAgentChat(
|
|
{
|
|
conversationId: "cnv_stdio_ready",
|
|
traceId: "trc_stdio_ready_1",
|
|
message: "first"
|
|
},
|
|
{
|
|
now: () => "2026-05-23T00:06:00.000Z",
|
|
codexStdioManager: manager,
|
|
env
|
|
}
|
|
);
|
|
validateCodeAgentChatSchema(first);
|
|
assert.equal(first.status, "completed");
|
|
assert.equal(first.provider, "codex-stdio");
|
|
assert.equal(first.runner.kind, "codex-mcp-stdio-runner");
|
|
assert.equal(first.sessionMode, "codex-mcp-stdio-long-lived");
|
|
assert.equal(first.implementationType, "repo-owned-codex-mcp-stdio-session");
|
|
assert.equal(first.session.longLivedSession, true);
|
|
assert.equal(first.session.codexStdio, true);
|
|
assert.equal(first.runner.writeCapable, true);
|
|
assert.equal(first.runner.durableSession, true);
|
|
assert.equal(first.capabilityLevel, "long-lived-codex-stdio-session");
|
|
assert.equal(first.longLivedSessionGate.status, "pass");
|
|
assert.equal(first.longLivedSessionGate.pass, true);
|
|
assert.equal(classifyCodexRunnerCapability(first, { httpStatus: 200 }).capabilityPass, true);
|
|
|
|
const second = await handleCodeAgentChat(
|
|
{
|
|
conversationId: "cnv_stdio_ready",
|
|
traceId: "trc_stdio_ready_2",
|
|
message: "second"
|
|
},
|
|
{
|
|
now: () => "2026-05-23T00:06:01.000Z",
|
|
codexStdioManager: manager,
|
|
env
|
|
}
|
|
);
|
|
assert.equal(second.status, "completed");
|
|
assert.equal(second.sessionId, first.sessionId);
|
|
assert.equal(second.sessionReuse.reused, true);
|
|
assert.equal(second.sessionReuse.turn, 2);
|
|
assert.equal(second.toolCalls[0].name, "codex-reply");
|
|
assert.equal(second.reply.content, "second stdio reply");
|
|
assert.equal(calls[0].name, "codex");
|
|
assert.equal(calls[1].name, "codex-reply");
|
|
assert.equal(calls[1].args.threadId, "thread_stdio_ready");
|
|
});
|
|
|
|
test("Codex stdio runner startup failure marks session failed and blocks long-lived gate", async () => {
|
|
const manager = createCodexStdioSessionManager({
|
|
idFactory: () => "ses_stdio_start_failed",
|
|
createRpcClient: async () => ({
|
|
async initialize() {
|
|
throw new Error("MCP initialize failed with sk-test-secret and TOKEN=abc123");
|
|
},
|
|
close() {}
|
|
})
|
|
});
|
|
const env = {
|
|
PATH: process.env.PATH,
|
|
OPENAI_API_KEY: "test-openai-key-material",
|
|
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
|
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
|
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
|
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
|
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
|
|
};
|
|
|
|
const payload = await handleCodeAgentChat(
|
|
{
|
|
conversationId: "cnv_stdio_start_failed",
|
|
traceId: "trc_stdio_start_failed",
|
|
message: "启动后回答"
|
|
},
|
|
{
|
|
now: () => "2026-05-23T00:08:00.000Z",
|
|
codexStdioManager: manager,
|
|
env
|
|
}
|
|
);
|
|
|
|
validateCodeAgentChatSchema(payload);
|
|
assert.equal(payload.status, "failed");
|
|
assert.equal(payload.provider, "codex-stdio");
|
|
assert.equal(payload.error.code, "codex_stdio_failed");
|
|
assert.equal(payload.session.sessionId, "ses_stdio_start_failed");
|
|
assert.equal(payload.session.status, "failed");
|
|
assert.equal(payload.session.statusReason, "codex_stdio_failed");
|
|
assert.equal(payload.longLivedSessionGate.status, "blocked");
|
|
assert.equal(payload.longLivedSessionGate.pass, false);
|
|
assert.ok(payload.longLivedSessionGate.blockers.some((blocker) => blocker.code === "session_failed"));
|
|
assert.equal(payload.capabilityLevel, "blocked");
|
|
assert.equal(payload.runnerTrace.sessionStatus, "failed");
|
|
assert.equal(payload.runnerTrace.valuesPrinted, false);
|
|
const serialized = JSON.stringify(payload);
|
|
assert.equal(serialized.includes("sk-test-secret"), false);
|
|
assert.equal(serialized.includes("TOKEN=abc123"), false);
|
|
assert.equal(classifyCodexRunnerCapability(payload, { httpStatus: 200 }).capabilityPass, false);
|
|
});
|
|
|
|
test("Codex stdio protocol missing tools is a structured blocker, not a passed session", async () => {
|
|
const manager = createCodexStdioSessionManager({
|
|
idFactory: () => "ses_stdio_missing_tool",
|
|
createRpcClient: async () => ({
|
|
async initialize() {
|
|
return { tools: ["codex"] };
|
|
},
|
|
async listTools() {
|
|
return ["codex"];
|
|
},
|
|
close() {}
|
|
})
|
|
});
|
|
const env = {
|
|
PATH: process.env.PATH,
|
|
OPENAI_API_KEY: "test-openai-key-material",
|
|
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
|
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
|
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
|
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
|
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
|
|
};
|
|
|
|
const payload = await handleCodeAgentChat(
|
|
{
|
|
conversationId: "cnv_stdio_missing_tool",
|
|
traceId: "trc_stdio_missing_tool",
|
|
message: "需要长会话"
|
|
},
|
|
{
|
|
now: () => "2026-05-23T00:08:30.000Z",
|
|
codexStdioManager: manager,
|
|
env
|
|
}
|
|
);
|
|
|
|
validateCodeAgentChatSchema(payload);
|
|
assert.equal(payload.status, "failed");
|
|
assert.equal(payload.error.code, "codex_stdio_protocol_blocked");
|
|
assert.deepEqual(payload.error.missingTools, ["codex-reply"]);
|
|
assert.equal(payload.session.status, "failed");
|
|
assert.equal(payload.longLivedSessionGate.status, "blocked");
|
|
assert.ok(payload.longLivedSessionGate.blockers.some((blocker) => blocker.code === "session_failed"));
|
|
assert.equal(payload.availability.codexStdio.ready, true);
|
|
assert.deepEqual(payload.availability.codexStdio.protocol.toolsObserved, ["codex"]);
|
|
assert.equal(classifyCodexRunnerCapability(payload, { httpStatus: 200 }).capabilityPass, false);
|
|
});
|