diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 3263641d..2ad7a96b 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -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 = { diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index ee1b1ae5..8efa3068 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -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",