2188 lines
86 KiB
JavaScript
2188 lines
86 KiB
JavaScript
import assert from "node:assert/strict";
|
||
import { existsSync } from "node:fs";
|
||
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
import test from "node:test";
|
||
|
||
import { createCodeAgentSessionRegistry } from "./code-agent-session-registry.mjs";
|
||
import { handleCodeAgentChat, validateCodeAgentChatSchema } from "./code-agent-chat.mjs";
|
||
import {
|
||
codexAppServerArgs,
|
||
codexAppServerProviderBaseUrl,
|
||
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,
|
||
HWLAB_M3_STATUS_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.workspace, "/workspace/hwlab");
|
||
assert.equal(first.session.sandbox, "read-only");
|
||
assert.equal(first.session.runnerKind, "hwlab-readonly-runner");
|
||
assert.equal(first.session.sessionMode, "controlled-readonly-session-registry");
|
||
assert.equal(first.session.capabilityLevel, "read-only-session-tools");
|
||
assert.equal(first.session.longLivedSession, true);
|
||
assert.equal(first.session.durableSession, false);
|
||
assert.equal(first.session.codexStdio, false);
|
||
assert.equal(first.session.writeCapable, false);
|
||
assert.equal(first.session.readOnly, true);
|
||
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);
|
||
const describedCreating = registry.describe();
|
||
assert.equal(describedCreating.sessionMode, "controlled-readonly-session-registry");
|
||
assert.equal(describedCreating.longLivedSession, true);
|
||
assert.equal(describedCreating.longLivedCodexStdio, false);
|
||
assert.equal(describedCreating.recentSessions[0].sessionId, "ses_test-registry");
|
||
assert.equal(describedCreating.recentSessions[0].sessionMode, "controlled-readonly-session-registry");
|
||
assert.equal(describedCreating.recentSessions[0].workspace, "/workspace/hwlab");
|
||
assert.equal(describedCreating.recentSessions[0].secretMaterialStored, false);
|
||
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 stores bounded non-sensitive conversation facts", () => {
|
||
const registry = createCodeAgentSessionRegistry({
|
||
maxConversationFacts: 2,
|
||
maxFactSkills: 2,
|
||
idFactory: () => "ses_fact_registry"
|
||
});
|
||
const acquired = registry.acquire({
|
||
conversationId: "cnv_fact_registry",
|
||
workspace: "/workspace/hwlab",
|
||
traceId: "trc_fact_registry_1",
|
||
now: "2026-05-23T00:00:00.000Z"
|
||
});
|
||
registry.release(acquired.session.sessionId, {
|
||
conversationId: "cnv_fact_registry",
|
||
traceId: "trc_fact_registry_1",
|
||
now: "2026-05-23T00:00:00.010Z"
|
||
});
|
||
|
||
registry.recordFact("cnv_fact_registry", {
|
||
traceId: "trc_fact_registry_1",
|
||
provider: "codex-readonly-runner",
|
||
backend: "hwlab-cloud-api/codex-readonly-runner",
|
||
workspace: "/workspace/hwlab",
|
||
sandbox: "read-only",
|
||
session: acquired.session,
|
||
sessionMode: "controlled-readonly-session-registry",
|
||
capabilityLevel: "read-only-session-tools",
|
||
runner: {
|
||
kind: "hwlab-readonly-runner",
|
||
sessionId: "ses_fact_registry"
|
||
},
|
||
runnerTrace: {
|
||
traceId: "trc_fact_registry_1",
|
||
runnerKind: "hwlab-readonly-runner",
|
||
sessionMode: "controlled-readonly-session-registry",
|
||
events: ["tool:pwd:completed"],
|
||
valuesPrinted: false
|
||
},
|
||
toolCalls: [{
|
||
name: "pwd",
|
||
status: "completed",
|
||
stdout: "must-not-store-output sk-secret-value OPENAI_API_KEY=secret",
|
||
traceId: "trc_fact_registry_1"
|
||
}],
|
||
skills: {
|
||
status: "ready",
|
||
items: [
|
||
{ name: "alpha" },
|
||
{ name: "beta" },
|
||
{ name: "gamma" }
|
||
],
|
||
count: 3,
|
||
totalCount: 3
|
||
}
|
||
}, { now: "2026-05-23T00:00:00.020Z" });
|
||
registry.recordFact("cnv_fact_registry", {
|
||
traceId: "trc_fact_registry_2",
|
||
workspace: "/workspace/hwlab",
|
||
runner: { kind: "hwlab-readonly-runner" },
|
||
toolCalls: [{
|
||
name: "skills.discover",
|
||
status: "completed",
|
||
stdout: "must-not-store-output sk-secret-value OPENAI_API_KEY=secret"
|
||
}],
|
||
skills: {
|
||
status: "ready",
|
||
items: [
|
||
{ name: "alpha" },
|
||
{ name: "beta" },
|
||
{ name: "gamma" }
|
||
],
|
||
count: 3,
|
||
totalCount: 3
|
||
}
|
||
}, { now: "2026-05-23T00:00:00.030Z" });
|
||
registry.recordFact("cnv_fact_registry", {
|
||
traceId: "trc_fact_registry_3",
|
||
workspace: "/workspace/hwlab",
|
||
runner: { kind: "hwlab-readonly-runner" },
|
||
toolCalls: [{ name: "session.context", status: "completed" }]
|
||
}, { now: "2026-05-23T00:00:00.040Z" });
|
||
|
||
const facts = registry.getConversationFacts("cnv_fact_registry");
|
||
assert.equal(facts.conversationId, "cnv_fact_registry");
|
||
assert.equal(facts.sessionId, "ses_fact_registry");
|
||
assert.equal(facts.workspace, "/workspace/hwlab");
|
||
assert.equal(facts.turnCount, 2);
|
||
assert.deepEqual(facts.traceIds, ["trc_fact_registry_2", "trc_fact_registry_3"]);
|
||
assert.equal(facts.valuesRedacted, true);
|
||
assert.equal(facts.secretMaterialStored, false);
|
||
assert.deepEqual(facts.latestSkills.names, ["alpha", "beta"]);
|
||
assert.equal(facts.latestSkills.truncated, true);
|
||
assert.equal(JSON.stringify(facts).includes("must-not-store-output"), false);
|
||
assert.equal(JSON.stringify(facts).includes("sk-secret-value"), false);
|
||
assert.equal(JSON.stringify(facts).includes("OPENAI_API_KEY=secret"), false);
|
||
assert.match(facts.summary, /workspace=\/workspace\/hwlab/u);
|
||
});
|
||
|
||
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("code agent session registry blocks failed and interrupted sessions without reusing them", () => {
|
||
const failedRegistry = createCodeAgentSessionRegistry({
|
||
idFactory: () => "ses_failed"
|
||
});
|
||
const failedSeed = failedRegistry.acquire({
|
||
conversationId: "cnv_failed",
|
||
workspace: "/workspace/hwlab",
|
||
traceId: "trc_failed_1",
|
||
now: "2026-05-23T00:00:00.000Z"
|
||
});
|
||
assert.equal(failedSeed.ok, true);
|
||
failedRegistry.fail(failedSeed.session.sessionId, {
|
||
conversationId: "cnv_failed",
|
||
traceId: "trc_failed_1",
|
||
now: "2026-05-23T00:00:00.010Z"
|
||
});
|
||
const failed = failedRegistry.acquire({
|
||
conversationId: "cnv_failed",
|
||
workspace: "/workspace/hwlab",
|
||
traceId: "trc_failed_2",
|
||
now: "2026-05-23T00:00:01.000Z"
|
||
});
|
||
assert.equal(failed.ok, false);
|
||
assert.equal(failed.code, "session_failed");
|
||
assert.equal(failed.session.status, "failed");
|
||
assert.equal(failed.blocker.sourceIssue, "pikasTech/HWLAB#317");
|
||
|
||
const interruptedRegistry = createCodeAgentSessionRegistry({
|
||
idFactory: () => "ses_interrupted"
|
||
});
|
||
const interruptedSeed = interruptedRegistry.acquire({
|
||
conversationId: "cnv_interrupted",
|
||
workspace: "/workspace/hwlab",
|
||
traceId: "trc_interrupted_1",
|
||
now: "2026-05-23T00:00:00.000Z"
|
||
});
|
||
assert.equal(interruptedSeed.ok, true);
|
||
interruptedRegistry.interrupt(interruptedSeed.session.sessionId, {
|
||
conversationId: "cnv_interrupted",
|
||
traceId: "trc_interrupted_1",
|
||
now: "2026-05-23T00:00:00.010Z"
|
||
});
|
||
const interrupted = interruptedRegistry.acquire({
|
||
conversationId: "cnv_interrupted",
|
||
workspace: "/workspace/hwlab",
|
||
traceId: "trc_interrupted_2",
|
||
now: "2026-05-23T00:00:01.000Z"
|
||
});
|
||
assert.equal(interrupted.ok, false);
|
||
assert.equal(interrupted.code, "session_interrupted");
|
||
assert.equal(interrupted.session.status, "interrupted");
|
||
});
|
||
|
||
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.sessionMode, "controlled-readonly-session-registry");
|
||
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.session.longLivedSession, true);
|
||
assert.equal(first.runner.longLivedSession, true);
|
||
assert.equal(first.longLivedSessionGate.status, "blocked");
|
||
assert.equal(first.longLivedSessionGate.pass, false);
|
||
assert.ok(first.longLivedSessionGate.blockers.some((blocker) => blocker.code === "codex_stdio_blocked_readonly_session_available"));
|
||
|
||
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, busy, and failed 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"
|
||
});
|
||
expiredRegistry.recordFact("cnv_expired_chat", {
|
||
traceId: "trc_expired_seed",
|
||
workspace: process.cwd(),
|
||
session: first.session,
|
||
runner: { kind: "hwlab-readonly-runner" },
|
||
sessionMode: "controlled-readonly-session-registry",
|
||
capabilityLevel: "read-only-session-tools",
|
||
toolCalls: [{ name: "pwd", status: "completed" }]
|
||
}, { now: "2026-05-23T00:02:00.002Z" });
|
||
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");
|
||
assert.equal(expired.conversationFacts.conversationId, "cnv_expired_chat");
|
||
assert.equal(expired.conversationFacts.sessionId, "ses_expired_chat");
|
||
assert.equal(expired.conversationFacts.turnCount, 1);
|
||
assert.equal(expired.conversationFacts.recentToolCalls[0].name, "pwd");
|
||
assert.equal(expired.error.blocker.conversationFacts.sessionId, "ses_expired_chat");
|
||
|
||
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"
|
||
});
|
||
busyRegistry.recordFact("cnv_busy_chat", {
|
||
traceId: "trc_busy_seed",
|
||
workspace: process.cwd(),
|
||
sessionId: "ses_busy_chat",
|
||
runner: { kind: "hwlab-readonly-runner" },
|
||
sessionMode: "controlled-readonly-session-registry",
|
||
capabilityLevel: "read-only-session-tools",
|
||
toolCalls: [{ name: "skills.discover", status: "completed" }],
|
||
skills: {
|
||
status: "ready",
|
||
items: [{ name: "alpha-skill" }],
|
||
count: 1,
|
||
totalCount: 1
|
||
}
|
||
}, { now: "2026-05-23T00:03:00.001Z" });
|
||
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");
|
||
assert.equal(busy.conversationFacts.conversationId, "cnv_busy_chat");
|
||
assert.equal(busy.conversationFacts.sessionId, "ses_busy_chat");
|
||
assert.equal(busy.conversationFacts.latestSkills.names[0], "alpha-skill");
|
||
|
||
const failedRegistry = createCodeAgentSessionRegistry({
|
||
idFactory: () => "ses_failed_chat"
|
||
});
|
||
const failedSeed = failedRegistry.acquire({
|
||
conversationId: "cnv_failed_chat",
|
||
workspace: process.cwd(),
|
||
sandbox: "read-only",
|
||
runnerKind: "hwlab-readonly-runner",
|
||
capabilityLevel: "read-only-session-tools",
|
||
traceId: "trc_failed_seed",
|
||
now: "2026-05-23T00:04:00.000Z"
|
||
});
|
||
failedRegistry.fail(failedSeed.session.sessionId, {
|
||
conversationId: "cnv_failed_chat",
|
||
traceId: "trc_failed_seed",
|
||
now: "2026-05-23T00:04:00.001Z"
|
||
});
|
||
failedRegistry.recordFact("cnv_failed_chat", {
|
||
traceId: "trc_failed_seed",
|
||
workspace: process.cwd(),
|
||
session: failedSeed.session,
|
||
runner: { kind: "hwlab-readonly-runner" },
|
||
sessionMode: "controlled-readonly-session-registry",
|
||
capabilityLevel: "read-only-session-tools",
|
||
toolCalls: [{ name: "pwd", status: "completed" }]
|
||
}, { now: "2026-05-23T00:04:00.002Z" });
|
||
const failed = await handleCodeAgentChat(
|
||
{
|
||
conversationId: "cnv_failed_chat",
|
||
traceId: "trc_failed_request",
|
||
message: "pwd"
|
||
},
|
||
{
|
||
now: () => "2026-05-23T00:04:01.000Z",
|
||
sessionRegistry: failedRegistry,
|
||
env: {
|
||
PATH: process.env.PATH,
|
||
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
|
||
}
|
||
}
|
||
);
|
||
assert.equal(failed.status, "failed");
|
||
assert.equal(failed.error.code, "session_failed");
|
||
assert.equal(failed.error.layer, "session");
|
||
assert.equal(failed.error.retryable, true);
|
||
assert.match(failed.error.userMessage, /session 已失败/u);
|
||
assert.equal(failed.error.blocker.code, "session_failed");
|
||
assert.equal(failed.session.status, "failed");
|
||
assert.equal(failed.capabilityLevel, "blocked");
|
||
assert.equal(failed.conversationFacts.conversationId, "cnv_failed_chat");
|
||
assert.equal(failed.conversationFacts.sessionId, "ses_failed_chat");
|
||
assert.equal(failed.conversationFacts.recentToolCalls[0].name, "pwd");
|
||
});
|
||
|
||
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.deepEqual(fallback.runnerLimitations, [
|
||
"openai-responses-fallback",
|
||
"text-chat-only",
|
||
"not-workspace-tools",
|
||
"not-session-runner"
|
||
]);
|
||
assert.equal(fallback.runnerLimitations.includes("not-codex-stdio"), false);
|
||
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,
|
||
longLivedSession: 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("OpenAI provider turns receive bounded prior session facts and record continuity", async () => {
|
||
const registry = createCodeAgentSessionRegistry({
|
||
idFactory: () => "ses_provider_context"
|
||
});
|
||
const providerPrompts = [];
|
||
const env = {
|
||
PATH: process.env.PATH,
|
||
HWLAB_CODE_AGENT_PROVIDER: "openai",
|
||
HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK: "1",
|
||
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
|
||
};
|
||
|
||
const first = await handleCodeAgentChat(
|
||
{
|
||
conversationId: "cnv_provider_context",
|
||
traceId: "trc_provider_context_pwd",
|
||
message: "pwd"
|
||
},
|
||
{
|
||
now: () => "2026-05-23T00:04:10.000Z",
|
||
sessionRegistry: registry,
|
||
env
|
||
}
|
||
);
|
||
assert.equal(first.status, "completed");
|
||
assert.equal(first.provider, "codex-readonly-runner");
|
||
assert.equal(first.toolCalls[0].name, "pwd");
|
||
assert.equal(first.conversationFacts.turnCount, 1);
|
||
|
||
const second = await handleCodeAgentChat(
|
||
{
|
||
conversationId: "cnv_provider_context",
|
||
traceId: "trc_provider_context_openai",
|
||
message: "请根据刚才的上下文简要说明"
|
||
},
|
||
{
|
||
now: () => "2026-05-23T00:04:11.000Z",
|
||
sessionRegistry: registry,
|
||
env,
|
||
callProvider: async ({ conversationFacts }) => {
|
||
const promptFacts = JSON.stringify(conversationFacts);
|
||
providerPrompts.push(promptFacts);
|
||
assert.equal(conversationFacts.sessionId, first.sessionId);
|
||
assert.equal(conversationFacts.workspace, process.cwd());
|
||
assert.equal(conversationFacts.recentToolCalls[0].name, "pwd");
|
||
return {
|
||
provider: "openai-responses",
|
||
model: "gpt-test",
|
||
backend: "hwlab-cloud-api/openai-responses",
|
||
content: `上一轮工作目录是 ${conversationFacts.workspace},session=${conversationFacts.sessionId},trace=${conversationFacts.latestTraceId}。`,
|
||
usage: null,
|
||
providerTrace: { source: "test-provider" }
|
||
};
|
||
}
|
||
}
|
||
);
|
||
|
||
assert.equal(second.status, "completed");
|
||
assert.equal(second.provider, "openai-responses");
|
||
assert.equal(second.conversationFacts.turnCount, 2);
|
||
assert.equal(second.conversationFacts.sessionId, first.sessionId);
|
||
assert.equal(second.conversationFacts.latestProvider, "openai-responses");
|
||
assert.equal(second.conversationFacts.latestBackend, "hwlab-cloud-api/openai-responses");
|
||
assert.match(second.reply.content, /上一轮工作目录/u);
|
||
assert.match(providerPrompts[0], /trc_provider_context_pwd/u);
|
||
assert.equal(JSON.stringify(second.conversationFacts).includes("sk-"), false);
|
||
});
|
||
|
||
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.session.sessionMode, "controlled-m3-io-skill-cli");
|
||
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 M3 true/false/read requests bypass Codex stdio and use Skill CLI /v1/m3/io", async () => {
|
||
const calls = [];
|
||
const codexStdioCalls = [];
|
||
const forbiddenCodexStdioManager = {
|
||
describe() {
|
||
codexStdioCalls.push("describe");
|
||
throw new Error("M3 IO must not inspect Codex stdio before Skill CLI control");
|
||
},
|
||
async probe() {
|
||
codexStdioCalls.push("probe");
|
||
throw new Error("M3 IO must not probe Codex stdio before Skill CLI control");
|
||
},
|
||
async chat() {
|
||
codexStdioCalls.push("chat");
|
||
throw new Error("M3 IO must not enter Codex stdio chat");
|
||
},
|
||
cancel() {
|
||
codexStdioCalls.push("cancel");
|
||
},
|
||
reapIdle() {
|
||
codexStdioCalls.push("reapIdle");
|
||
}
|
||
};
|
||
let sessionSeq = 0;
|
||
const sessionRegistry = createCodeAgentSessionRegistry({
|
||
idFactory: () => `ses_m3_stdio_bypass_${sessionSeq += 1}`
|
||
});
|
||
const cases = [
|
||
{
|
||
suffix: "true",
|
||
message: "通过 HWLAB API 把 DO1 置为 true,然后读取 DI1。",
|
||
action: "do.write",
|
||
value: true,
|
||
operationId: "op_m3_do_true_bypass",
|
||
auditId: "aud_m3_do_true_bypass",
|
||
evidenceId: "evd_m3_do_true_bypass"
|
||
},
|
||
{
|
||
suffix: "false",
|
||
message: "把 res_boxsimu_1 的 DO1 置为 false,并回读 res_boxsimu_2 的 DI1。",
|
||
action: "do.write",
|
||
value: false,
|
||
operationId: "op_m3_do_false_bypass",
|
||
auditId: "aud_m3_do_false_bypass",
|
||
evidenceId: "evd_m3_do_false_bypass"
|
||
},
|
||
{
|
||
suffix: "read",
|
||
message: "读取 DI1 的当前状态,走 HWLAB API 控制 M3 IO。",
|
||
action: "di.read",
|
||
value: false,
|
||
operationId: "op_m3_di_read_bypass",
|
||
auditId: "aud_m3_di_read_bypass",
|
||
evidenceId: "evd_m3_di_read_bypass"
|
||
}
|
||
];
|
||
|
||
for (const item of cases) {
|
||
const payload = await handleCodeAgentChat(
|
||
{
|
||
conversationId: `cnv_m3_stdio_bypass_${item.suffix}`,
|
||
traceId: `trc_m3_stdio_bypass_${item.suffix}`,
|
||
message: item.message
|
||
},
|
||
{
|
||
now: () => "2026-05-23T00:08:40.000Z",
|
||
codexStdioManager: forbiddenCodexStdioManager,
|
||
sessionRegistry,
|
||
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(),
|
||
HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667"
|
||
},
|
||
callProvider: async () => {
|
||
throw new Error("OpenAI fallback must not be used for M3 IO control");
|
||
},
|
||
m3IoSkillRequestJson: async (url, request) => {
|
||
calls.push({ suffix: item.suffix, url, request });
|
||
assert.equal(new URL(url).pathname, HWLAB_M3_IO_API_ROUTE);
|
||
assert.equal(request.method, "POST");
|
||
assert.equal(request.body.action, item.action);
|
||
assert.equal(request.body.source, "hwlab-agent-runtime.m3-io");
|
||
if (item.action === "do.write") {
|
||
assert.equal(request.body.resourceId, "res_boxsimu_1");
|
||
assert.equal(request.body.port, "DO1");
|
||
assert.equal(request.body.value, item.value);
|
||
} else {
|
||
assert.equal(request.body.resourceId, "res_boxsimu_2");
|
||
assert.equal(request.body.port, "DI1");
|
||
}
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
body: {
|
||
serviceId: "hwlab-cloud-api",
|
||
contractVersion: "m3-io-control-v1",
|
||
status: "succeeded",
|
||
accepted: true,
|
||
action: item.action,
|
||
traceId: `trc_m3_stdio_bypass_${item.suffix}`,
|
||
operationId: item.operationId,
|
||
auditId: item.auditId,
|
||
evidenceId: item.evidenceId,
|
||
auditState: {
|
||
status: item.action === "do.write" ? "written_non_durable" : "read"
|
||
},
|
||
evidenceState: {
|
||
status: "blocked",
|
||
sourceKind: "BLOCKED",
|
||
blocker: "runtime_durable_not_green",
|
||
writeStatus: item.action === "do.write" ? "written_non_durable" : "not_written"
|
||
},
|
||
durableStatus: {
|
||
status: "degraded",
|
||
durable: false,
|
||
blocker: "runtime_durable_not_green"
|
||
},
|
||
result: item.action === "do.write"
|
||
? {
|
||
value: item.value,
|
||
targetReadback: {
|
||
status: "succeeded",
|
||
value: item.value,
|
||
resourceId: "res_boxsimu_2",
|
||
port: "DI1"
|
||
}
|
||
}
|
||
: {
|
||
status: "succeeded",
|
||
value: item.value,
|
||
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.runner.kind, "hwlab-m3-io-skill-cli");
|
||
assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli");
|
||
assert.equal(payload.codexStdioFeasibility.skipped, true);
|
||
assert.equal(payload.codexStdioFeasibility.reason, "m3_io_skill_cli_deterministic_route");
|
||
assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready);
|
||
assert.equal(payload.toolCalls.length, 1);
|
||
const skillTool = payload.toolCalls[0];
|
||
assert.equal(skillTool.type, "skill-cli");
|
||
assert.equal(skillTool.name, "hwlab-agent-runtime.m3-io");
|
||
assert.equal(skillTool.status, "completed");
|
||
assert.equal(skillTool.route, HWLAB_M3_IO_API_ROUTE);
|
||
assert.equal(skillTool.method, "POST");
|
||
assert.equal(skillTool.accepted, true);
|
||
assert.equal(skillTool.operationId, item.operationId);
|
||
assert.equal(skillTool.auditId, item.auditId);
|
||
assert.equal(skillTool.evidenceId, item.evidenceId);
|
||
assert.equal(skillTool.readback.value, item.value);
|
||
assert.equal(payload.runnerTrace.route, HWLAB_M3_IO_API_ROUTE);
|
||
assert.equal(payload.runnerTrace.method, "POST");
|
||
assert.equal(payload.providerTrace.fallbackUsed, false);
|
||
assert.equal(payload.providerTrace.readback.value, item.value);
|
||
assert.ok(payload.runner.toolPolicy.allowed.includes(`POST ${HWLAB_M3_IO_API_ROUTE}`));
|
||
assert.equal(JSON.stringify(payload.toolCalls).includes("gateway-simu"), false);
|
||
assert.equal(JSON.stringify(payload.toolCalls).includes("box-simu"), false);
|
||
assert.equal(JSON.stringify(payload.toolCalls).includes("patch-panel"), false);
|
||
}
|
||
|
||
assert.equal(codexStdioCalls.length, 0);
|
||
assert.equal(calls.length, cases.length);
|
||
});
|
||
|
||
test("Code Agent ambiguous M3 IO control request returns structured blocker without Codex stdio", async () => {
|
||
const codexStdioCalls = [];
|
||
const payload = await handleCodeAgentChat(
|
||
{
|
||
conversationId: "cnv_m3_ambiguous_stdio_bypass",
|
||
traceId: "trc_m3_ambiguous_stdio_bypass",
|
||
message: "通过 HWLAB API 控制 M3 IO。"
|
||
},
|
||
{
|
||
now: () => "2026-05-23T00:08:50.000Z",
|
||
codexStdioManager: {
|
||
describe() {
|
||
codexStdioCalls.push("describe");
|
||
throw new Error("ambiguous M3 IO must not inspect Codex stdio");
|
||
},
|
||
async probe() {
|
||
codexStdioCalls.push("probe");
|
||
throw new Error("ambiguous M3 IO must not probe Codex stdio");
|
||
},
|
||
async chat() {
|
||
codexStdioCalls.push("chat");
|
||
throw new Error("ambiguous M3 IO must not enter Codex stdio chat");
|
||
},
|
||
cancel() {
|
||
codexStdioCalls.push("cancel");
|
||
},
|
||
reapIdle() {
|
||
codexStdioCalls.push("reapIdle");
|
||
}
|
||
},
|
||
env: {
|
||
PATH: process.env.PATH,
|
||
OPENAI_API_KEY: "test-openai-key-material",
|
||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
||
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
||
HWLAB_CODE_AGENT_WORKSPACE: process.cwd(),
|
||
HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667"
|
||
},
|
||
callProvider: async () => {
|
||
throw new Error("OpenAI fallback must not be used for ambiguous M3 IO");
|
||
},
|
||
m3IoSkillRequestJson: async () => {
|
||
throw new Error("ambiguous M3 IO must block before any HWLAB API mutation 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.length, 1);
|
||
assert.equal(payload.toolCalls[0].status, "blocked");
|
||
assert.equal(payload.toolCalls[0].route, HWLAB_M3_IO_API_ROUTE);
|
||
assert.equal(payload.toolCalls[0].blocker.code, "m3_io_intent_action_missing");
|
||
assert.equal(payload.toolCalls[0].hwlabApi.source, "intent-validation");
|
||
assert.equal(payload.toolCalls[0].accepted, false);
|
||
assert.equal(payload.toolCalls[0].operationId, null);
|
||
assert.equal(payload.codexStdioFeasibility.skipped, true);
|
||
assert.equal(payload.providerTrace.fallbackUsed, false);
|
||
assert.match(payload.reply.content, /Blocker: m3_io_intent_action_missing/u);
|
||
assert.equal(codexStdioCalls.length, 0);
|
||
});
|
||
|
||
test("Code Agent M3 status uses Skill CLI GET /v1/m3/status and keeps route evidence", async () => {
|
||
const calls = [];
|
||
const payload = await handleCodeAgentChat(
|
||
{
|
||
conversationId: "cnv_m3_status_skill",
|
||
traceId: "trc_m3_status_skill",
|
||
message: "读取 M3 status 聚合状态"
|
||
},
|
||
{
|
||
now: () => "2026-05-23T00:09:10.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 status");
|
||
},
|
||
m3IoSkillRequestJson: async (url, request) => {
|
||
calls.push({ url, request });
|
||
assert.equal(new URL(url).pathname, HWLAB_M3_STATUS_API_ROUTE);
|
||
assert.equal(request.method, "GET");
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
body: {
|
||
status: "live",
|
||
sourceKind: "DEV-LIVE",
|
||
traceId: "trc_m3_status_skill",
|
||
boxes: [{
|
||
id: "boxsimu_2",
|
||
resourceId: "res_boxsimu_2",
|
||
online: true,
|
||
ports: {
|
||
DI1: {
|
||
value: false
|
||
}
|
||
}
|
||
}],
|
||
patchPanel: {
|
||
serviceId: "hwlab-patch-panel",
|
||
observable: true,
|
||
connectionActive: true
|
||
},
|
||
trust: {
|
||
operationId: "op_m3_status_skill",
|
||
traceId: "trc_m3_status_skill",
|
||
auditId: "aud_m3_status_skill",
|
||
evidenceId: "evd_m3_status_skill",
|
||
durableStatus: "green",
|
||
blocker: null,
|
||
readStatus: {
|
||
audit: "read",
|
||
evidence: "read"
|
||
},
|
||
runtime: {
|
||
durable: true
|
||
}
|
||
}
|
||
}
|
||
};
|
||
}
|
||
}
|
||
);
|
||
|
||
validateCodeAgentChatSchema(payload);
|
||
assert.equal(payload.status, "completed");
|
||
assert.equal(payload.provider, "hwlab-skill-cli");
|
||
assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.readonly);
|
||
assert.equal(payload.toolCalls[0].route, HWLAB_M3_STATUS_API_ROUTE);
|
||
assert.equal(payload.toolCalls[0].method, "GET");
|
||
assert.equal(payload.toolCalls[0].accepted, true);
|
||
assert.equal(payload.toolCalls[0].operationId, "op_m3_status_skill");
|
||
assert.equal(payload.toolCalls[0].readback.value, false);
|
||
assert.equal(payload.runnerTrace.route, HWLAB_M3_STATUS_API_ROUTE);
|
||
assert.equal(payload.providerTrace.method, "GET");
|
||
assert.equal(payload.providerTrace.fallbackUsed, false);
|
||
assert.equal(calls.length, 1);
|
||
});
|
||
|
||
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));
|
||
}
|
||
|
||
function escapeRegExp(value) {
|
||
return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||
}
|
||
|
||
async function createFakeCodexCommand() {
|
||
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-fake-codex-"));
|
||
const packageRoot = path.join(root, "node_modules", "@openai", "codex");
|
||
const command = path.join(packageRoot, "bin", "codex.js");
|
||
const native = path.join(root, "node_modules", "@openai", "codex-linux-x64", "vendor", "x86_64-unknown-linux-musl", "codex", "codex");
|
||
await mkdir(path.dirname(command), { recursive: true });
|
||
await mkdir(path.dirname(native), { recursive: true });
|
||
await writeFile(path.join(packageRoot, "package.json"), JSON.stringify({ name: "@openai/codex", version: "0.128.0" }), "utf8");
|
||
await writeFile(command, "#!/usr/bin/env node\nconsole.log('codex 0.128.0');\n", "utf8");
|
||
await writeFile(native, "#!/bin/sh\nexit 0\n", "utf8");
|
||
await chmod(command, 0o755);
|
||
await chmod(native, 0o755);
|
||
return { root, command };
|
||
}
|
||
|
||
async function prepareFakeCodexHome(root = null) {
|
||
const codexHome = root ?? await mkdtemp(path.join(os.tmpdir(), "hwlab-codex-home-"));
|
||
await mkdir(codexHome, { recursive: true });
|
||
await writeFile(path.join(codexHome, "config.toml"), "model = \"gpt-test\"\n", "utf8");
|
||
await writeFile(path.join(codexHome, "auth.json"), "{\"auth\":\"redacted-test-placeholder\"}\n", "utf8");
|
||
return codexHome;
|
||
}
|
||
|
||
function createFakeAppServerClient({ calls, responses = ["first stdio reply", "second stdio reply"], failedTurn = null, retryingTurn = null } = {}) {
|
||
let notificationHandler = null;
|
||
let turn = 0;
|
||
return {
|
||
async initialize() {
|
||
calls?.push({ method: "initialize" });
|
||
return { initialized: true };
|
||
},
|
||
setNotificationHandler(handler) {
|
||
notificationHandler = typeof handler === "function" ? handler : null;
|
||
},
|
||
async startThread(args) {
|
||
calls?.push({ method: "thread/start", args });
|
||
notificationHandler?.({ method: "thread/started", params: { thread: { id: "thread_stdio_ready" } } });
|
||
return { threadId: "thread_stdio_ready" };
|
||
},
|
||
async resumeThread(args) {
|
||
calls?.push({ method: "thread/resume", args });
|
||
notificationHandler?.({ method: "thread/started", params: { thread: { id: args.threadId } } });
|
||
return { threadId: args.threadId };
|
||
},
|
||
async startTurn(args) {
|
||
calls?.push({ method: "turn/start", args });
|
||
turn += 1;
|
||
const turnId = `turn_stdio_${turn}`;
|
||
notificationHandler?.({ method: "turn/started", params: { turn: { id: turnId } } });
|
||
if (retryingTurn && turn === retryingTurn.turn) {
|
||
notificationHandler?.({
|
||
method: "error",
|
||
params: {
|
||
threadId: args.threadId,
|
||
turnId,
|
||
willRetry: true,
|
||
error: { message: retryingTurn.message }
|
||
}
|
||
});
|
||
}
|
||
if (failedTurn && turn === failedTurn.turn) {
|
||
notificationHandler?.({
|
||
method: "turn/completed",
|
||
params: { turn: { id: turnId, status: "failed", error: { message: failedTurn.message } } }
|
||
});
|
||
return { turnId };
|
||
}
|
||
const text = responses[turn - 1] ?? responses.at(-1) ?? "stdio reply";
|
||
notificationHandler?.({ method: "item/agentMessage/delta", params: { itemId: `item_${turn}`, delta: text } });
|
||
notificationHandler?.({ method: "item/completed", params: { item: { id: `item_${turn}`, type: "agentMessage", text } } });
|
||
notificationHandler?.({ method: "turn/completed", params: { turn: { id: turnId, status: "completed" } } });
|
||
return { turnId };
|
||
},
|
||
close() {}
|
||
};
|
||
}
|
||
|
||
function createTimedFakeAppServerClient({ calls, text = "timed stdio reply", eventEveryMs = 20, eventCount = 0, completeAfterMs = null } = {}) {
|
||
let notificationHandler = null;
|
||
let turn = 0;
|
||
const timers = new Set();
|
||
const later = (ms, callback) => {
|
||
const timer = setTimeout(() => {
|
||
timers.delete(timer);
|
||
callback();
|
||
}, ms);
|
||
timers.add(timer);
|
||
};
|
||
return {
|
||
async initialize() {
|
||
calls?.push({ method: "initialize" });
|
||
return { initialized: true };
|
||
},
|
||
setNotificationHandler(handler) {
|
||
notificationHandler = typeof handler === "function" ? handler : null;
|
||
},
|
||
async startThread(args) {
|
||
calls?.push({ method: "thread/start", args });
|
||
notificationHandler?.({ method: "thread/started", params: { thread: { id: "thread_stdio_timed" } } });
|
||
return { threadId: "thread_stdio_timed" };
|
||
},
|
||
async resumeThread(args) {
|
||
calls?.push({ method: "thread/resume", args });
|
||
notificationHandler?.({ method: "thread/started", params: { thread: { id: args.threadId } } });
|
||
return { threadId: args.threadId };
|
||
},
|
||
async startTurn(args) {
|
||
calls?.push({ method: "turn/start", args });
|
||
turn += 1;
|
||
const turnId = `turn_stdio_timed_${turn}`;
|
||
notificationHandler?.({ method: "turn/started", params: { turn: { id: turnId } } });
|
||
for (let index = 1; index <= eventCount; index += 1) {
|
||
later(eventEveryMs * index, () => {
|
||
notificationHandler?.({
|
||
method: "item/commandExecution/outputDelta",
|
||
params: { delta: `progress-${index}` }
|
||
});
|
||
});
|
||
}
|
||
if (Number.isInteger(completeAfterMs)) {
|
||
later(completeAfterMs, () => {
|
||
notificationHandler?.({ method: "item/agentMessage/delta", params: { itemId: `item_${turn}`, delta: text } });
|
||
notificationHandler?.({ method: "item/completed", params: { item: { id: `item_${turn}`, type: "agentMessage", text } } });
|
||
notificationHandler?.({ method: "turn/completed", params: { turn: { id: turnId, status: "completed" } } });
|
||
});
|
||
}
|
||
return { turnId };
|
||
},
|
||
close() {
|
||
for (const timer of timers) clearTimeout(timer);
|
||
timers.clear();
|
||
}
|
||
};
|
||
}
|
||
|
||
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-app-server-stdio");
|
||
assert.equal(blocked.error.code, "codex_cli_binary_missing");
|
||
assert.equal(blocked.error.layer, "runner");
|
||
assert.equal(blocked.error.retryable, false);
|
||
assert.match(blocked.error.userMessage, /Codex CLI|Codex stdio/u);
|
||
assert.equal(blocked.error.blocker.runner, "codex-app-server-stdio-runner");
|
||
assert.ok(blocked.error.blockers.some((blocker) => blocker.code === "codex_cli_binary_missing"));
|
||
assert.ok(blocked.availability.codexStdio.blockerCodes.includes("stdio_protocol_not_wired"));
|
||
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("Codex stdio feasibility verifies writable workspace, CODEX_HOME, and version-probed command", async () => {
|
||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-stdio-workspace-"));
|
||
const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-codex-home-"));
|
||
const fakeCodex = await createFakeCodexCommand();
|
||
try {
|
||
await prepareFakeCodexHome(codexHome);
|
||
const manager = createCodexStdioSessionManager();
|
||
const availability = manager.describe({
|
||
env: {
|
||
PATH: process.env.PATH,
|
||
OPENAI_API_KEY: "test-openai-key-material",
|
||
CODEX_HOME: codexHome,
|
||
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_CODEX_SANDBOX: "workspace-write",
|
||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses"
|
||
}
|
||
});
|
||
|
||
assert.equal(availability.command, fakeCodex.command);
|
||
assert.equal(availability.binary.present, true);
|
||
assert.equal(availability.binary.versionDetected, true);
|
||
assert.equal(availability.workspace, workspace);
|
||
assert.equal(availability.workspaceState.exists, true);
|
||
assert.equal(availability.workspaceState.readable, true);
|
||
assert.equal(availability.workspaceState.writable, true);
|
||
assert.equal(availability.runtimeContract.workspaceMount.status, "ready");
|
||
assert.equal(availability.codexHome, codexHome);
|
||
assert.equal(availability.codexHomeState.exists, true);
|
||
assert.equal(availability.codexHomeState.readable, true);
|
||
assert.equal(availability.codexHomeState.writable, true);
|
||
assert.equal(availability.runtimeContract.codexHome.status, "ready");
|
||
assert.equal(availability.codexHomeFiles.configPresent, true);
|
||
assert.equal(availability.codexHomeFiles.authPresent, true);
|
||
assert.equal(availability.childEnvBoundary.forbiddenEnvPresent, false);
|
||
assert.ok(availability.childEnvBoundary.strippedEnvKeys.includes("OPENAI_API_KEY"));
|
||
assert.ok(availability.childEnvBoundary.strippedEnvKeys.includes("HWLAB_CODE_AGENT_OPENAI_BASE_URL"));
|
||
assert.deepEqual(availability.childEnvBoundary.noProxy.missing, []);
|
||
assert.equal(availability.startupReady, true);
|
||
assert.equal(availability.canStartLongLivedCodexStdio, true);
|
||
assert.equal(availability.ready, false);
|
||
assert.ok(availability.blockerCodes.includes("stdio_protocol_not_wired"));
|
||
assert.equal(availability.blockerCodes.includes("workspace_mount_missing"), false);
|
||
assert.equal(availability.blockerCodes.includes("workspace_write_boundary_blocked"), false);
|
||
assert.equal(availability.blockerCodes.includes("codex_home_missing"), false);
|
||
assert.equal(availability.blockerCodes.includes("codex_home_write_blocked"), false);
|
||
} finally {
|
||
await rm(workspace, { recursive: true, force: true });
|
||
await rm(codexHome, { recursive: true, force: true });
|
||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("Codex app-server args pin repo-owned DEV responses egress without leaking provider env", () => {
|
||
const env = {
|
||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses",
|
||
OPENAI_API_KEY: "test-openai-key-material"
|
||
};
|
||
|
||
assert.equal(codexAppServerProviderBaseUrl(env), "http://172.26.26.227:17680/v1");
|
||
const args = codexAppServerArgs(env);
|
||
assert.deepEqual(args.slice(0, 1), ["app-server"]);
|
||
assert.deepEqual(args.slice(-2), ["--listen", "stdio://"]);
|
||
assert.ok(args.includes("model_provider=\"OpenAI\""));
|
||
assert.ok(args.includes("model_providers.OpenAI.base_url=\"http://172.26.26.227:17680/v1\""));
|
||
assert.ok(args.includes("model_providers.OpenAI.wire_api=\"responses\""));
|
||
assert.equal(args.join(" ").includes("test-openai-key-material"), false);
|
||
assert.equal(args.join(" ").includes("/v1/responses"), false);
|
||
});
|
||
|
||
test("repo-owned Codex stdio manager creates and reuses long-lived sessions with trace evidence", async () => {
|
||
const calls = [];
|
||
const fakeCodex = await createFakeCodexCommand();
|
||
const codexHome = await prepareFakeCodexHome();
|
||
const registry = createCodeAgentSessionRegistry();
|
||
const manager = createCodexStdioSessionManager({
|
||
idFactory: () => "ses_stdio_ready",
|
||
createRpcClient: async () => createFakeAppServerClient({ calls })
|
||
});
|
||
const 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_WORKSPACE: process.cwd(),
|
||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses"
|
||
};
|
||
|
||
const first = await handleCodeAgentChat(
|
||
{
|
||
conversationId: "cnv_stdio_ready",
|
||
traceId: "trc_stdio_ready_1",
|
||
message: "first"
|
||
},
|
||
{
|
||
now: () => "2026-05-23T00:06:00.000Z",
|
||
codexStdioManager: manager,
|
||
sessionRegistry: registry,
|
||
env
|
||
}
|
||
);
|
||
validateCodeAgentChatSchema(first);
|
||
assert.equal(first.status, "completed");
|
||
assert.equal(first.provider, "codex-stdio");
|
||
assert.equal(first.runner.kind, "codex-app-server-stdio-runner");
|
||
assert.equal(first.sessionMode, "codex-app-server-stdio-long-lived");
|
||
assert.equal(first.implementationType, "repo-owned-codex-app-server-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.deepEqual(first.runnerLimitations, ["hardware-control-via-cloud-api-only", "secret-values-redacted"]);
|
||
assert.equal(first.runnerLimitations.includes("not-codex-stdio"), false);
|
||
assert.equal(first.runnerLimitations.includes("not-write-capable"), false);
|
||
assert.equal(first.runnerLimitations.includes("process-local-session-registry"), false);
|
||
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,
|
||
sessionRegistry: registry,
|
||
env
|
||
}
|
||
);
|
||
assert.equal(second.status, "completed");
|
||
assert.equal(second.sessionId, first.sessionId);
|
||
assert.equal(second.conversationId, first.conversationId);
|
||
assert.equal(second.sessionReuse.reused, true);
|
||
assert.equal(second.sessionReuse.turn, 2);
|
||
assert.equal(second.runnerTrace.sessionId, first.sessionId);
|
||
assert.equal(second.runnerTrace.sessionReused, true);
|
||
assert.equal(second.workspace, first.workspace);
|
||
assert.equal(second.sandbox, first.sandbox);
|
||
assert.equal(second.toolCalls[0].name, "codex-app-server.thread/resume+turn/start");
|
||
assert.equal(second.reply.content, "second stdio reply");
|
||
const turnPathCalls = calls.filter((call) => call.method !== "initialize");
|
||
assert.deepEqual(turnPathCalls.map((call) => call.method), ["thread/start", "turn/start", "thread/resume", "turn/start"]);
|
||
assert.equal(turnPathCalls[0].args.cwd, process.cwd());
|
||
assert.equal(turnPathCalls[2].args.threadId, "thread_stdio_ready");
|
||
assert.match(turnPathCalls[3].args.prompt, /Prior session facts/u);
|
||
assert.match(turnPathCalls[3].args.prompt, /ses_stdio_ready/u);
|
||
assert.match(turnPathCalls[3].args.prompt, /trc_stdio_ready_1/u);
|
||
assert.match(turnPathCalls[3].args.prompt, new RegExp(escapeRegExp(process.cwd()), "u"));
|
||
assert.equal(second.providerTrace.protocol, "codex-app-server-jsonrpc-stdio");
|
||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||
await rm(codexHome, { recursive: true, force: true });
|
||
});
|
||
|
||
test("Codex stdio runner startup failure marks session failed and blocks long-lived gate", async () => {
|
||
const fakeCodex = await createFakeCodexCommand();
|
||
const codexHome = await prepareFakeCodexHome();
|
||
const manager = createCodexStdioSessionManager({
|
||
idFactory: () => "ses_stdio_start_failed",
|
||
createProbeRpcClient: async () => ({
|
||
async initialize() {
|
||
return { initialized: true };
|
||
},
|
||
close() {}
|
||
}),
|
||
createRpcClient: async () => ({
|
||
async initialize() {
|
||
throw new Error("app-server initialize failed with sk-test-secret and TOKEN=abc123");
|
||
},
|
||
close() {}
|
||
})
|
||
});
|
||
const 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_WORKSPACE: process.cwd(),
|
||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses"
|
||
};
|
||
|
||
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.sessionMode, "codex-app-server-stdio-long-lived");
|
||
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);
|
||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||
await rm(codexHome, { recursive: true, force: true });
|
||
});
|
||
|
||
test("Codex app-server retrying error notification waits for completed turn", async () => {
|
||
const calls = [];
|
||
const fakeCodex = await createFakeCodexCommand();
|
||
const codexHome = await prepareFakeCodexHome();
|
||
const manager = createCodexStdioSessionManager({
|
||
idFactory: () => "ses_stdio_retrying_turn",
|
||
createRpcClient: async () => createFakeAppServerClient({
|
||
calls,
|
||
responses: ["retry recovered stdio reply"],
|
||
retryingTurn: { turn: 1, message: "Reconnecting... 1/5" }
|
||
})
|
||
});
|
||
const 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_WORKSPACE: process.cwd(),
|
||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses"
|
||
};
|
||
|
||
const payload = await handleCodeAgentChat(
|
||
{
|
||
conversationId: "cnv_stdio_retrying_turn",
|
||
traceId: "trc_stdio_retrying_turn",
|
||
message: "需要长会话"
|
||
},
|
||
{
|
||
now: () => "2026-05-23T00:08:45.000Z",
|
||
codexStdioManager: manager,
|
||
env
|
||
}
|
||
);
|
||
|
||
validateCodeAgentChatSchema(payload);
|
||
assert.equal(payload.status, "completed");
|
||
assert.equal(payload.reply.content, "retry recovered stdio reply");
|
||
assert.equal(payload.session.status, "idle");
|
||
assert.equal(payload.longLivedSessionGate.status, "pass");
|
||
assert.equal(payload.runnerTrace.valuesPrinted, false);
|
||
assert.ok(payload.runnerTrace.events.some((event) => event.label === "app-server:retrying"));
|
||
assert.ok(payload.runnerTrace.events.some((event) => event.label === "turn:completed:completed"));
|
||
assert.equal(payload.runnerTrace.events.some((event) => event.label === "app-server:error"), false);
|
||
assert.deepEqual(calls.filter((call) => call.method !== "initialize").map((call) => call.method), ["thread/start", "turn/start"]);
|
||
assert.equal(classifyCodexRunnerCapability(payload, { httpStatus: 200 }).capabilityPass, true);
|
||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||
await rm(codexHome, { recursive: true, force: true });
|
||
});
|
||
|
||
test("Codex app-server activity timeout does not fail an active turn", async () => {
|
||
const calls = [];
|
||
const fakeCodex = await createFakeCodexCommand();
|
||
const codexHome = await prepareFakeCodexHome();
|
||
const manager = createCodexStdioSessionManager({
|
||
idFactory: () => "ses_stdio_active_turn",
|
||
createRpcClient: async () => createTimedFakeAppServerClient({
|
||
calls,
|
||
text: "active turn completed",
|
||
eventEveryMs: 20,
|
||
eventCount: 5,
|
||
completeAfterMs: 130
|
||
})
|
||
});
|
||
const 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_WORKSPACE: process.cwd(),
|
||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses"
|
||
};
|
||
|
||
try {
|
||
const started = Date.now();
|
||
const payload = await handleCodeAgentChat(
|
||
{
|
||
conversationId: "cnv_stdio_active_turn",
|
||
traceId: "trc_stdio_active_turn",
|
||
message: "需要超过 activity timeout 但持续输出事件"
|
||
},
|
||
{
|
||
now: () => "2026-05-24T00:09:00.000Z",
|
||
codexStdioManager: manager,
|
||
env,
|
||
timeoutMs: 35,
|
||
hardTimeoutMs: 500
|
||
}
|
||
);
|
||
|
||
validateCodeAgentChatSchema(payload);
|
||
assert.equal(payload.status, "completed");
|
||
assert.equal(payload.reply.content, "active turn completed");
|
||
assert.ok(Date.now() - started >= 100);
|
||
assert.ok(payload.runnerTrace.events.some((event) => event.label === "item/commandExecution/outputDelta"));
|
||
assert.equal(payload.runnerTrace.events.some((event) => event.label === "timeout:no_activity"), false);
|
||
assert.equal(payload.error, undefined);
|
||
} finally {
|
||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||
await rm(codexHome, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("Codex app-server activity timeout fails only after no new events", async () => {
|
||
const calls = [];
|
||
const fakeCodex = await createFakeCodexCommand();
|
||
const codexHome = await prepareFakeCodexHome();
|
||
const manager = createCodexStdioSessionManager({
|
||
idFactory: () => "ses_stdio_idle_timeout",
|
||
createRpcClient: async () => createTimedFakeAppServerClient({
|
||
calls,
|
||
eventCount: 0,
|
||
completeAfterMs: null
|
||
})
|
||
});
|
||
const 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_WORKSPACE: process.cwd(),
|
||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses"
|
||
};
|
||
|
||
try {
|
||
const started = Date.now();
|
||
const payload = await handleCodeAgentChat(
|
||
{
|
||
conversationId: "cnv_stdio_idle_timeout",
|
||
traceId: "trc_stdio_idle_timeout",
|
||
message: "停止输出事件后触发 idle timeout"
|
||
},
|
||
{
|
||
now: () => "2026-05-24T00:09:30.000Z",
|
||
codexStdioManager: manager,
|
||
env,
|
||
timeoutMs: 30,
|
||
hardTimeoutMs: 500
|
||
}
|
||
);
|
||
|
||
validateCodeAgentChatSchema(payload);
|
||
assert.equal(payload.status, "timeout");
|
||
assert.equal(payload.error.code, "codex_stdio_idle_timeout");
|
||
assert.match(payload.error.message, /no new events/u);
|
||
assert.equal(payload.error.timeoutMs, 30);
|
||
assert.ok(payload.error.idleMs >= 30);
|
||
assert.match(payload.error.lastActivityAt, /^\d{4}-\d{2}-\d{2}T/u);
|
||
assert.equal(payload.error.waitingFor, "turn/completed");
|
||
assert.ok(Date.now() - started >= 30);
|
||
const timeoutEvent = payload.runnerTrace.events.find((event) => event.label === "timeout:no_activity");
|
||
assert.ok(timeoutEvent);
|
||
assert.equal(timeoutEvent.timeoutMs, 30);
|
||
assert.ok(timeoutEvent.idleMs >= 30);
|
||
assert.match(timeoutEvent.lastActivityAt, /^\d{4}-\d{2}-\d{2}T/u);
|
||
assert.equal(timeoutEvent.waitingFor, "turn/completed");
|
||
assert.equal(payload.runnerTrace.events.some((event) => event.label === "timeout:hard_cap"), false);
|
||
} finally {
|
||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||
await rm(codexHome, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("Codex app-server failed turn is not wrapped as completed assistant output", async () => {
|
||
const calls = [];
|
||
const fakeCodex = await createFakeCodexCommand();
|
||
const codexHome = await prepareFakeCodexHome();
|
||
const manager = createCodexStdioSessionManager({
|
||
idFactory: () => "ses_stdio_failed_turn",
|
||
createRpcClient: async () => createFakeAppServerClient({
|
||
calls,
|
||
failedTurn: { turn: 1, message: "provider network failed with sk-test-secret and TOKEN=abc123" }
|
||
})
|
||
});
|
||
const 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_WORKSPACE: process.cwd(),
|
||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses"
|
||
};
|
||
|
||
const payload = await handleCodeAgentChat(
|
||
{
|
||
conversationId: "cnv_stdio_failed_turn",
|
||
traceId: "trc_stdio_failed_turn",
|
||
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_failed");
|
||
assert.equal(Object.hasOwn(payload, "reply"), false);
|
||
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.error.message.includes("sk-test-secret"), false);
|
||
assert.equal(payload.error.message.includes("TOKEN=abc123"), false);
|
||
assert.deepEqual(calls.filter((call) => call.method !== "initialize").map((call) => call.method), ["thread/start", "turn/start"]);
|
||
assert.equal(classifyCodexRunnerCapability(payload, { httpStatus: 200 }).capabilityPass, false);
|
||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||
await rm(codexHome, { recursive: true, force: true });
|
||
});
|