0d06a4c5fe
- Replace monolithic app.ts / app-conversation.ts / app-device-pod.ts /
app-skills.ts / app-helpers.ts / app-session-tabs.ts / auth.ts and the
364-line index.html with a Vite-built React + TypeScript SPA.
- New src/ module tree: main.tsx, App.tsx, components/{layout,auth,
conversation,command-bar,device-pod,skills,settings,help},
hooks/, services/api, services/auth, state, types, logic/ (pure
helpers from the old tree), styles/.
- Pure-logic modules (composer-policy, code-agent-facts, code-agent-
status, live-status, message-markdown, app-trace, app-session-tabs,
runtime) move to src/logic/ with their tests preserved; tests still
auto-discover.
- Build pipeline switches from Bun.build to Vite: vite.config.ts
inlines everything into a single app.js + app-assets/ for the dist;
scripts/dist-contract.ts now runs vite build and asserts the dist
against a fresh build instead of comparing dist to source.
- scripts/check.ts now verifies the React entry, the mount-only
index.html, Vite dist, the composer / session / device pod / skills
/ settings / help contracts, the React state shape, and the new
test file locations. It also refuses to run if any legacy app-*.ts
or auth.ts survives at the top level.
- New scripts/frontend-guard.ts enforces the 400-line migration target,
refuses the legacy el map and document.getElementById markers,
refuses to keep app-*.ts / auth.ts at the top level, and verifies
the required module directory layout.
- Update scripts/src/dev-cloud-workbench-smoke-lib.mjs to read the new
src/services/, src/logic/ paths and refresh cloudWebModuleSourceFiles.
- index.html is now a mount shell only (no login shell, no workbench
shell, no device pod sidebar markup). React renders the full UI from
src/main.tsx, satisfying the issue-756 migration goal of catching
unclosed tags at the TypeScript/JSX compile step instead of relying
on browser DOM healing.
- web:check (now: check + frontend-guard + tsc-check + bun test) is
green: 51 pass / 0 fail, Vite build produces dist/app.js (379 kB)
+ dist/index.html + dist/app-assets/. Vite build is reproducible;
dist freshness is verified against a fresh Vite build.
- Grandfathered pure-logic files (> 600 lines): src/logic/app-trace.ts
(1367), src/logic/live-status.ts (1006), src/logic/code-agent-facts.ts
(548), src/logic/code-agent-status.ts (572). Reason: each is a tested
pure-logic classifier with a public contract consumed by both the
CLI renderer and the Web renderer; splitting now would fork the
public function shape across two PRs and the migration target is the
React entry + module split, not a pure-logic refactor.
Refs #756. Closes #756 once merged + deployed to hwlab-v02.
Co-authored-by: Codex <codex@local>
361 lines
13 KiB
TypeScript
361 lines
13 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import test from "node:test";
|
||
|
||
import {
|
||
codeAgentAttributionFromMessage,
|
||
codeAgentFactsFromMessage,
|
||
codeAgentRuntimePathFromMessage
|
||
} from "./code-agent-facts.ts";
|
||
|
||
test("codex-readonly-runner is shown as partial read-only session registry", () => {
|
||
const facts = codeAgentFactsFromMessage({
|
||
status: "source",
|
||
provider: "codex-readonly-runner",
|
||
model: "read-only-tools",
|
||
backend: "hwlab-cloud-api/codex-readonly-runner",
|
||
workspace: "/workspace/hwlab",
|
||
sandbox: "read-only",
|
||
capabilityLevel: "read-only-session-tools",
|
||
sessionMode: "controlled-readonly-session-registry",
|
||
implementationType: "controlled-readonly-session-registry",
|
||
session: {
|
||
sessionId: "ses_readonly",
|
||
status: "idle",
|
||
turn: 1,
|
||
lastTraceId: "trc_readonly"
|
||
},
|
||
sessionReuse: {
|
||
reused: false,
|
||
turn: 1,
|
||
status: "idle"
|
||
},
|
||
runner: {
|
||
kind: "hwlab-readonly-runner",
|
||
codexStdio: false,
|
||
writeCapable: false,
|
||
durableSession: false,
|
||
longLivedSession: true
|
||
},
|
||
longLivedSessionGate: {
|
||
status: "blocked",
|
||
pass: false,
|
||
blockers: [{ code: "codex_stdio_blocked_readonly_session_available" }]
|
||
},
|
||
toolCalls: [{ name: "pwd", status: "completed" }],
|
||
skills: { status: "not_requested", count: 0 },
|
||
runnerTrace: {
|
||
traceId: "trc_readonly",
|
||
runnerKind: "hwlab-readonly-runner",
|
||
sessionMode: "controlled-readonly-session-registry",
|
||
turn: 1
|
||
},
|
||
traceId: "trc_readonly"
|
||
});
|
||
|
||
assert.equal(facts.kind, "controlled-readonly-session-registry");
|
||
assert.equal(facts.fullCodeAgent, false);
|
||
assert.equal(facts.codexSessionGatePass, false);
|
||
assert.match(facts.statusLabel, /只读长会话/u);
|
||
assert.match(facts.summary, /可复用/u);
|
||
assert.match(facts.summary, /Codex stdio/u);
|
||
assert.match(facts.rows.find((row) => row.label === "runner/provider/backend").value, /runner=hwlab-readonly-runner/u);
|
||
assert.match(facts.rows.find((row) => row.label === "toolCalls").value, /1\/1 pwd\(completed\)/u);
|
||
});
|
||
|
||
test("long-lived Codex app-server stdio session fields are shown as full Code Agent session", () => {
|
||
const message = {
|
||
status: "completed",
|
||
provider: "codex-stdio",
|
||
model: "gpt-5.5",
|
||
backend: "hwlab-cloud-api/codex-app-server-stdio",
|
||
workspace: "/workspace/hwlab",
|
||
sandbox: "workspace-write",
|
||
capabilityLevel: "long-lived-codex-stdio-session",
|
||
sessionMode: "codex-app-server-stdio-long-lived",
|
||
implementationType: "repo-owned-codex-app-server-stdio-session",
|
||
session: {
|
||
sessionId: "ses_stdio",
|
||
status: "idle",
|
||
turn: 3,
|
||
idleTimeoutMs: 1800000,
|
||
lastTraceId: "trc_stdio",
|
||
longLivedSession: true,
|
||
codexStdio: true
|
||
},
|
||
runner: {
|
||
kind: "codex-app-server-stdio-runner",
|
||
codexStdio: true,
|
||
writeCapable: true,
|
||
durableSession: true
|
||
},
|
||
longLivedSessionGate: {
|
||
status: "pass",
|
||
pass: true
|
||
},
|
||
toolCalls: [{ name: "codex-reply", status: "completed" }],
|
||
skills: { status: "not_requested", count: 0 },
|
||
runnerTrace: {
|
||
traceId: "trc_stdio",
|
||
runnerKind: "codex-app-server-stdio-runner",
|
||
sessionMode: "codex-app-server-stdio-long-lived",
|
||
implementationType: "repo-owned-codex-app-server-stdio-session",
|
||
turn: 3
|
||
},
|
||
providerTrace: {
|
||
transport: "stdio",
|
||
protocol: "codex-app-server-jsonrpc-stdio",
|
||
wireApi: "responses",
|
||
command: "codex app-server --listen stdio://",
|
||
terminalStatus: "completed"
|
||
},
|
||
traceId: "trc_stdio"
|
||
};
|
||
const facts = codeAgentFactsFromMessage(message);
|
||
const runtimePath = codeAgentRuntimePathFromMessage({ role: "agent", ...message });
|
||
|
||
assert.equal(facts.kind, "codex-stdio-long-lived");
|
||
assert.equal(facts.statusLabel, "真实 runner:Codex app-server stdio 长会话");
|
||
assert.equal(facts.fullCodeAgent, true);
|
||
assert.equal(facts.codexSessionGatePass, true);
|
||
assert.match(facts.rows.find((row) => row.label === "sandbox").value, /workspace-write/u);
|
||
assert.equal(runtimePath.kind, "codex-app-server-stdio");
|
||
assert.equal(runtimePath.tone, "ok");
|
||
assert.equal(runtimePath.provider, "codex-stdio");
|
||
assert.equal(runtimePath.runnerKind, "codex-app-server-stdio-runner");
|
||
assert.equal(runtimePath.protocol, "codex-app-server-jsonrpc-stdio");
|
||
assert.equal(runtimePath.implementationType, "repo-owned-codex-app-server-stdio-session");
|
||
assert.equal(runtimePath.command, "codex app-server --listen stdio://");
|
||
assert.equal(runtimePath.terminalStatus, "completed");
|
||
assert.equal(runtimePath.wireApi, "responses");
|
||
assert.match(runtimePath.summary, /Responses wire API/u);
|
||
});
|
||
|
||
test("AgentRun carried Codex app-server session is shown as full Code Agent session", () => {
|
||
const message = {
|
||
status: "completed",
|
||
provider: "codex-stdio",
|
||
model: "deepseek-chat",
|
||
backend: "hwlab-cloud-api/codex-app-server-stdio",
|
||
infrastructureBackend: "agentrun-v01/deepseek",
|
||
workspace: "/home/agentrun/workspace",
|
||
sandbox: "danger-full-access",
|
||
capabilityLevel: "long-lived-codex-stdio-session",
|
||
sessionMode: "codex-app-server-stdio-long-lived",
|
||
implementationType: "repo-owned-codex-app-server-stdio-session",
|
||
session: {
|
||
sessionId: "ses_web_agentrun",
|
||
threadId: "019e8078-db67-7750-a5d9-1a99f3abd445",
|
||
status: "idle",
|
||
lastTraceId: "trc_web_agentrun",
|
||
longLivedSession: true,
|
||
codexStdio: true,
|
||
writeCapable: true,
|
||
durable: true,
|
||
durableSession: true
|
||
},
|
||
sessionReuse: {
|
||
sessionId: "ses_web_agentrun",
|
||
threadId: "019e8078-db67-7750-a5d9-1a99f3abd445",
|
||
reused: true
|
||
},
|
||
runner: {
|
||
kind: "codex-app-server-stdio-runner",
|
||
codexStdio: true,
|
||
writeCapable: true,
|
||
durableSession: true,
|
||
longLivedSession: true
|
||
},
|
||
longLivedSessionGate: {
|
||
status: "pass",
|
||
pass: true
|
||
},
|
||
toolCalls: [{ name: "codex-app-server.turn", status: "completed" }],
|
||
skills: { status: "delegated", count: 0 },
|
||
runnerTrace: {
|
||
traceId: "trc_web_agentrun",
|
||
runnerKind: "codex-app-server-stdio-runner",
|
||
sessionMode: "codex-app-server-stdio-long-lived",
|
||
implementationType: "repo-owned-codex-app-server-stdio-session"
|
||
},
|
||
providerTrace: {
|
||
transport: "stdio",
|
||
protocol: "codex-app-server-jsonrpc-stdio",
|
||
wireApi: "responses",
|
||
command: "codex app-server --listen stdio://",
|
||
terminalStatus: "completed",
|
||
source: "agentrun-v01"
|
||
},
|
||
traceId: "trc_web_agentrun"
|
||
};
|
||
const facts = codeAgentFactsFromMessage(message);
|
||
const runtimePath = codeAgentRuntimePathFromMessage({ role: "agent", ...message });
|
||
|
||
assert.equal(facts.kind, "codex-stdio-long-lived");
|
||
assert.equal(facts.fullCodeAgent, true);
|
||
assert.equal(runtimePath.kind, "codex-app-server-stdio");
|
||
assert.equal(runtimePath.tone, "ok");
|
||
assert.equal(runtimePath.providerTraceMissing, false);
|
||
assert.equal(runtimePath.criticalMissing, false);
|
||
assert.equal(runtimePath.terminalStatusOk, true);
|
||
});
|
||
|
||
test("Codex app-server without providerTrace is degraded with structured missing fields", () => {
|
||
const message = {
|
||
role: "agent",
|
||
status: "completed",
|
||
provider: "codex-stdio",
|
||
backend: "hwlab-cloud-api/codex-app-server-stdio",
|
||
capabilityLevel: "long-lived-codex-stdio-session",
|
||
sessionMode: "codex-app-server-stdio-long-lived",
|
||
implementationType: "repo-owned-codex-app-server-stdio-session",
|
||
runner: { kind: "codex-app-server-stdio-runner" },
|
||
longLivedSessionGate: { status: "pass", pass: true },
|
||
traceId: "trc_missing_provider_trace"
|
||
};
|
||
const facts = codeAgentFactsFromMessage(message);
|
||
const runtimePath = codeAgentRuntimePathFromMessage(message);
|
||
|
||
assert.equal(facts.kind, "codex-stdio-provider-trace-missing");
|
||
assert.equal(facts.fullCodeAgent, false);
|
||
assert.equal(runtimePath.providerTraceMissing, true);
|
||
assert.equal(runtimePath.label, "DEGRADED:providerTrace 缺失");
|
||
assert.deepEqual(runtimePath.missingFields, ["protocol", "providerTrace.command", "providerTrace.terminalStatus"]);
|
||
});
|
||
|
||
test("AgentRun invalid tool-call failure is attributed to Codex provider tool-call JSON", () => {
|
||
const message = {
|
||
role: "agent",
|
||
status: "failed",
|
||
provider: "codex-stdio",
|
||
backend: "hwlab-cloud-api/codex-app-server-stdio",
|
||
capabilityLevel: "long-lived-codex-stdio-session",
|
||
sessionMode: "codex-app-server-stdio-long-lived",
|
||
implementationType: "repo-owned-codex-app-server-stdio-session",
|
||
runner: { kind: "codex-app-server-stdio-runner" },
|
||
providerTrace: {
|
||
transport: "stdio",
|
||
protocol: "codex-app-server-jsonrpc-stdio",
|
||
wireApi: "responses",
|
||
command: "codex app-server --listen stdio://",
|
||
terminalStatus: "failed",
|
||
failureKind: "provider-invalid-tool-call",
|
||
failureMessage: "invalid params, invalid function arguments json string, tool_call_id: call_function_selftest_2 (2013)"
|
||
},
|
||
error: {
|
||
code: "provider-invalid-tool-call",
|
||
category: "provider_invalid_tool_call"
|
||
},
|
||
traceId: "trc_invalid_tool_call"
|
||
};
|
||
const facts = codeAgentFactsFromMessage(message);
|
||
const runtimePath = codeAgentRuntimePathFromMessage(message);
|
||
|
||
assert.equal(facts.kind, "codex-stdio-blocked");
|
||
assert.equal(runtimePath.kind, "codex-app-server-stdio");
|
||
assert.equal(runtimePath.terminalStatusOk, false);
|
||
assert.equal(runtimePath.failureKind, "provider-invalid-tool-call");
|
||
assert.equal(runtimePath.label, "BLOCKED:Codex tool-call JSON 无效");
|
||
assert.match(runtimePath.summary, /不是 HWLAB device-pod/u);
|
||
assert.match(runtimePath.summary, /tool-call arguments JSON/u);
|
||
});
|
||
|
||
test("openai-responses fallback is explicitly text-chat-only", () => {
|
||
const message = {
|
||
status: "source",
|
||
provider: "openai-responses",
|
||
model: "gpt-5.5",
|
||
backend: "hwlab-cloud-api/openai-responses",
|
||
capabilityLevel: "text-chat-only",
|
||
sessionMode: "provider-text-request",
|
||
runner: {
|
||
kind: "openai-responses-fallback",
|
||
codexStdio: false,
|
||
writeCapable: false,
|
||
durableSession: false
|
||
},
|
||
longLivedSessionGate: {
|
||
status: "blocked",
|
||
pass: false,
|
||
blockers: [{ code: "openai_responses_fallback_not_session" }]
|
||
},
|
||
toolCalls: [],
|
||
skills: { status: "not_requested", count: 0 },
|
||
runnerTrace: {
|
||
traceId: "trc_fallback",
|
||
runnerKind: "openai-responses-fallback",
|
||
events: ["fallback:text-chat-only"]
|
||
},
|
||
traceId: "trc_fallback"
|
||
};
|
||
const facts = codeAgentFactsFromMessage(message);
|
||
const attribution = codeAgentAttributionFromMessage(message);
|
||
|
||
assert.equal(facts.kind, "text-chat-only");
|
||
assert.equal(facts.statusLabel, "OpenAI fallback:只是文本回答 / text-chat-only");
|
||
assert.equal(facts.fullCodeAgent, false);
|
||
assert.equal(facts.codexSessionGatePass, false);
|
||
assert.match(facts.summary, /只是文本回答/u);
|
||
assert.match(attribution.label, /只是文本回答/u);
|
||
assert.match(attribution.summary, /当前不能执行工具/u);
|
||
assert.equal(attribution.fields.find((field) => field.key === "runnerKind").value, "openai-responses-fallback / 不是 runner 控制");
|
||
assert.equal(attribution.fields.find((field) => field.key === "capabilityLevel").value, "text-chat-only / 只是文本回答");
|
||
assert.equal(attribution.fields.find((field) => field.key === "toolCalls").value, "0/0 / 无工具调用:只是文本回答");
|
||
});
|
||
|
||
test("stateless one-shot is not wrapped as a long-lived session", () => {
|
||
const facts = codeAgentFactsFromMessage({
|
||
status: "source",
|
||
provider: "codex-cli",
|
||
model: "gpt-5.5",
|
||
backend: "hwlab-cloud-api/codex-cli",
|
||
workspace: "/workspace/hwlab",
|
||
sandbox: "read-only",
|
||
capabilityLevel: "text-chat-only",
|
||
sessionMode: "ephemeral-one-shot",
|
||
implementationType: "codex-cli-one-shot-ephemeral",
|
||
session: {
|
||
sessionId: "cnv_one_shot",
|
||
status: "expired",
|
||
idleTimeoutMs: 0,
|
||
lastTraceId: "trc_one_shot"
|
||
},
|
||
runner: {
|
||
kind: "codex-cli-one-shot-ephemeral",
|
||
codexStdio: false,
|
||
writeCapable: false,
|
||
durableSession: false
|
||
},
|
||
longLivedSessionGate: {
|
||
status: "blocked",
|
||
pass: false,
|
||
blockers: [{ code: "one_shot_runner_not_long_lived" }]
|
||
},
|
||
traceId: "trc_one_shot"
|
||
});
|
||
|
||
assert.equal(facts.kind, "stateless-one-shot");
|
||
assert.equal(facts.fullCodeAgent, false);
|
||
assert.equal(facts.codexSessionGatePass, false);
|
||
assert.match(facts.summary, /一次性 runner/u);
|
||
});
|
||
|
||
test("missing attribution fields render structured evidence insufficiency", () => {
|
||
const attribution = codeAgentAttributionFromMessage({
|
||
role: "agent",
|
||
status: "failed",
|
||
traceId: "trc_missing_fields",
|
||
error: {
|
||
code: "provider_unavailable",
|
||
message: "blocked"
|
||
}
|
||
});
|
||
|
||
assert.equal(attribution.label, "BLOCKED:当前不能执行");
|
||
assert.equal(attribution.fields.find((field) => field.key === "traceId").value, "trc_missing_fields");
|
||
assert.equal(attribution.fields.find((field) => field.key === "provider").missing, true);
|
||
assert.equal(attribution.fields.find((field) => field.key === "backend").value, "字段缺失:证据不足");
|
||
assert.equal(attribution.fields.find((field) => field.key === "runnerKind").chip, "runnerKind=字段缺失:证据不足");
|
||
assert.equal(attribution.fields.find((field) => field.key === "providerTrace.command").missing, true);
|
||
assert.match(attribution.summary, /字段缺失/u);
|
||
});
|