fix: route code agent m3 io through skill cli

This commit is contained in:
Code Queue Review
2026-05-24 09:48:24 +00:00
parent fbf727add8
commit 0a732f4e57
2 changed files with 516 additions and 44 deletions
+460 -5
View File
@@ -144,7 +144,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
status: "accepted",
label: "request:accepted",
promptSummary: message.length > 160 ? `${message.slice(0, 157)}...` : message,
waitingFor: "codex-stdio-readiness"
waitingFor: runnerIntent.kind === "m3_io" ? HWLAB_M3_IO_API_ROUTE : "codex-stdio-readiness"
});
const securityIntent = runnerIntent.kind === "security" ? runnerIntent : null;
@@ -163,7 +163,12 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
backend: CODEX_STDIO_BACKEND,
workspace: options.workspace ?? repoRoot,
sandbox: "workspace-write",
toolCalls: [],
toolCalls: [securityBlockedToolCall({
traceId,
toolName: securityIntent.toolName,
reason: securityIntent.reason,
cwd: options.workspace ?? repoRoot
})],
skills: notRequestedSkills(),
runner: codexStdioBlockedRunnerDescriptor({ workspace: options.workspace, session: null }),
runnerTrace: traceRecorder.runnerTrace({
@@ -188,6 +193,22 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
});
}
if (runnerIntent.kind === "m3_io") {
const runnerResult = await callM3IoSkillRunner({
intent: runnerIntent,
conversationId,
sessionId: requestedSessionId,
traceId,
env: options.env ?? process.env,
now: options.now,
workspace: options.workspace,
sessionRegistry,
requestJson: options.m3IoSkillRequestJson,
traceRecorder
});
return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry });
}
if (runnerIntent.kind === "session_context") {
traceRecorder.append({
type: "session",
@@ -905,6 +926,396 @@ async function inspectReadOnlyRunnerAvailability(env, options = {}) {
};
}
async function callM3IoSkillRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, sessionRegistry, requestJson, traceRecorder }) {
const resolvedWorkspace = resolveRunnerWorkspace(env, { workspace });
const registry = resolveCodeAgentSessionRegistry({ sessionRegistry });
const startedAt = nowIso(now);
const sessionCapabilityLevel = configuredCloudApiBaseUrl(env) && m3IoIntentHasSupportedAction(intent)
? m3SessionCapabilityLevelForIntent(intent)
: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked;
const sessionAcquire = registry.acquire({
conversationId,
sessionId,
workspace: resolvedWorkspace,
sandbox: M3_IO_SKILL_SANDBOX,
runnerKind: M3_IO_SKILL_RUNNER_KIND,
sessionMode: M3_IO_SKILL_SESSION_MODE,
capabilityLevel: sessionCapabilityLevel,
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
traceId,
now
});
const codexStdioFeasibility = skippedCodexStdioFeasibilityForM3Io({ workspace: resolvedWorkspace });
if (!sessionAcquire.ok) {
const blockedSession = sessionAcquire.session;
throw runnerError(sessionAcquire.code, sessionAcquire.message, {
provider: M3_IO_SKILL_PROVIDER,
model: M3_IO_SKILL_MODEL,
backend: M3_IO_SKILL_BACKEND,
workspace: resolvedWorkspace ?? null,
sandbox: M3_IO_SKILL_SANDBOX,
session: blockedSession,
toolCalls: [],
skills: notRequestedSkills(),
runner: m3IoSkillRunnerDescriptor({ workspace: resolvedWorkspace, session: blockedSession }),
runnerTrace: m3IoSkillRunnerTrace({
traceId,
workspace: resolvedWorkspace ?? repoRoot,
session: blockedSession,
events: ["request:accepted", `session:${sessionAcquire.code}`],
startedAt,
outputTruncated: false
}),
capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked,
sessionMode: M3_IO_SKILL_SESSION_MODE,
sessionReuse: blockedSession ? sessionReuseEvidence(blockedSession) : null,
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
runnerLimitations: [...M3_IO_SKILL_LIMITATION_FLAGS],
codexStdioFeasibility,
longLivedSessionGate: longLivedSessionGate({
provider: M3_IO_SKILL_PROVIDER,
runnerKind: M3_IO_SKILL_RUNNER_KIND,
session: blockedSession,
sessionMode: M3_IO_SKILL_SESSION_MODE,
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
codexStdioFeasibility
}),
route: HWLAB_M3_IO_API_ROUTE,
toolName: HWLAB_M3_IO_SKILL_NAME
});
}
let session = sessionAcquire.session;
const requestId = `req_${randomUUID()}`;
const actorId = "usr_code_agent";
traceRecorder?.append({
type: "tool_call",
status: "started",
label: "tool:hwlab-m3-io:started",
toolName: HWLAB_M3_IO_SKILL_NAME,
waitingFor: HWLAB_M3_IO_API_ROUTE
});
const { commandArgs, skillResult } = await executeM3IoSkillIntent({
intent,
traceId,
requestId,
actorId,
env,
now,
requestJson
});
const finishedAt = nowIso(now);
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
const capabilityLevel = skillResult.capabilityLevel ?? (
skillResult.ok ? m3SessionCapabilityLevelForIntent(intent) : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked
);
const route = m3SkillRoute(skillResult);
const method = m3SkillMethod(skillResult);
const directRunnerSeed = {
session,
sessionMode: M3_IO_SKILL_SESSION_MODE,
sessionReuse: sessionReuseEvidence(session),
runner: {
kind: M3_IO_SKILL_RUNNER_KIND,
codexStdio: false,
durableSession: false,
sessionReused: session?.reused ?? false,
turn: session?.turn ?? null
}
};
const m3Io = m3IoStructuredResult({
skillResult,
stdioResult: directRunnerSeed,
commandArgs,
route,
method
});
const toolCall = m3IoSkillToolCall({
skillResult,
commandArgs,
cwd: resolvedWorkspace,
capabilityLevel,
route,
method,
responseType: m3Io.type
});
const events = [
"request:accepted",
"runner:m3-skill-cli:selected",
"tool:skill-cli:started",
`route:${route}`,
`tool:${toolCall.status}`
];
const runnerTrace = m3IoSkillRunnerTrace({
traceId,
events,
startedAt,
finishedAt,
outputTruncated: toolCall.outputTruncated,
workspace: resolvedWorkspace,
session,
skillResult
});
traceRecorder?.append({
type: "tool_call",
status: skillResult.ok ? "completed" : "blocked",
label: `tool:hwlab-m3-io:${skillResult.ok ? "completed" : "blocked"}`,
toolName: HWLAB_M3_IO_SKILL_NAME,
outputSummary: `route=${route}; status=${skillResult.status}; operationId=${skillResult.operationId ?? "null"}`,
terminal: false
});
const blockers = skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []);
const runner = m3IoSkillRunnerDescriptor({ workspace: resolvedWorkspace, session, skillResult });
return {
provider: M3_IO_SKILL_PROVIDER,
model: M3_IO_SKILL_MODEL,
backend: M3_IO_SKILL_BACKEND,
content: m3IoSkillReply(skillResult, m3Io),
responseType: m3Io.type,
m3Io,
workspace: resolvedWorkspace,
sandbox: M3_IO_SKILL_SANDBOX,
session,
sessionMode: M3_IO_SKILL_SESSION_MODE,
sessionReuse: sessionReuseEvidence(session),
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
runnerLimitations: [...M3_IO_SKILL_LIMITATION_FLAGS],
codexStdioFeasibility,
longLivedSessionGate: longLivedSessionGate({
provider: M3_IO_SKILL_PROVIDER,
runnerKind: M3_IO_SKILL_RUNNER_KIND,
session,
sessionMode: M3_IO_SKILL_SESSION_MODE,
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
codexStdioFeasibility
}),
toolCalls: [toolCall],
skills: {
status: "used",
items: [m3IoSkillItem({ capabilityLevel, route })],
count: 1,
blockers
},
runner,
runnerTrace,
capabilityLevel,
blockers,
route,
toolName: HWLAB_M3_IO_SKILL_NAME,
providerTrace: {
...m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel, responseType: m3Io.type }),
runnerKind: M3_IO_SKILL_RUNNER_KIND,
codexStdio: false,
transport: "skill-cli",
fallbackUsed: false
}
};
}
async function executeM3IoSkillIntent({ intent, traceId, requestId, actorId, env, now, requestJson }) {
if (!m3IoIntentHasSupportedAction(intent)) {
const blocker = m3IoIntentActionMissingBlocker({ traceId });
return {
commandArgs: [
"m3",
"io",
"--trace-id",
traceId,
"--request-id",
requestId,
"--actor-id",
actorId
],
skillResult: m3IoSkillBlockedBeforeRequestResult({
traceId,
requestId,
actorId,
blocker,
route: HWLAB_M3_IO_API_ROUTE,
method: "POST",
action: "m3.io",
hwlabApi: {
route: HWLAB_M3_IO_API_ROUTE,
redactedUrl: null,
source: "intent-validation",
baseUrlConfigured: Boolean(configuredCloudApiBaseUrl(env)),
requiredEnv: [...HWLAB_M3_IO_API_BASE_URL_ENVS],
recommendedEnv: HWLAB_M3_IO_API_BASE_URL_ENV,
missingConfig: [],
cloudApiOnly: true,
directGatewayCalls: false,
directBoxCalls: false,
directPatchPanelCalls: false
},
startedAt: nowIso(now),
finishedAt: nowIso(now)
})
};
}
const commandArgs = m3IoSkillArgsForIntent(intent, { env, traceId });
if (!commandArgs.includes("--request-id")) {
commandArgs.push("--request-id", requestId);
}
const skillResult = await runM3IoSkillCommand(commandArgs, {
env,
now,
requestJson
});
return { commandArgs, skillResult };
}
function m3IoIntentHasSupportedAction(intent = {}) {
return ["status", "do.write", "di.read"].includes(intent.action);
}
function skippedCodexStdioFeasibilityForM3Io({ workspace } = {}) {
return {
checked: true,
skipped: true,
reason: "m3_io_skill_cli_deterministic_route",
sourceIssue: "pikasTech/HWLAB#334",
status: "skipped",
ready: false,
canStartLongLivedCodexStdio: false,
commandProbe: {
ready: false,
skipped: true,
reason: "m3_io_skill_cli_deterministic_route"
},
workspace: workspace ?? repoRoot,
sandbox: M3_IO_SKILL_SANDBOX,
blockers: [],
blockerCodes: [],
summary: `M3 IO requests use deterministic Skill CLI -> HWLAB API ${HWLAB_M3_IO_API_ROUTE}; Codex stdio chat is not part of this control decision.`
};
}
function m3IoIntentActionMissingBlocker({ traceId }) {
return {
code: "m3_io_intent_action_missing",
layer: "intent",
category: "needs_confirmation",
retryable: false,
source: "code-agent-m3-skill-cli",
summary: "M3 IO request did not specify status, DI read, or a DO write value.",
message: "M3 IO intent requires an explicit action: m3 status, DI1 read, or DO1 write with true/false.",
zh: "M3 IO 请求缺少明确动作:请指定读取 M3 status、读取 DI1,或把 DO1 写成 true/false。",
userMessage: "M3 IO 请求缺少明确动作,需要指定 status、DI1 读取,或 DO1=true/false 写入。",
traceId,
route: HWLAB_M3_IO_API_ROUTE,
toolName: HWLAB_M3_IO_SKILL_NAME
};
}
function m3IoSkillBlockedBeforeRequestResult({
traceId,
requestId,
actorId,
blocker,
route = HWLAB_M3_IO_API_ROUTE,
method = "POST",
action = "m3.io",
hwlabApi,
startedAt,
finishedAt
}) {
return {
ok: false,
service: HWLAB_M3_IO_SKILL_NAME,
contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION,
route,
method,
hwlabApi,
capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked,
controlReady: false,
action,
accepted: false,
status: "blocked",
traceId,
requestId,
actorId,
operationId: null,
auditId: null,
evidenceId: null,
audit: {
auditId: null,
status: "not_written",
durableStatus: null,
summary: "blocked before HWLAB API request"
},
evidence: {
evidenceId: null,
status: "blocked",
sourceKind: "BLOCKED",
blocker: blocker.code,
writeStatus: "not_written",
summary: "blocked before HWLAB API request"
},
durable: {
status: "blocked",
durable: false,
blocker: blocker.code,
category: blocker.category,
summary: blocker.message
},
blocker,
capabilityBlocker: blocker,
trustBlocker: null,
blockers: [blocker],
readiness: {
status: "blocked",
controlReady: false,
capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked,
route,
blocker,
trustBlocker: null
},
command: null,
result: {
value: null,
targetReadback: null
},
readback: null,
controlPath: {
cloudApi: false,
gatewaySimu: false,
boxSimu: false,
patchPanel: false,
frontendBypass: false
},
safety: {
cloudApiRouteOnly: true,
allowedRoute: route,
directGatewayCalls: false,
directBoxCalls: false,
directPatchPanelCalls: false,
fallbackUsed: false,
openAiFallbackUsed: false
},
httpStatus: 0,
error: {
code: blocker.code,
layer: blocker.layer,
category: blocker.category,
blocker,
retryable: blocker.retryable,
userMessage: blocker.userMessage,
message: blocker.message,
traceId,
route,
toolName: HWLAB_M3_IO_SKILL_NAME
},
rawStatus: null,
startedAt,
finishedAt,
response: null
};
}
async function callCodexStdioRunner({ message, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, model, codexStdioManager, traceRecorder, conversationFacts, externalNetworkIntent = null }) {
const manager = resolveCodexStdioSessionManager({ codexStdioManager });
try {
@@ -1446,6 +1857,7 @@ function m3SkillMethod(skillResult = {}) {
function m3SessionCapabilityLevelForIntent(intent = {}) {
if (intent.action === "status") return HWLAB_M3_IO_CAPABILITY_LEVELS.readonly;
if (!m3IoIntentHasSupportedAction(intent)) return HWLAB_M3_IO_CAPABILITY_LEVELS.blocked;
return HWLAB_M3_IO_CAPABILITY_LEVELS.ready;
}
@@ -1634,6 +2046,7 @@ function detectM3IoIntent(text) {
const wantsWrite = /(?:DO1|数字输出).{0,30}(?:write|set|置|写|打开|关闭|true|false|高|低)|(?:write|set|置|写|打开|关闭).{0,30}(?:DO1|数字输出)/iu.test(normalized) ||
(topologyMentioned && /(?:执行|run|闭环|loop|控制|触发)/iu.test(normalized));
const wantsStatus = /(?:M3).{0,16}(?:status|状态|聚合|health|readiness)|(?:status|状态|聚合|health|readiness).{0,16}(?:M3)/iu.test(normalized);
const wantsM3ControlButMissingAction = /(?:HWLAB API|Skill CLI|M3).{0,32}(?:控制|control|IO)|(?:控制|control).{0,32}(?:HWLAB API|Skill CLI|M3 IO|M3)/iu.test(normalized);
if (wantsStatus && !wantsRead && !wantsWrite) {
return {
kind: "m3_io",
@@ -1642,6 +2055,13 @@ function detectM3IoIntent(text) {
};
}
if (!wantsRead && !wantsWrite) {
if (wantsM3ControlButMissingAction) {
return {
kind: "m3_io",
toolName: HWLAB_M3_IO_SKILL_NAME,
action: null
};
}
return null;
}
@@ -1836,7 +2256,7 @@ function m3IoSkillReply(skillResult, m3Io = null) {
lines.push(`执行路径:Code Agent -> Skill CLI -> HWLAB API ${route};没有使用 OpenAI fallback,也没有由 Code Agent 直连硬件服务。`);
lines.push(`排查字段:traceId=${skillResult.traceId ?? "null"}operationId=${skillResult.operationId ?? "null"}auditId=${skillResult.auditId ?? skillResult.audit?.auditId ?? "null"}evidenceId=${skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? "null"}`);
if (m3Io?.blocker?.code) {
lines.push(`blocker=${m3Io.blocker.code}layer=${m3Io.blocker.layer ?? "unknown"}`);
lines.push(`Blocker: ${m3Io.blocker.code}blocker=${m3Io.blocker.code}layer=${m3Io.blocker.layer ?? "unknown"}`);
}
lines.push(`可信状态:trusted=${m3Io?.trust?.trusted === true ? "true" : "false"}durable=${m3Io?.trust?.durable === true ? "true" : "false"};这不是 M3 DEV-LIVE 验收结论。`);
return boundToolOutput(lines.join("\n"), READONLY_TOOL_OUTPUT_LIMIT).text;
@@ -1959,9 +2379,12 @@ function structuredCompletionBlocker(result, context = {}) {
: null;
if ((result.provider === M3_IO_SKILL_PROVIDER || m3SkillTool || result.m3Io?.type === "m3_io_blocker") && result.capabilityLevel === HWLAB_M3_IO_CAPABILITY_LEVELS.blocked) {
const primary = result.m3Io?.blocker ?? m3SkillTool?.blocker ?? result.skills?.blockers?.[0] ?? result.runnerTrace?.blocker ?? null;
const topCode = m3CompletionBlockerCode(primary);
return structuredBlocker({
code: primary?.code ?? "m3_io_blocker",
layer: primary?.layer ?? (primary?.code === "hwlab_api_unavailable" ? "hwlab-api" : "m3-readiness"),
code: topCode,
layer: topCode === "m3_readiness_blocked"
? "m3-readiness"
: primary?.layer ?? (primary?.code === "hwlab_api_unavailable" ? "hwlab-api" : "m3-readiness"),
message: primary?.message ?? "M3 IO Skill CLI did not reach a ready HWLAB API control path.",
userMessage: primary?.userMessage ?? primary?.zh ?? "M3 控制链路仍受阻,前端应显示为能力未就绪,而不是发送失败。",
retryable: primary?.code === "hwlab_api_unavailable",
@@ -1978,6 +2401,13 @@ function structuredCompletionBlocker(result, context = {}) {
return null;
}
function m3CompletionBlockerCode(primary = {}) {
if (["m3_gateway_session_unavailable", "runtime_durable_not_green", "skill_cli_api_base_missing", "m3_io_intent_action_missing"].includes(primary?.code)) {
return "m3_readiness_blocked";
}
return primary?.code ?? "m3_io_blocker";
}
function sessionReuseEvidence(session) {
return {
conversationId: session.conversationId,
@@ -2042,6 +2472,31 @@ function runnerSafetyContract() {
};
}
function securityBlockedToolCall({ traceId, toolName = "security.boundary", cwd = repoRoot } = {}) {
return {
id: `tool_${randomUUID()}`,
type: "security-policy",
name: toolName,
status: "blocked",
cwd,
exitCode: 1,
stdout: "",
stderrSummary: "security_blocked",
outputTruncated: false,
traceId,
route: null,
blocker: {
code: "security_blocked",
layer: "security",
category: "security_blocked",
retryable: false,
traceId,
toolName,
userMessage: "该请求被安全边界阻断,不能绕过 HWLAB API。"
}
};
}
async function inspectCodexStdioFeasibility(env = process.env, options = {}) {
const manager = resolveCodexStdioSessionManager(options);
const descriptor = {
+56 -39
View File
@@ -2738,7 +2738,7 @@ test("cloud api /v1/agent/chat can answer skills prompt without local skills man
}
});
test("cloud api /v1/agent/chat blocks M3 IO when Codex stdio is unavailable", async () => {
test("cloud api /v1/agent/chat routes M3 IO through Skill CLI even when Codex stdio is unavailable", async () => {
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-skill-"));
const calls = [];
const server = createCloudApiServer({
@@ -2814,13 +2814,19 @@ test("cloud api /v1/agent/chat blocks M3 IO when Codex stdio is unavailable", as
assert.equal(response.status, 200);
const payload = await response.json();
validateCodeAgentChatSchema(payload);
assert.equal(payload.status, "failed");
assert.equal(payload.provider, "codex-stdio");
assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio");
assert.equal(payload.capabilityLevel, "blocked");
assert.deepEqual(payload.toolCalls, []);
assert.equal(Object.hasOwn(payload, "reply"), false);
assert.equal(calls.length, 0);
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.runner.kind, "hwlab-m3-io-skill-cli");
assert.equal(payload.codexStdioFeasibility.skipped, true);
assert.equal(payload.responseType, "m3_io_result");
assert.equal(payload.toolCalls.length, 1);
assert.equal(payload.toolCalls[0].name, "hwlab-agent-runtime.m3-io");
assert.equal(payload.toolCalls[0].route, "/v1/m3/io");
assert.equal(payload.toolCalls[0].accepted, true);
assert.equal(Object.hasOwn(payload, "reply"), true);
assert.equal(calls.length, 1);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
@@ -2934,8 +2940,11 @@ test("cloud api /v1/agent/chat recognizes M3 patch-panel topology text before Co
});
validateCodeAgentChatSchema(payload);
assert.equal(payload.status, "completed");
assert.equal(payload.provider, "codex-stdio");
assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio");
assert.equal(payload.provider, "hwlab-skill-cli");
assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-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.responseType, "m3_io_result");
assert.equal(payload.m3Io.type, "m3_io_result");
assert.equal(payload.m3Io.action, "do.write");
@@ -2945,7 +2954,7 @@ test("cloud api /v1/agent/chat recognizes M3 patch-panel topology text before Co
assert.equal(payload.m3Io.path.summary, "Code Agent -> Skill CLI -> HWLAB API /v1/m3/io");
assert.equal(payload.m3Io.operation.operationId, "op_m3_do_write_topology_skill");
assert.equal(payload.m3Io.trace.traceId, "trc_server-test-m3-topology");
assert.equal(payload.m3Io.session.sessionId, "ses_server_m3_topology_skill");
assert.equal(payload.m3Io.session.sessionId, payload.session.sessionId);
assert.equal(payload.m3Io.trust.trusted, false);
assert.equal(payload.m3Io.trust.durable, false);
const skillTool = payload.toolCalls.find((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io");
@@ -2957,7 +2966,7 @@ test("cloud api /v1/agent/chat recognizes M3 patch-panel topology text before Co
assert.equal(skillTool.traceId, "trc_server-test-m3-topology");
assert.equal(skillTool.readback.value, true);
assert.equal(payload.providerTrace.fallbackUsed, false);
assert.equal(payload.providerTrace.transport, "stdio+skill-cli");
assert.equal(payload.providerTrace.transport, "skill-cli");
assert.ok(payload.runnerTrace.events.includes("tool:skill-cli:started"));
assert.ok(payload.runnerTrace.events.includes("route:/v1/m3/io"));
assert.match(payload.reply.content, /Code Agent -> Skill CLI -> HWLAB API \/v1\/m3\/io/u);
@@ -2967,8 +2976,7 @@ test("cloud api /v1/agent/chat recognizes M3 patch-panel topology text before Co
assert.equal(calls[0].url, "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667/v1/m3/io");
assert.equal(calls[0].request.body.action, "do.write");
assert.equal(calls[0].request.body.value, true);
assert.ok(codexStdioCalls.includes("initialize"));
assert.ok(codexStdioCalls.includes("callTool"));
assert.deepEqual(codexStdioCalls, []);
assert.equal(/https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel)|:7101\b|:7201\b|:7301\b|\/invoke\b|\/sync\/tick\b/iu.test(JSON.stringify(payload.toolCalls)), false);
} finally {
await new Promise((resolve, reject) => {
@@ -3036,7 +3044,11 @@ test("cloud api /v1/agent/chat returns Skill CLI blocker for M3 topology without
});
validateCodeAgentChatSchema(payload);
assert.equal(payload.status, "completed");
assert.equal(payload.provider, "codex-stdio");
assert.equal(payload.provider, "hwlab-skill-cli");
assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-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.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked);
assert.equal(payload.responseType, "m3_io_blocker");
assert.equal(payload.m3Io.type, "m3_io_blocker");
@@ -3057,7 +3069,7 @@ test("cloud api /v1/agent/chat returns Skill CLI blocker for M3 topology without
assert.match(payload.reply.content, /M3 DO1 写入需要 --value true 或 --value false/u);
assert.match(payload.reply.content, /Code Agent -> Skill CLI -> HWLAB API \/v1\/m3\/io/u);
assert.equal(payload.providerTrace.fallbackUsed, false);
assert.equal(payload.providerTrace.transport, "stdio+skill-cli");
assert.equal(payload.providerTrace.transport, "skill-cli");
assert.equal(calls.length, 0);
} finally {
await new Promise((resolve, reject) => {
@@ -3196,7 +3208,7 @@ test("cloud api /v1/agent/chat promotes /v1/m3/io blocker into Chinese M3 IO rep
}
});
test("cloud api /v1/agent/chat routes M3 through Codex stdio then in-process HWLAB API handler", async () => {
test("cloud api /v1/agent/chat routes M3 Skill CLI through in-process HWLAB API handler", async () => {
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-m3-skill-"));
const fakeCodex = await createFakeCodexCommand();
const gatewayCalls = [];
@@ -3258,12 +3270,13 @@ test("cloud api /v1/agent/chat routes M3 through Codex stdio then in-process HWL
validateCodeAgentChatSchema(payload);
assert.equal(payload.status, "completed");
assert.equal(payload.provider, "codex-stdio");
assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio");
assert.equal(payload.runner.kind, "codex-mcp-stdio-runner");
assert.equal(payload.sessionMode, "codex-mcp-stdio-long-lived");
assert.equal(payload.runner.codexStdio, true);
assert.equal(payload.codexStdioFeasibility.ready, true);
assert.equal(payload.provider, "hwlab-skill-cli");
assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-skill-cli");
assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli");
assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli");
assert.equal(payload.runner.codexStdio, false);
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.responseType, "m3_io_result");
assert.equal(payload.m3Io.type, "m3_io_result");
@@ -3271,7 +3284,7 @@ test("cloud api /v1/agent/chat routes M3 through Codex stdio then in-process HWL
assert.equal(payload.m3Io.do1.targetValue, true);
assert.equal(payload.m3Io.di1.observedValue, true);
assert.equal(payload.m3Io.operation.operationId.startsWith("op_m3_do_write_"), true);
assert.equal(payload.m3Io.session.sessionId, "ses_server_stdio_m3_skill");
assert.equal(payload.m3Io.session.sessionId, payload.session.sessionId);
assert.equal(payload.m3Io.path.summary, "Code Agent -> Skill CLI -> HWLAB API /v1/m3/io");
assert.equal(payload.m3Io.trust.trusted, false);
assert.equal(payload.m3Io.trust.durable, false);
@@ -3287,12 +3300,11 @@ test("cloud api /v1/agent/chat routes M3 through Codex stdio then in-process HWL
assert.match(skillTool.auditId, /^aud_m3_do_write_/u);
assert.match(skillTool.evidenceId, /^evd_m3_do_write_/u);
assert.equal(payload.runnerTrace.route, "/v1/m3/io");
assert.ok(payload.runnerTrace.eventLabels.includes("prompt:sent"));
assert.ok(payload.runnerTrace.events.includes("tool:skill-cli:started"));
assert.ok(payload.runnerTrace.events.includes("route:/v1/m3/io"));
assert.equal(payload.providerTrace.fallbackUsed, false);
assert.equal(payload.providerTrace.codexStdio, true);
assert.equal(payload.providerTrace.transport, "stdio+skill-cli");
assert.equal(payload.providerTrace.codexStdio, false);
assert.equal(payload.providerTrace.transport, "skill-cli");
assert.match(payload.reply.content, /HWLAB API \/v1\/m3\/io/u);
assert.match(payload.reply.content, /res_boxsimu_1:DO1=true/u);
assert.match(payload.reply.content, /res_boxsimu_2:DI1=true/u);
@@ -3304,8 +3316,7 @@ test("cloud api /v1/agent/chat routes M3 through Codex stdio then in-process HWL
"/status",
"/invoke"
]);
assert.ok(codexStdioCalls.includes("initialize"));
assert.ok(codexStdioCalls.includes("callTool"));
assert.deepEqual(codexStdioCalls, []);
assert.equal(/https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel)|:7101\b|:7201\b|:7301\b|\/invoke\b|\/sync\/tick\b/iu.test(JSON.stringify(payload.toolCalls)), false);
} finally {
await new Promise((resolve, reject) => {
@@ -3316,7 +3327,7 @@ test("cloud api /v1/agent/chat routes M3 through Codex stdio then in-process HWL
}
});
test("cloud api /v1/agent/chat blocks direct M3 API base targets only after Codex stdio session is ready", async () => {
test("cloud api /v1/agent/chat blocks direct M3 API base targets before Codex stdio", async () => {
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-direct-target-"));
const codexHome = path.join(workspace, "codex-home");
const fakeCodex = await createFakeCodexCommand();
@@ -3373,14 +3384,17 @@ test("cloud api /v1/agent/chat blocks direct M3 API base targets only after Code
});
validateCodeAgentChatSchema(payload);
assert.equal(payload.status, "completed");
assert.equal(payload.provider, "codex-stdio");
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.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked);
assert.equal(payload.responseType, "m3_io_blocker");
assert.equal(payload.m3Io.type, "m3_io_blocker");
assert.equal(payload.m3Io.blocker.code, "direct_hardware_target_blocked");
assert.equal(payload.toolCalls[0].name, "codex");
assert.equal(payload.toolCalls[0].status, "completed");
const skillTool = payload.toolCalls.find((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io");
assert.equal(payload.toolCalls.length, 1);
const skillTool = payload.toolCalls[0];
assert.equal(skillTool.name, "hwlab-agent-runtime.m3-io");
assert.equal(skillTool.status, "blocked");
assert.equal(skillTool.route, "/v1/m3/io");
assert.equal(skillTool.method, "POST");
@@ -3398,7 +3412,7 @@ test("cloud api /v1/agent/chat blocks direct M3 API base targets only after Code
}
});
test("cloud api /v1/agent/chat reports M3 Skill CLI missing API base after Codex stdio session is ready", async () => {
test("cloud api /v1/agent/chat reports M3 Skill CLI missing API base before Codex stdio", async () => {
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-api-base-missing-"));
const codexHome = path.join(workspace, "codex-home");
const fakeCodex = await createFakeCodexCommand();
@@ -3452,15 +3466,18 @@ test("cloud api /v1/agent/chat reports M3 Skill CLI missing API base after Codex
message: "读取 M3 DI1"
});
assert.equal(payload.status, "completed");
assert.equal(payload.provider, "codex-stdio");
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.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked);
assert.equal(payload.responseType, "m3_io_blocker");
assert.equal(payload.m3Io.type, "m3_io_blocker");
assert.equal(payload.m3Io.blocker.code, "skill_cli_api_base_missing");
assert.equal(payload.skills.blockers[0].code, "skill_cli_api_base_missing");
assert.equal(payload.toolCalls[0].name, "codex");
assert.equal(payload.toolCalls[0].status, "completed");
const skillTool = payload.toolCalls.find((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io");
assert.equal(payload.toolCalls.length, 1);
const skillTool = payload.toolCalls[0];
assert.equal(skillTool.name, "hwlab-agent-runtime.m3-io");
assert.equal(skillTool.blocker.code, "skill_cli_api_base_missing");
assert.deepEqual(skillTool.blocker.missingConfig, [
"HWLAB_CODE_AGENT_HWLAB_API_BASE_URL",