feat: gate Code Agent session registry
Implements #317 session-gated read-only runner phase with structured long-lived Codex stdio blockers.
This commit is contained in:
@@ -38,7 +38,7 @@ report、issue、PR 或截图。
|
||||
| 观测结果 | readiness |
|
||||
| --- | --- |
|
||||
| `status: "failed"`,`error.code: "provider_unavailable"`,且 `error.missingEnv` 包含 `OPENAI_API_KEY` | `BLOCKED/credential`;provider 凭证缺失,不能标真实回复通过。 |
|
||||
| `provider: "codex-readonly-runner"` 且 `sessionMode: "controlled-readonly-session-registry"`、`capabilityLevel: "read-only-tools"`、`runnerLimitations` 包含 `not-codex-stdio` / `not-write-capable` / `not-durable-session` | 只能标为 read-only session registry partial pass;不能关闭 #275 的 long-lived Codex stdio/session blocker。 |
|
||||
| `provider: "codex-readonly-runner"` 且 `sessionMode: "controlled-readonly-session-registry"`、`capabilityLevel: "read-only-session-tools"`、`session.status` 为 `idle/ready/busy`、`session.idleTimeoutMs` 和 `session.lastTraceId` 存在、`longLivedSessionGate.status: "blocked"`、`runnerLimitations` 包含 `not-codex-stdio` / `not-write-capable` / `not-durable-session` | 只能标为 #317 read-only session registry partial pass;不能关闭 long-lived Codex stdio/session blocker。 |
|
||||
| `codexStdioFeasibility.status: "blocked"`,或 blocker 包含 `codex_cli_binary_missing`、`runner_lifecycle_missing`、`stdio_protocol_not_wired`、`workspace_mount_missing`、`provider_token_boundary` | 真实 Codex stdio / 等价 long-lived runner 未具备;必须按 blocker 处理,不能表述为完整 Codex session。 |
|
||||
| `status: "completed"`,但来自 mock、fixture、本地 stub、source-only smoke、浏览器本地回显或人工拼接 | 不是 DEV-LIVE reply pass。 |
|
||||
| 真实 DEV `POST /v1/agent/chat` 返回 `status: "completed"`,且 `reply.content` 是非空 assistant 回复 | 可标 DEV-LIVE reply pass。 |
|
||||
@@ -52,8 +52,10 @@ report、issue、PR 或截图。
|
||||
`/v1/agent/chat` 可以先落地受控只读能力,但必须诚实区分:
|
||||
|
||||
- `controlled-readonly-session-registry`:由 cloud-api 进程内 registry 保存
|
||||
`conversationId/sessionId` 映射、`turn` 计数、workspace 与只读工具 trace。它可以覆盖
|
||||
`pwd`、`skills.discover`、`ls`、`rg --files` 和 bounded `cat`,输出必须限长和脱敏。
|
||||
`conversationId/sessionId` 映射、`status`、workspace、sandbox、`createdAt`、`updatedAt`、
|
||||
`idleTimeoutMs`、`lastTraceId`、`turn` 计数与只读工具 trace。它可以覆盖 `pwd`、
|
||||
`skills.discover`、`ls`、`rg --files` 和 bounded `cat`,输出必须限长和脱敏。该模式的
|
||||
`longLivedSessionGate` 必须保持 `blocked`,直到 Codex stdio 或等价长期通道真实接通。
|
||||
- 该模式必须同时标记 `not-codex-stdio`、`not-write-capable`、`not-durable-session`。它不是
|
||||
long-lived Codex stdio session,不提供写文件、任意 shell、硬件写、Secret/kubeconfig/DB URL
|
||||
读取,也不证明 M3/M4/M5 trusted green。
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
codeAgentSecretRefPlaceholder,
|
||||
inspectCodeAgentProviderEnv
|
||||
} from "./code-agent-contract.mjs";
|
||||
import {
|
||||
DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS,
|
||||
createCodeAgentSessionRegistry
|
||||
} from "./code-agent-session-registry.mjs";
|
||||
|
||||
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 120000;
|
||||
const DEFAULT_CODEX_COMMAND = "codex";
|
||||
@@ -23,6 +27,7 @@ const READONLY_RUNNER_KIND = "hwlab-readonly-runner";
|
||||
const READONLY_RUNNER_SANDBOX = "read-only";
|
||||
const READONLY_SESSION_MODE = "controlled-readonly-session-registry";
|
||||
const READONLY_IMPLEMENTATION_TYPE = "controlled-readonly-session-registry";
|
||||
const READONLY_SESSION_CAPABILITY_LEVEL = "read-only-session-tools";
|
||||
const OPENAI_FALLBACK_RUNNER_KIND = "openai-responses-fallback";
|
||||
const CODEX_CLI_ONE_SHOT_RUNNER_KIND = "codex-cli-one-shot-ephemeral";
|
||||
const READONLY_TOOL_OUTPUT_LIMIT = 4000;
|
||||
@@ -58,12 +63,14 @@ const CODE_AGENT_SYSTEM_PROMPT = [
|
||||
].join("\n");
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const readOnlySessions = new Map();
|
||||
const readOnlyConversationSessions = new Map();
|
||||
const defaultCodeAgentSessionRegistry = createCodeAgentSessionRegistry({
|
||||
idleTimeoutMs: DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS,
|
||||
maxSessions: MAX_READONLY_SESSIONS
|
||||
});
|
||||
|
||||
export async function handleCodeAgentChat(params = {}, options = {}) {
|
||||
const timestamp = nowIso(options.now);
|
||||
const { conversationId, sessionId } = resolveConversationSessionIds(params);
|
||||
const { conversationId, sessionId, requestedSessionId } = resolveConversationSessionIds(params);
|
||||
const messageId = `msg_${randomUUID()}`;
|
||||
const traceId = cleanProtocolId(params.traceId, "trc") || `trc_${randomUUID()}`;
|
||||
const providerPlan = resolveProviderPlan(options.env ?? process.env, options);
|
||||
@@ -89,16 +96,18 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
|
||||
intent: runnerIntent,
|
||||
conversationId,
|
||||
traceId,
|
||||
sessionId,
|
||||
sessionId: requestedSessionId,
|
||||
env: options.env ?? process.env,
|
||||
now: options.now,
|
||||
workspace: options.workspace,
|
||||
skillsDirs: options.skillsDirs,
|
||||
skillsDirsExact: options.skillsDirsExact
|
||||
skillsDirsExact: options.skillsDirsExact,
|
||||
sessionRegistry: options.sessionRegistry
|
||||
});
|
||||
const completedAt = nowIso(options.now);
|
||||
return {
|
||||
...base,
|
||||
sessionId: runnerResult.session?.sessionId ?? base.sessionId,
|
||||
status: "completed",
|
||||
updatedAt: completedAt,
|
||||
provider: runnerResult.provider,
|
||||
@@ -106,11 +115,13 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
|
||||
backend: runnerResult.backend,
|
||||
workspace: runnerResult.workspace,
|
||||
sandbox: runnerResult.sandbox,
|
||||
session: runnerResult.session,
|
||||
sessionMode: runnerResult.sessionMode,
|
||||
sessionReuse: runnerResult.sessionReuse,
|
||||
implementationType: runnerResult.implementationType,
|
||||
runnerLimitations: runnerResult.runnerLimitations,
|
||||
codexStdioFeasibility: runnerResult.codexStdioFeasibility,
|
||||
longLivedSessionGate: runnerResult.longLivedSessionGate,
|
||||
toolCalls: runnerResult.toolCalls,
|
||||
skills: runnerResult.skills,
|
||||
runner: runnerResult.runner,
|
||||
@@ -155,6 +166,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
|
||||
backend: providerResult.backend ?? base.backend,
|
||||
workspace: providerResult.workspace ?? null,
|
||||
sandbox: providerResult.sandbox ?? "none",
|
||||
session: providerResult.session ?? null,
|
||||
sessionMode: providerResult.sessionMode ?? "provider-text-request",
|
||||
sessionReuse: providerResult.sessionReuse ?? null,
|
||||
implementationType: providerResult.implementationType ?? OPENAI_FALLBACK_RUNNER_KIND,
|
||||
@@ -165,6 +177,14 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
|
||||
"not-durable-session"
|
||||
],
|
||||
codexStdioFeasibility: providerResult.codexStdioFeasibility ?? inspectCodexStdioFeasibility(envForFeasibility(options.env ?? process.env)),
|
||||
longLivedSessionGate: providerResult.longLivedSessionGate ?? longLivedSessionGate({
|
||||
provider: providerResult.provider ?? base.provider,
|
||||
runnerKind: providerResult.runner?.kind ?? OPENAI_FALLBACK_RUNNER_KIND,
|
||||
session: providerResult.session ?? null,
|
||||
sessionMode: providerResult.sessionMode ?? "provider-text-request",
|
||||
implementationType: providerResult.implementationType ?? OPENAI_FALLBACK_RUNNER_KIND,
|
||||
codexStdioFeasibility: providerResult.codexStdioFeasibility ?? inspectCodexStdioFeasibility(envForFeasibility(options.env ?? process.env))
|
||||
}),
|
||||
toolCalls: Array.isArray(providerResult.toolCalls) ? providerResult.toolCalls : [],
|
||||
skills: providerResult.skills ?? {
|
||||
status: "not_requested",
|
||||
@@ -201,6 +221,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
|
||||
if (error.backend) payload.backend = error.backend;
|
||||
if (error.workspace !== undefined) payload.workspace = error.workspace;
|
||||
if (error.sandbox !== undefined) payload.sandbox = error.sandbox;
|
||||
if (error.session !== undefined) payload.session = error.session;
|
||||
if (error.toolCalls !== undefined) payload.toolCalls = error.toolCalls;
|
||||
if (error.skills !== undefined) payload.skills = error.skills;
|
||||
if (error.runner !== undefined) payload.runner = error.runner;
|
||||
@@ -211,6 +232,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
|
||||
if (error.implementationType !== undefined) payload.implementationType = error.implementationType;
|
||||
if (error.runnerLimitations !== undefined) payload.runnerLimitations = error.runnerLimitations;
|
||||
if (error.codexStdioFeasibility !== undefined) payload.codexStdioFeasibility = error.codexStdioFeasibility;
|
||||
if (error.longLivedSessionGate !== undefined) payload.longLivedSessionGate = error.longLivedSessionGate;
|
||||
if (error.code === "provider_unavailable") {
|
||||
payload.availability = describeCodeAgentAvailability(options.env ?? process.env, options);
|
||||
} else if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "security_blocked"].includes(error.code)) {
|
||||
@@ -266,7 +288,11 @@ export function describeCodeAgentAvailability(env = process.env, options = {}) {
|
||||
const blocked = providerPlan.mode === "openai"
|
||||
? !providerContract.ready
|
||||
: missingEnv.length > 0;
|
||||
const runnerAvailability = inspectReadOnlyRunnerAvailability(env, options);
|
||||
const sessionRegistry = resolveCodeAgentSessionRegistry(options);
|
||||
const runnerAvailability = inspectReadOnlyRunnerAvailability(env, {
|
||||
...options,
|
||||
sessionRegistry
|
||||
});
|
||||
return {
|
||||
endpoint: "POST /v1/agent/chat",
|
||||
provider: providerPlan.provider,
|
||||
@@ -291,11 +317,13 @@ export function describeCodeAgentAvailability(env = process.env, options = {}) {
|
||||
"skills",
|
||||
"runnerTrace",
|
||||
"capabilityLevel",
|
||||
"session",
|
||||
"sessionMode",
|
||||
"sessionReuse",
|
||||
"implementationType",
|
||||
"runnerLimitations",
|
||||
"codexStdioFeasibility",
|
||||
"longLivedSessionGate",
|
||||
"error.message",
|
||||
"error.code",
|
||||
"error.missingEnv",
|
||||
@@ -312,11 +340,13 @@ export function describeCodeAgentAvailability(env = process.env, options = {}) {
|
||||
secretRefs: blocked ? providerContract.secretRefs : [],
|
||||
egress: providerContract.egress,
|
||||
safety: providerContract.safety,
|
||||
capabilityLevel: runnerAvailability.ready ? "read-only-tools" : blocked ? "blocked" : "text-chat-only",
|
||||
capabilityLevel: runnerAvailability.ready ? READONLY_SESSION_CAPABILITY_LEVEL : blocked ? "blocked" : "text-chat-only",
|
||||
sessionRegistry: runnerAvailability.sessionRegistry,
|
||||
sessionMode: runnerAvailability.sessionMode,
|
||||
implementationType: runnerAvailability.implementationType,
|
||||
runnerLimitations: runnerAvailability.runnerLimitations,
|
||||
codexStdioFeasibility: runnerAvailability.codexStdioFeasibility,
|
||||
longLivedSessionGate: runnerAvailability.longLivedSessionGate,
|
||||
ready: !blocked || runnerAvailability.ready
|
||||
};
|
||||
}
|
||||
@@ -381,6 +411,7 @@ function inspectReadOnlyRunnerAvailability(env, options = {}) {
|
||||
const skillsDirs = resolveSkillDirs(env, options);
|
||||
const skillsDirsPresent = skillsDirs.filter((dir) => existsSync(dir));
|
||||
const codexStdioFeasibility = inspectCodexStdioFeasibility(envForFeasibility(env));
|
||||
const sessionRegistry = resolveCodeAgentSessionRegistry(options).describe();
|
||||
return {
|
||||
kind: READONLY_RUNNER_KIND,
|
||||
backend: READONLY_RUNNER_BACKEND,
|
||||
@@ -392,7 +423,7 @@ function inspectReadOnlyRunnerAvailability(env, options = {}) {
|
||||
session: READONLY_SESSION_MODE,
|
||||
status: workspaceReady ? "available" : "blocked",
|
||||
ready: workspaceReady,
|
||||
capabilityLevel: workspaceReady ? "read-only-tools" : "blocked",
|
||||
capabilityLevel: workspaceReady ? READONLY_SESSION_CAPABILITY_LEVEL : "blocked",
|
||||
implementationType: READONLY_IMPLEMENTATION_TYPE,
|
||||
longLivedSession: false,
|
||||
durableSession: false,
|
||||
@@ -400,15 +431,69 @@ function inspectReadOnlyRunnerAvailability(env, options = {}) {
|
||||
writeCapable: false,
|
||||
runnerLimitations: [...READONLY_LIMITATION_FLAGS],
|
||||
codexStdioFeasibility,
|
||||
longLivedSessionGate: longLivedSessionGate({
|
||||
provider: READONLY_RUNNER_PROVIDER,
|
||||
runnerKind: READONLY_RUNNER_KIND,
|
||||
sessionMode: READONLY_SESSION_MODE,
|
||||
implementationType: READONLY_IMPLEMENTATION_TYPE,
|
||||
codexStdioFeasibility
|
||||
}),
|
||||
sessionRegistry,
|
||||
skillsDirs,
|
||||
skillsDirsPresent,
|
||||
safety: runnerSafetyContract()
|
||||
};
|
||||
}
|
||||
|
||||
async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, skillsDirs, skillsDirsExact }) {
|
||||
async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, skillsDirs, skillsDirsExact, sessionRegistry }) {
|
||||
const resolvedWorkspace = resolveRunnerWorkspace(env, { workspace });
|
||||
const session = getOrCreateReadOnlySession({ conversationId, sessionId, workspace: resolvedWorkspace, now });
|
||||
const registry = resolveCodeAgentSessionRegistry({ sessionRegistry });
|
||||
const sessionAcquire = registry.acquire({
|
||||
conversationId,
|
||||
sessionId,
|
||||
workspace: resolvedWorkspace,
|
||||
sandbox: READONLY_RUNNER_SANDBOX,
|
||||
runnerKind: READONLY_RUNNER_KIND,
|
||||
capabilityLevel: READONLY_SESSION_CAPABILITY_LEVEL,
|
||||
implementationType: READONLY_IMPLEMENTATION_TYPE,
|
||||
traceId,
|
||||
now
|
||||
});
|
||||
if (!sessionAcquire.ok) {
|
||||
const blockedSession = sessionAcquire.session;
|
||||
const blockedTrace = runnerTrace({
|
||||
traceId,
|
||||
workspace: resolvedWorkspace ?? repoRoot,
|
||||
session: blockedSession,
|
||||
events: [`blocked:${sessionAcquire.code}`],
|
||||
startedAt: nowIso(now),
|
||||
outputTruncated: false
|
||||
});
|
||||
throw runnerError(sessionAcquire.code, sessionAcquire.message, {
|
||||
workspace: resolvedWorkspace ?? null,
|
||||
session: blockedSession,
|
||||
toolCalls: [],
|
||||
skills: notRequestedSkills(),
|
||||
runner: runnerDescriptor({ workspace: resolvedWorkspace, session: blockedSession }),
|
||||
runnerTrace: blockedTrace,
|
||||
capabilityLevel: "blocked",
|
||||
sessionMode: READONLY_SESSION_MODE,
|
||||
sessionReuse: sessionReuseEvidence(blockedSession),
|
||||
implementationType: READONLY_IMPLEMENTATION_TYPE,
|
||||
runnerLimitations: [...READONLY_LIMITATION_FLAGS],
|
||||
codexStdioFeasibility: inspectCodexStdioFeasibility(envForFeasibility(env)),
|
||||
longLivedSessionGate: longLivedSessionGate({
|
||||
provider: READONLY_RUNNER_PROVIDER,
|
||||
runnerKind: READONLY_RUNNER_KIND,
|
||||
session: blockedSession,
|
||||
sessionMode: READONLY_SESSION_MODE,
|
||||
implementationType: READONLY_IMPLEMENTATION_TYPE,
|
||||
codexStdioFeasibility: inspectCodexStdioFeasibility(envForFeasibility(env))
|
||||
}),
|
||||
blockers: [sessionAcquire.blocker]
|
||||
});
|
||||
}
|
||||
let session = sessionAcquire.session;
|
||||
const runner = runnerDescriptor({ workspace: resolvedWorkspace, session });
|
||||
const startedAt = nowIso(now);
|
||||
const codexStdioFeasibility = inspectCodexStdioFeasibility(envForFeasibility(env));
|
||||
@@ -424,12 +509,28 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId,
|
||||
sessionReuse: sessionReuseEvidence(session),
|
||||
implementationType: READONLY_IMPLEMENTATION_TYPE,
|
||||
runnerLimitations: [...READONLY_LIMITATION_FLAGS],
|
||||
codexStdioFeasibility
|
||||
codexStdioFeasibility,
|
||||
longLivedSessionGate: longLivedSessionGate({
|
||||
provider: READONLY_RUNNER_PROVIDER,
|
||||
runnerKind: READONLY_RUNNER_KIND,
|
||||
session,
|
||||
sessionMode: READONLY_SESSION_MODE,
|
||||
implementationType: READONLY_IMPLEMENTATION_TYPE,
|
||||
codexStdioFeasibility
|
||||
})
|
||||
};
|
||||
|
||||
if (!resolvedWorkspace || !(await pathReadable(resolvedWorkspace))) {
|
||||
session = registry.fail(session.sessionId, {
|
||||
now,
|
||||
traceId,
|
||||
conversationId,
|
||||
reused: session.reused,
|
||||
statusReason: "workspace_unreadable"
|
||||
}) ?? session;
|
||||
throw runnerError("runner_unavailable", `Read-only runner workspace is not readable: ${resolvedWorkspace || "missing"}`, {
|
||||
workspace: resolvedWorkspace ?? null,
|
||||
session,
|
||||
toolCalls: [],
|
||||
skills: notRequestedSkills(),
|
||||
runner,
|
||||
@@ -440,8 +541,16 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId,
|
||||
}
|
||||
|
||||
if (intent.kind === "security") {
|
||||
session = registry.fail(session.sessionId, {
|
||||
now,
|
||||
traceId,
|
||||
conversationId,
|
||||
reused: session.reused,
|
||||
statusReason: "security_blocked"
|
||||
}) ?? session;
|
||||
throw runnerError("security_blocked", intent.reason ?? "The read-only runner blocked a request that could expose secrets or mutate hardware", {
|
||||
workspace: resolvedWorkspace,
|
||||
session,
|
||||
toolCalls: [{
|
||||
id: `tool_${randomUUID()}`,
|
||||
type: "security",
|
||||
@@ -468,6 +577,7 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId,
|
||||
|
||||
if (intent.kind === "pwd") {
|
||||
const toolCall = await runPwdTool({ workspace: resolvedWorkspace, traceId, env });
|
||||
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
|
||||
return readOnlyRunnerResult({
|
||||
content: [
|
||||
"当前受控只读 runner 工作目录:",
|
||||
@@ -490,12 +600,14 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId,
|
||||
if (intent.kind === "ls") {
|
||||
const toolCall = await runLsTool({ workspace: resolvedWorkspace, target: intent.target, traceId });
|
||||
assertReadOnlyToolCompleted(toolCall, {
|
||||
session,
|
||||
workspace: resolvedWorkspace,
|
||||
skills: notRequestedSkills(),
|
||||
runner,
|
||||
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:ls:blocked"], startedAt, outputTruncated: false }),
|
||||
baseEvidence
|
||||
});
|
||||
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
|
||||
return readOnlyRunnerResult({
|
||||
content: [
|
||||
"受控只读 ls 结果:",
|
||||
@@ -518,12 +630,14 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId,
|
||||
if (intent.kind === "rg_files") {
|
||||
const toolCall = await runRgFilesTool({ workspace: resolvedWorkspace, target: intent.target, traceId, env });
|
||||
assertReadOnlyToolCompleted(toolCall, {
|
||||
session,
|
||||
workspace: resolvedWorkspace,
|
||||
skills: notRequestedSkills(),
|
||||
runner,
|
||||
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:rg --files:blocked"], startedAt, outputTruncated: false }),
|
||||
baseEvidence
|
||||
});
|
||||
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
|
||||
return readOnlyRunnerResult({
|
||||
content: [
|
||||
"受控只读 rg --files 结果:",
|
||||
@@ -546,12 +660,14 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId,
|
||||
if (intent.kind === "cat") {
|
||||
const toolCall = await runCatTool({ workspace: resolvedWorkspace, target: intent.target, traceId });
|
||||
assertReadOnlyToolCompleted(toolCall, {
|
||||
session,
|
||||
workspace: resolvedWorkspace,
|
||||
skills: notRequestedSkills(),
|
||||
runner,
|
||||
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:cat:blocked"], startedAt, outputTruncated: false }),
|
||||
baseEvidence
|
||||
});
|
||||
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
|
||||
return readOnlyRunnerResult({
|
||||
content: [
|
||||
"受控只读 cat 结果:",
|
||||
@@ -574,8 +690,16 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId,
|
||||
if (intent.kind === "skills") {
|
||||
const skills = await discoverSkills({ env, skillsDirs, skillsDirsExact, traceId });
|
||||
if (skills.status === "blocked") {
|
||||
session = registry.fail(session.sessionId, {
|
||||
now,
|
||||
traceId,
|
||||
conversationId,
|
||||
reused: session.reused,
|
||||
statusReason: "skills_unavailable"
|
||||
}) ?? session;
|
||||
throw runnerError("skills_unavailable", "No usable SKILL.md manifest was found for the read-only runner", {
|
||||
workspace: resolvedWorkspace,
|
||||
session,
|
||||
toolCalls: [{
|
||||
id: `tool_${randomUUID()}`,
|
||||
type: "file-read",
|
||||
@@ -595,6 +719,7 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId,
|
||||
blockers: skills.blockers
|
||||
});
|
||||
}
|
||||
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
|
||||
return readOnlyRunnerResult({
|
||||
content: skillsReply(skills),
|
||||
workspace: resolvedWorkspace,
|
||||
@@ -618,8 +743,16 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId,
|
||||
});
|
||||
}
|
||||
|
||||
session = registry.fail(session.sessionId, {
|
||||
now,
|
||||
traceId,
|
||||
conversationId,
|
||||
reused: session.reused,
|
||||
statusReason: "tool_unavailable"
|
||||
}) ?? session;
|
||||
throw runnerError("tool_unavailable", `Read-only runner does not expose requested tool: ${intent.toolName ?? "unknown"}`, {
|
||||
workspace: resolvedWorkspace,
|
||||
session,
|
||||
toolCalls: [{
|
||||
id: `tool_${randomUUID()}`,
|
||||
type: "tool",
|
||||
@@ -1214,16 +1347,25 @@ function readOnlyRunnerResult({ content, workspace, toolCalls, skills, runner, r
|
||||
content,
|
||||
workspace,
|
||||
sandbox: READONLY_RUNNER_SANDBOX,
|
||||
session,
|
||||
sessionMode: READONLY_SESSION_MODE,
|
||||
sessionReuse: sessionReuseEvidence(session),
|
||||
implementationType: READONLY_IMPLEMENTATION_TYPE,
|
||||
runnerLimitations: [...READONLY_LIMITATION_FLAGS],
|
||||
codexStdioFeasibility,
|
||||
longLivedSessionGate: longLivedSessionGate({
|
||||
provider: READONLY_RUNNER_PROVIDER,
|
||||
runnerKind: READONLY_RUNNER_KIND,
|
||||
session,
|
||||
sessionMode: READONLY_SESSION_MODE,
|
||||
implementationType: READONLY_IMPLEMENTATION_TYPE,
|
||||
codexStdioFeasibility
|
||||
}),
|
||||
toolCalls,
|
||||
skills,
|
||||
runner,
|
||||
runnerTrace,
|
||||
capabilityLevel: "read-only-tools",
|
||||
capabilityLevel: READONLY_SESSION_CAPABILITY_LEVEL,
|
||||
providerTrace: {
|
||||
runnerKind: READONLY_RUNNER_KIND,
|
||||
toolCalls: toolCalls.length,
|
||||
@@ -1236,42 +1378,6 @@ function readOnlyRunnerResult({ content, workspace, toolCalls, skills, runner, r
|
||||
};
|
||||
}
|
||||
|
||||
function getOrCreateReadOnlySession({ conversationId, sessionId, workspace, now }) {
|
||||
const requestedSessionId = cleanProtocolId(sessionId, "ses") || null;
|
||||
const mappedSessionId = readOnlyConversationSessions.get(conversationId);
|
||||
const effectiveSessionId = requestedSessionId || mappedSessionId || `ses_${randomUUID()}`;
|
||||
const timestamp = nowIso(now);
|
||||
let session = readOnlySessions.get(effectiveSessionId);
|
||||
const reused = Boolean(session);
|
||||
if (!session) {
|
||||
session = {
|
||||
sessionId: effectiveSessionId,
|
||||
conversationIds: new Set(),
|
||||
workspace,
|
||||
createdAt: timestamp,
|
||||
lastUsedAt: timestamp,
|
||||
turn: 0
|
||||
};
|
||||
readOnlySessions.set(effectiveSessionId, session);
|
||||
}
|
||||
session.conversationIds.add(conversationId);
|
||||
session.workspace = workspace;
|
||||
session.lastUsedAt = timestamp;
|
||||
session.turn += 1;
|
||||
readOnlyConversationSessions.set(conversationId, effectiveSessionId);
|
||||
pruneReadOnlySessions();
|
||||
return {
|
||||
sessionId: effectiveSessionId,
|
||||
conversationId,
|
||||
workspace,
|
||||
createdAt: session.createdAt,
|
||||
lastUsedAt: session.lastUsedAt,
|
||||
turn: session.turn,
|
||||
reused,
|
||||
conversationIds: [...session.conversationIds]
|
||||
};
|
||||
}
|
||||
|
||||
function sessionReuseEvidence(session) {
|
||||
return {
|
||||
conversationId: session.conversationId,
|
||||
@@ -1282,32 +1388,41 @@ function sessionReuseEvidence(session) {
|
||||
previousTurns: Math.max(0, session.turn - 1),
|
||||
workspace: session.workspace,
|
||||
createdAt: session.createdAt,
|
||||
lastUsedAt: session.lastUsedAt
|
||||
updatedAt: session.updatedAt,
|
||||
idleTimeoutMs: session.idleTimeoutMs,
|
||||
expiresAt: session.expiresAt,
|
||||
lastTraceId: session.lastTraceId,
|
||||
status: session.status
|
||||
};
|
||||
}
|
||||
|
||||
function pruneReadOnlySessions() {
|
||||
if (readOnlySessions.size <= MAX_READONLY_SESSIONS) return;
|
||||
const sorted = [...readOnlySessions.entries()]
|
||||
.sort((a, b) => String(a[1].lastUsedAt).localeCompare(String(b[1].lastUsedAt)));
|
||||
for (const [sessionId, session] of sorted.slice(0, readOnlySessions.size - MAX_READONLY_SESSIONS)) {
|
||||
readOnlySessions.delete(sessionId);
|
||||
for (const conversationId of session.conversationIds) {
|
||||
if (readOnlyConversationSessions.get(conversationId) === sessionId) {
|
||||
readOnlyConversationSessions.delete(conversationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sessionReplyLine(session) {
|
||||
return `Session registry: mode=${READONLY_SESSION_MODE}; sessionId=${session.sessionId}; reused=${session.reused}; turn=${session.turn}.`;
|
||||
return `Session registry: mode=${READONLY_SESSION_MODE}; sessionId=${session.sessionId}; status=${session.status}; reused=${session.reused}; turn=${session.turn}; idleTimeoutMs=${session.idleTimeoutMs}; lastTraceId=${session.lastTraceId}.`;
|
||||
}
|
||||
|
||||
function limitationReplyLine() {
|
||||
return "边界:controlled-readonly-session-registry;not-codex-stdio;not-write-capable;not-durable-session。";
|
||||
}
|
||||
|
||||
function releaseReadOnlySession(registry, session, { now, traceId, conversationId } = {}) {
|
||||
return registry.release(session.sessionId, {
|
||||
now,
|
||||
traceId,
|
||||
conversationId,
|
||||
reused: session.reused,
|
||||
status: "idle"
|
||||
}) ?? session;
|
||||
}
|
||||
|
||||
function resolveCodeAgentSessionRegistry(options = {}) {
|
||||
return options.sessionRegistry &&
|
||||
typeof options.sessionRegistry.acquire === "function" &&
|
||||
typeof options.sessionRegistry.release === "function" &&
|
||||
typeof options.sessionRegistry.describe === "function"
|
||||
? options.sessionRegistry
|
||||
: defaultCodeAgentSessionRegistry;
|
||||
}
|
||||
|
||||
function runnerDescriptor({ workspace, kind = READONLY_RUNNER_KIND, session = null } = {}) {
|
||||
return {
|
||||
kind,
|
||||
@@ -1326,7 +1441,7 @@ function runnerDescriptor({ workspace, kind = READONLY_RUNNER_KIND, session = nu
|
||||
durableSession: false,
|
||||
writeCapable: false,
|
||||
readOnly: true,
|
||||
capabilityLevel: kind === READONLY_RUNNER_KIND ? "read-only-tools" : "text-chat-only",
|
||||
capabilityLevel: kind === READONLY_RUNNER_KIND ? READONLY_SESSION_CAPABILITY_LEVEL : "text-chat-only",
|
||||
toolPolicy: {
|
||||
allowed: ["pwd", "skills.discover", "ls", "rg --files", "cat"],
|
||||
blocked: ["secret-read", "kubeconfig-read", "hardware-write", "gateway-direct-call", "box-simu-direct-call", "patch-panel-direct-call", "M3/M4/M5-acceptance-claim"]
|
||||
@@ -1344,6 +1459,9 @@ function runnerTrace({ traceId, events, startedAt, outputTruncated, workspace =
|
||||
sandbox: READONLY_RUNNER_SANDBOX,
|
||||
sessionMode: runnerKind === READONLY_RUNNER_KIND ? READONLY_SESSION_MODE : "ephemeral-one-shot",
|
||||
sessionId: session?.sessionId ?? null,
|
||||
sessionStatus: session?.status ?? null,
|
||||
idleTimeoutMs: session?.idleTimeoutMs ?? null,
|
||||
lastTraceId: session?.lastTraceId ?? null,
|
||||
turn: session?.turn ?? null,
|
||||
sessionReused: session?.reused ?? false,
|
||||
implementationType: runnerKind === READONLY_RUNNER_KIND ? READONLY_IMPLEMENTATION_TYPE : "codex-cli-one-shot-ephemeral",
|
||||
@@ -1429,6 +1547,77 @@ function inspectCodexStdioFeasibility(env = process.env) {
|
||||
};
|
||||
}
|
||||
|
||||
function longLivedSessionGate({
|
||||
provider,
|
||||
runnerKind,
|
||||
session,
|
||||
sessionMode,
|
||||
implementationType,
|
||||
codexStdioFeasibility
|
||||
} = {}) {
|
||||
const normalizedProvider = String(provider ?? "").trim();
|
||||
const normalizedRunnerKind = String(runnerKind ?? "").trim();
|
||||
const normalizedSessionMode = String(sessionMode ?? "").trim();
|
||||
const normalizedImplementation = String(implementationType ?? "").trim();
|
||||
const blockers = [];
|
||||
if (normalizedProvider === "openai-responses" || normalizedRunnerKind === OPENAI_FALLBACK_RUNNER_KIND) {
|
||||
blockers.push({
|
||||
code: "openai_responses_fallback_not_session",
|
||||
sourceIssue: "pikasTech/HWLAB#317",
|
||||
summary: "OpenAI Responses fallback is text-chat-only and cannot pass the long-lived Codex session gate."
|
||||
});
|
||||
}
|
||||
if (normalizedRunnerKind === CODEX_CLI_ONE_SHOT_RUNNER_KIND || normalizedSessionMode === "ephemeral-one-shot") {
|
||||
blockers.push({
|
||||
code: "one_shot_runner_not_long_lived",
|
||||
sourceIssue: "pikasTech/HWLAB#317",
|
||||
summary: "codex exec --ephemeral / one-shot runner output is not a reusable long-lived runner session."
|
||||
});
|
||||
}
|
||||
if (normalizedImplementation === READONLY_IMPLEMENTATION_TYPE) {
|
||||
blockers.push({
|
||||
code: "stdio_protocol_not_wired",
|
||||
sourceIssue: "pikasTech/HWLAB#317",
|
||||
summary: "This response is backed by the controlled read-only session registry, not Codex stdio or an equivalent long-lived protocol adapter."
|
||||
});
|
||||
}
|
||||
for (const blocker of codexStdioFeasibility?.blockers ?? []) {
|
||||
if (!blocker?.code || blockers.some((item) => item.code === blocker.code)) continue;
|
||||
blockers.push({
|
||||
code: blocker.code,
|
||||
sourceIssue: blocker.sourceIssue ?? "pikasTech/HWLAB#317",
|
||||
summary: blocker.summary ?? `Codex stdio feasibility blocker: ${blocker.code}.`
|
||||
});
|
||||
}
|
||||
|
||||
const pass =
|
||||
normalizedProvider === READONLY_RUNNER_PROVIDER &&
|
||||
normalizedRunnerKind !== OPENAI_FALLBACK_RUNNER_KIND &&
|
||||
normalizedRunnerKind !== CODEX_CLI_ONE_SHOT_RUNNER_KIND &&
|
||||
session?.longLivedSession === true &&
|
||||
session?.codexStdio === true &&
|
||||
codexStdioFeasibility?.canStartLongLivedCodexStdio === true &&
|
||||
blockers.length === 0;
|
||||
|
||||
return {
|
||||
status: pass ? "pass" : "blocked",
|
||||
pass,
|
||||
requiredCapability: "long-lived-codex-stdio-session",
|
||||
currentCapability: normalizedImplementation || normalizedSessionMode || normalizedRunnerKind || "unknown",
|
||||
sessionId: session?.sessionId ?? null,
|
||||
sessionStatus: session?.status ?? null,
|
||||
runnerKind: normalizedRunnerKind || null,
|
||||
provider: normalizedProvider || null,
|
||||
sessionMode: normalizedSessionMode || null,
|
||||
implementationType: normalizedImplementation || null,
|
||||
blockers,
|
||||
sourceIssue: "pikasTech/HWLAB#317",
|
||||
summary: pass
|
||||
? "Long-lived Codex stdio/session gate passed."
|
||||
: "Long-lived Codex stdio/session gate remains blocked; current response must not be reported as full #317 completion."
|
||||
};
|
||||
}
|
||||
|
||||
function envForFeasibility(env = process.env) {
|
||||
return {
|
||||
PATH: env.PATH || process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
|
||||
@@ -1688,6 +1877,27 @@ async function callCodexCli({ providerPlan, message, conversationId, traceId, ti
|
||||
workspace: repoRoot,
|
||||
sandbox: READONLY_RUNNER_SANDBOX,
|
||||
sessionMode: "ephemeral-one-shot",
|
||||
session: {
|
||||
sessionId: conversationId,
|
||||
conversationId,
|
||||
status: "expired",
|
||||
workspace: repoRoot,
|
||||
sandbox: READONLY_RUNNER_SANDBOX,
|
||||
runnerKind: CODEX_CLI_ONE_SHOT_RUNNER_KIND,
|
||||
capabilityLevel: "text-chat-only",
|
||||
implementationType: "codex-cli-one-shot-ephemeral",
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
idleTimeoutMs: 0,
|
||||
expiresAt: nowIso(),
|
||||
lastTraceId: traceId,
|
||||
turn: 1,
|
||||
longLivedSession: false,
|
||||
codexStdio: false,
|
||||
durable: false,
|
||||
secretMaterialStored: false,
|
||||
valuesRedacted: true
|
||||
},
|
||||
sessionReuse: {
|
||||
conversationId,
|
||||
sessionId: conversationId,
|
||||
@@ -1700,6 +1910,13 @@ async function callCodexCli({ providerPlan, message, conversationId, traceId, ti
|
||||
implementationType: "codex-cli-one-shot-ephemeral",
|
||||
runnerLimitations: ["one-shot", "not-durable-session", "not-controlled-session-registry"],
|
||||
codexStdioFeasibility: inspectCodexStdioFeasibility(envForFeasibility(env)),
|
||||
longLivedSessionGate: longLivedSessionGate({
|
||||
provider: providerPlan.provider,
|
||||
runnerKind: CODEX_CLI_ONE_SHOT_RUNNER_KIND,
|
||||
sessionMode: "ephemeral-one-shot",
|
||||
implementationType: "codex-cli-one-shot-ephemeral",
|
||||
codexStdioFeasibility: inspectCodexStdioFeasibility(envForFeasibility(env))
|
||||
}),
|
||||
toolCalls: [],
|
||||
skills: notRequestedSkills(),
|
||||
runner: runnerDescriptor({ workspace: repoRoot, kind: CODEX_CLI_ONE_SHOT_RUNNER_KIND }),
|
||||
@@ -1966,30 +2183,32 @@ function resolveConversationSessionIds(params = {}) {
|
||||
const requestedConversationId = cleanProtocolId(params.conversationId, "cnv");
|
||||
const requestedSessionId = cleanProtocolId(params.sessionId, "ses");
|
||||
if (requestedConversationId && requestedSessionId) {
|
||||
readOnlyConversationSessions.set(requestedConversationId, requestedSessionId);
|
||||
return {
|
||||
conversationId: requestedConversationId,
|
||||
sessionId: requestedSessionId
|
||||
sessionId: requestedSessionId,
|
||||
requestedSessionId
|
||||
};
|
||||
}
|
||||
if (requestedConversationId) {
|
||||
return {
|
||||
conversationId: requestedConversationId,
|
||||
sessionId: readOnlyConversationSessions.get(requestedConversationId) || requestedConversationId
|
||||
sessionId: requestedConversationId,
|
||||
requestedSessionId: null
|
||||
};
|
||||
}
|
||||
if (requestedSessionId) {
|
||||
const conversationId = `cnv_${requestedSessionId.replace(/^[a-z][a-z0-9]*_/u, "")}`;
|
||||
readOnlyConversationSessions.set(conversationId, requestedSessionId);
|
||||
return {
|
||||
conversationId,
|
||||
sessionId: requestedSessionId
|
||||
sessionId: requestedSessionId,
|
||||
requestedSessionId
|
||||
};
|
||||
}
|
||||
const conversationId = `cnv_${randomUUID()}`;
|
||||
return {
|
||||
conversationId,
|
||||
sessionId: conversationId
|
||||
sessionId: conversationId,
|
||||
requestedSessionId: null
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export const DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
export const DEFAULT_CODE_AGENT_MAX_SESSIONS = 200;
|
||||
export const CODE_AGENT_SESSION_STATUSES = Object.freeze([
|
||||
"creating",
|
||||
"ready",
|
||||
"busy",
|
||||
"idle",
|
||||
"interrupted",
|
||||
"expired",
|
||||
"failed"
|
||||
]);
|
||||
|
||||
export function createCodeAgentSessionRegistry(options = {}) {
|
||||
const idleTimeoutMs = positiveInteger(options.idleTimeoutMs, DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS);
|
||||
const maxSessions = positiveInteger(options.maxSessions, DEFAULT_CODE_AGENT_MAX_SESSIONS);
|
||||
const idFactory = typeof options.idFactory === "function" ? options.idFactory : () => `ses_${randomUUID()}`;
|
||||
const defaultNow = options.now;
|
||||
const sessions = new Map();
|
||||
const conversations = new Map();
|
||||
|
||||
function acquire(params = {}) {
|
||||
const timestamp = timestampFor(params.now ?? defaultNow);
|
||||
const timestampMs = Date.parse(timestamp);
|
||||
const conversationId = requiredId(params.conversationId, "cnv");
|
||||
const requestedSessionId = optionalId(params.sessionId);
|
||||
const mappedSessionId = conversations.get(conversationId) ?? null;
|
||||
|
||||
if (mappedSessionId && requestedSessionId && mappedSessionId !== requestedSessionId) {
|
||||
const mappedSession = sessions.get(mappedSessionId) ?? null;
|
||||
return blockedAcquire({
|
||||
code: "session_reuse_conflict",
|
||||
summary: `conversationId ${conversationId} is already bound to sessionId ${mappedSessionId}; requested ${requestedSessionId}.`,
|
||||
session: mappedSession,
|
||||
timestamp,
|
||||
traceId: params.traceId
|
||||
});
|
||||
}
|
||||
|
||||
const effectiveSessionId = mappedSessionId || requestedSessionId || requiredId(idFactory(), "ses");
|
||||
let session = sessions.get(effectiveSessionId) ?? null;
|
||||
const reused = Boolean(session);
|
||||
|
||||
if (session && sessionExpired(session, timestampMs)) {
|
||||
session.status = "expired";
|
||||
session.updatedAt = timestamp;
|
||||
session.lastTraceId = optionalId(params.traceId);
|
||||
return blockedAcquire({
|
||||
code: "session_expired",
|
||||
summary: `Code Agent runner session ${effectiveSessionId} expired after ${session.idleTimeoutMs}ms idle timeout.`,
|
||||
session,
|
||||
timestamp,
|
||||
traceId: params.traceId
|
||||
});
|
||||
}
|
||||
|
||||
if (session?.status === "busy") {
|
||||
session.updatedAt = timestamp;
|
||||
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
|
||||
return blockedAcquire({
|
||||
code: "session_busy",
|
||||
summary: `Code Agent runner session ${effectiveSessionId} is already busy with traceId ${session.currentTraceId ?? "unknown"}.`,
|
||||
session,
|
||||
timestamp,
|
||||
traceId: params.traceId
|
||||
});
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
session = {
|
||||
sessionId: effectiveSessionId,
|
||||
conversationIds: new Set(),
|
||||
status: "creating",
|
||||
workspace: params.workspace ?? null,
|
||||
sandbox: params.sandbox ?? "read-only",
|
||||
runnerKind: params.runnerKind ?? "hwlab-readonly-runner",
|
||||
capabilityLevel: params.capabilityLevel ?? "read-only-session-tools",
|
||||
implementationType: params.implementationType ?? "controlled-readonly-session-registry",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
idleTimeoutMs,
|
||||
expiresAt: plusMs(timestampMs, idleTimeoutMs),
|
||||
lastTraceId: optionalId(params.traceId),
|
||||
currentTraceId: null,
|
||||
turn: 0,
|
||||
durable: false,
|
||||
longLivedSession: false,
|
||||
codexStdio: false,
|
||||
secretMaterialStored: false
|
||||
};
|
||||
sessions.set(effectiveSessionId, session);
|
||||
}
|
||||
|
||||
session.conversationIds.add(conversationId);
|
||||
session.workspace = params.workspace ?? session.workspace;
|
||||
session.sandbox = params.sandbox ?? session.sandbox;
|
||||
session.runnerKind = params.runnerKind ?? session.runnerKind;
|
||||
session.capabilityLevel = params.capabilityLevel ?? session.capabilityLevel;
|
||||
session.implementationType = params.implementationType ?? session.implementationType;
|
||||
session.status = "busy";
|
||||
session.updatedAt = timestamp;
|
||||
session.expiresAt = plusMs(timestampMs, idleTimeoutMs);
|
||||
session.lastTraceId = optionalId(params.traceId);
|
||||
session.currentTraceId = optionalId(params.traceId);
|
||||
session.turn += 1;
|
||||
conversations.set(conversationId, effectiveSessionId);
|
||||
prune();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
reused,
|
||||
session: publicSession(session, { conversationId, reused, timestamp })
|
||||
};
|
||||
}
|
||||
|
||||
function release(sessionId, params = {}) {
|
||||
const session = sessions.get(requiredId(sessionId, "ses")) ?? null;
|
||||
if (!session) return null;
|
||||
const timestamp = timestampFor(params.now ?? defaultNow);
|
||||
const timestampMs = Date.parse(timestamp);
|
||||
session.status = CODE_AGENT_SESSION_STATUSES.includes(params.status) ? params.status : "idle";
|
||||
session.updatedAt = timestamp;
|
||||
session.expiresAt = plusMs(timestampMs, session.idleTimeoutMs);
|
||||
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
|
||||
session.currentTraceId = null;
|
||||
session.statusReason = params.statusReason ?? null;
|
||||
return publicSession(session, {
|
||||
conversationId: params.conversationId,
|
||||
reused: params.reused,
|
||||
timestamp
|
||||
});
|
||||
}
|
||||
|
||||
function fail(sessionId, params = {}) {
|
||||
return release(sessionId, { ...params, status: "failed" });
|
||||
}
|
||||
|
||||
function interrupt(sessionId, params = {}) {
|
||||
return release(sessionId, { ...params, status: "interrupted" });
|
||||
}
|
||||
|
||||
function get(sessionId, params = {}) {
|
||||
const session = sessions.get(requiredId(sessionId, "ses")) ?? null;
|
||||
return session ? publicSession(session, params) : null;
|
||||
}
|
||||
|
||||
function getByConversation(conversationId, params = {}) {
|
||||
const sessionId = conversations.get(requiredId(conversationId, "cnv"));
|
||||
return sessionId ? get(sessionId, { ...params, conversationId }) : null;
|
||||
}
|
||||
|
||||
function expireIdle(params = {}) {
|
||||
const timestamp = timestampFor(params.now ?? defaultNow);
|
||||
const timestampMs = Date.parse(timestamp);
|
||||
let expired = 0;
|
||||
for (const session of sessions.values()) {
|
||||
if (!sessionExpired(session, timestampMs)) continue;
|
||||
session.status = "expired";
|
||||
session.updatedAt = timestamp;
|
||||
session.currentTraceId = null;
|
||||
expired += 1;
|
||||
}
|
||||
return expired;
|
||||
}
|
||||
|
||||
function describe() {
|
||||
return {
|
||||
kind: "code-agent-session-registry",
|
||||
implementationType: "controlled-readonly-session-registry",
|
||||
status: "available",
|
||||
sessionCount: sessions.size,
|
||||
maxSessions,
|
||||
idleTimeoutMs,
|
||||
statuses: [...CODE_AGENT_SESSION_STATUSES],
|
||||
longLivedCodexStdio: false,
|
||||
durable: false,
|
||||
secretMaterialStored: false,
|
||||
valuesRedacted: true
|
||||
};
|
||||
}
|
||||
|
||||
function clear() {
|
||||
sessions.clear();
|
||||
conversations.clear();
|
||||
}
|
||||
|
||||
function prune() {
|
||||
if (sessions.size <= maxSessions) return;
|
||||
const sorted = [...sessions.entries()]
|
||||
.sort((a, b) => String(a[1].updatedAt).localeCompare(String(b[1].updatedAt)));
|
||||
for (const [sessionId, session] of sorted.slice(0, sessions.size - maxSessions)) {
|
||||
sessions.delete(sessionId);
|
||||
for (const conversationId of session.conversationIds) {
|
||||
if (conversations.get(conversationId) === sessionId) {
|
||||
conversations.delete(conversationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
acquire,
|
||||
release,
|
||||
fail,
|
||||
interrupt,
|
||||
get,
|
||||
getByConversation,
|
||||
expireIdle,
|
||||
describe,
|
||||
clear
|
||||
};
|
||||
}
|
||||
|
||||
function blockedAcquire({ code, summary, session, timestamp, traceId }) {
|
||||
const publicEvidence = session
|
||||
? publicSession(session, { timestamp, reused: true })
|
||||
: {
|
||||
status: "failed",
|
||||
updatedAt: timestamp,
|
||||
lastTraceId: optionalId(traceId),
|
||||
secretMaterialStored: false,
|
||||
valuesRedacted: true
|
||||
};
|
||||
return {
|
||||
ok: false,
|
||||
code,
|
||||
message: summary,
|
||||
session: publicEvidence,
|
||||
blocker: {
|
||||
code,
|
||||
sourceIssue: "pikasTech/HWLAB#317",
|
||||
summary
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function publicSession(session, { conversationId = null, reused = null } = {}) {
|
||||
const conversationIds = [...session.conversationIds];
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
conversationId: conversationId ?? conversationIds[0] ?? null,
|
||||
conversationIds,
|
||||
status: session.status,
|
||||
workspace: session.workspace,
|
||||
sandbox: session.sandbox,
|
||||
runnerKind: session.runnerKind,
|
||||
capabilityLevel: session.capabilityLevel,
|
||||
implementationType: session.implementationType,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
idleTimeoutMs: session.idleTimeoutMs,
|
||||
expiresAt: session.expiresAt,
|
||||
lastTraceId: session.lastTraceId,
|
||||
currentTraceId: session.currentTraceId,
|
||||
turn: session.turn,
|
||||
reused: reused === null ? undefined : Boolean(reused),
|
||||
durable: session.durable === true,
|
||||
longLivedSession: session.longLivedSession === true,
|
||||
codexStdio: session.codexStdio === true,
|
||||
secretMaterialStored: false,
|
||||
valuesRedacted: true,
|
||||
...(session.statusReason ? { statusReason: session.statusReason } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function sessionExpired(session, timestampMs) {
|
||||
if (!Number.isFinite(timestampMs) || session.status === "expired") return session.status === "expired";
|
||||
return Date.parse(session.expiresAt) <= timestampMs;
|
||||
}
|
||||
|
||||
function timestampFor(now) {
|
||||
const value = typeof now === "function" ? now() : now;
|
||||
if (typeof value === "string" && !Number.isNaN(Date.parse(value))) return value;
|
||||
if (value instanceof Date && !Number.isNaN(value.valueOf())) return value.toISOString();
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function plusMs(timestampMs, offsetMs) {
|
||||
return new Date(timestampMs + offsetMs).toISOString();
|
||||
}
|
||||
|
||||
function requiredId(value, fallbackPrefix) {
|
||||
const id = optionalId(value);
|
||||
if (id) return id;
|
||||
return `${fallbackPrefix}_${randomUUID()}`;
|
||||
}
|
||||
|
||||
function optionalId(value) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function positiveInteger(value, fallback) {
|
||||
return Number.isInteger(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createCodeAgentSessionRegistry } from "./code-agent-session-registry.mjs";
|
||||
import { handleCodeAgentChat, validateCodeAgentChatSchema } from "./code-agent-chat.mjs";
|
||||
import {
|
||||
classifyCodexRunnerCapability,
|
||||
classifyCodeAgentChatReadiness
|
||||
} from "../../scripts/src/code-agent-response-contract.mjs";
|
||||
|
||||
test("code agent session registry creates, reuses, and expires sessions without storing secrets", () => {
|
||||
const registry = createCodeAgentSessionRegistry({
|
||||
idleTimeoutMs: 1000,
|
||||
idFactory: () => "ses_test-registry"
|
||||
});
|
||||
const first = registry.acquire({
|
||||
conversationId: "cnv_registry",
|
||||
workspace: "/workspace/hwlab",
|
||||
sandbox: "read-only",
|
||||
runnerKind: "hwlab-readonly-runner",
|
||||
capabilityLevel: "read-only-session-tools",
|
||||
traceId: "trc_registry_1",
|
||||
now: "2026-05-23T00:00:00.000Z"
|
||||
});
|
||||
assert.equal(first.ok, true);
|
||||
assert.equal(first.reused, false);
|
||||
assert.equal(first.session.sessionId, "ses_test-registry");
|
||||
assert.equal(first.session.status, "busy");
|
||||
assert.equal(first.session.idleTimeoutMs, 1000);
|
||||
assert.equal(first.session.lastTraceId, "trc_registry_1");
|
||||
assert.equal(first.session.secretMaterialStored, false);
|
||||
assert.equal(first.session.valuesRedacted, true);
|
||||
registry.release(first.session.sessionId, {
|
||||
conversationId: "cnv_registry",
|
||||
traceId: "trc_registry_1",
|
||||
reused: first.session.reused,
|
||||
now: "2026-05-23T00:00:00.010Z"
|
||||
});
|
||||
|
||||
const second = registry.acquire({
|
||||
conversationId: "cnv_registry",
|
||||
workspace: "/workspace/hwlab",
|
||||
sandbox: "read-only",
|
||||
runnerKind: "hwlab-readonly-runner",
|
||||
capabilityLevel: "read-only-session-tools",
|
||||
traceId: "trc_registry_2",
|
||||
now: "2026-05-23T00:00:00.500Z"
|
||||
});
|
||||
assert.equal(second.ok, true);
|
||||
assert.equal(second.reused, true);
|
||||
assert.equal(second.session.sessionId, "ses_test-registry");
|
||||
assert.equal(second.session.turn, 2);
|
||||
assert.equal(second.session.lastTraceId, "trc_registry_2");
|
||||
registry.release(second.session.sessionId, {
|
||||
conversationId: "cnv_registry",
|
||||
traceId: "trc_registry_2",
|
||||
reused: second.session.reused,
|
||||
now: "2026-05-23T00:00:00.510Z"
|
||||
});
|
||||
|
||||
const expired = registry.acquire({
|
||||
conversationId: "cnv_registry",
|
||||
workspace: "/workspace/hwlab",
|
||||
sandbox: "read-only",
|
||||
runnerKind: "hwlab-readonly-runner",
|
||||
capabilityLevel: "read-only-session-tools",
|
||||
traceId: "trc_registry_expired",
|
||||
now: "2026-05-23T00:00:02.000Z"
|
||||
});
|
||||
assert.equal(expired.ok, false);
|
||||
assert.equal(expired.code, "session_expired");
|
||||
assert.equal(expired.session.status, "expired");
|
||||
assert.equal(expired.blocker.sourceIssue, "pikasTech/HWLAB#317");
|
||||
});
|
||||
|
||||
test("code agent session registry reports busy sessions as structured blocker", () => {
|
||||
const registry = createCodeAgentSessionRegistry({
|
||||
idFactory: () => "ses_busy"
|
||||
});
|
||||
const first = registry.acquire({
|
||||
conversationId: "cnv_busy",
|
||||
workspace: "/workspace/hwlab",
|
||||
sandbox: "read-only",
|
||||
runnerKind: "hwlab-readonly-runner",
|
||||
capabilityLevel: "read-only-session-tools",
|
||||
traceId: "trc_busy_1",
|
||||
now: "2026-05-23T00:00:00.000Z"
|
||||
});
|
||||
assert.equal(first.ok, true);
|
||||
const busy = registry.acquire({
|
||||
conversationId: "cnv_busy",
|
||||
workspace: "/workspace/hwlab",
|
||||
sandbox: "read-only",
|
||||
runnerKind: "hwlab-readonly-runner",
|
||||
capabilityLevel: "read-only-session-tools",
|
||||
traceId: "trc_busy_2",
|
||||
now: "2026-05-23T00:00:01.000Z"
|
||||
});
|
||||
assert.equal(busy.ok, false);
|
||||
assert.equal(busy.code, "session_busy");
|
||||
assert.equal(busy.session.status, "busy");
|
||||
assert.equal(busy.session.currentTraceId, "trc_busy_1");
|
||||
});
|
||||
|
||||
test("read-only runner returns session registry evidence and blocked long-lived gate", async () => {
|
||||
const registry = createCodeAgentSessionRegistry({
|
||||
idFactory: () => "ses_chat_registry"
|
||||
});
|
||||
const first = await handleCodeAgentChat(
|
||||
{
|
||||
conversationId: "cnv_chat_registry",
|
||||
traceId: "trc_chat_registry_pwd",
|
||||
message: "pwd"
|
||||
},
|
||||
{
|
||||
now: () => "2026-05-23T00:01:00.000Z",
|
||||
sessionRegistry: registry,
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
|
||||
}
|
||||
}
|
||||
);
|
||||
validateCodeAgentChatSchema(first);
|
||||
assert.equal(first.status, "completed");
|
||||
assert.equal(first.sessionId, "ses_chat_registry");
|
||||
assert.equal(first.session.sessionId, "ses_chat_registry");
|
||||
assert.equal(first.session.status, "idle");
|
||||
assert.equal(first.session.workspace, process.cwd());
|
||||
assert.equal(first.session.lastTraceId, "trc_chat_registry_pwd");
|
||||
assert.equal(first.capabilityLevel, "read-only-session-tools");
|
||||
assert.equal(first.longLivedSessionGate.status, "blocked");
|
||||
assert.equal(first.longLivedSessionGate.pass, false);
|
||||
assert.ok(first.longLivedSessionGate.blockers.some((blocker) => blocker.code === "stdio_protocol_not_wired"));
|
||||
|
||||
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, true);
|
||||
});
|
||||
|
||||
test("expired and busy session states are surfaced as structured blockers", async () => {
|
||||
const expiredRegistry = createCodeAgentSessionRegistry({
|
||||
idleTimeoutMs: 10,
|
||||
idFactory: () => "ses_expired_chat"
|
||||
});
|
||||
const first = expiredRegistry.acquire({
|
||||
conversationId: "cnv_expired_chat",
|
||||
workspace: process.cwd(),
|
||||
sandbox: "read-only",
|
||||
runnerKind: "hwlab-readonly-runner",
|
||||
capabilityLevel: "read-only-session-tools",
|
||||
traceId: "trc_expired_seed",
|
||||
now: "2026-05-23T00:02:00.000Z"
|
||||
});
|
||||
expiredRegistry.release(first.session.sessionId, {
|
||||
conversationId: "cnv_expired_chat",
|
||||
traceId: "trc_expired_seed",
|
||||
now: "2026-05-23T00:02:00.001Z"
|
||||
});
|
||||
const expired = await handleCodeAgentChat(
|
||||
{
|
||||
conversationId: "cnv_expired_chat",
|
||||
traceId: "trc_expired_request",
|
||||
message: "pwd"
|
||||
},
|
||||
{
|
||||
now: () => "2026-05-23T00:02:01.000Z",
|
||||
sessionRegistry: expiredRegistry,
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
|
||||
}
|
||||
}
|
||||
);
|
||||
assert.equal(expired.status, "failed");
|
||||
assert.equal(expired.error.code, "session_expired");
|
||||
assert.equal(expired.session.status, "expired");
|
||||
assert.equal(expired.capabilityLevel, "blocked");
|
||||
assert.equal(expired.longLivedSessionGate.status, "blocked");
|
||||
|
||||
const busyRegistry = createCodeAgentSessionRegistry({
|
||||
idFactory: () => "ses_busy_chat"
|
||||
});
|
||||
busyRegistry.acquire({
|
||||
conversationId: "cnv_busy_chat",
|
||||
workspace: process.cwd(),
|
||||
sandbox: "read-only",
|
||||
runnerKind: "hwlab-readonly-runner",
|
||||
capabilityLevel: "read-only-session-tools",
|
||||
traceId: "trc_busy_seed",
|
||||
now: "2026-05-23T00:03:00.000Z"
|
||||
});
|
||||
const busy = await handleCodeAgentChat(
|
||||
{
|
||||
conversationId: "cnv_busy_chat",
|
||||
traceId: "trc_busy_request",
|
||||
message: "pwd"
|
||||
},
|
||||
{
|
||||
now: () => "2026-05-23T00:03:01.000Z",
|
||||
sessionRegistry: busyRegistry,
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
|
||||
}
|
||||
}
|
||||
);
|
||||
assert.equal(busy.status, "failed");
|
||||
assert.equal(busy.error.code, "session_busy");
|
||||
assert.equal(busy.session.status, "busy");
|
||||
assert.equal(busy.capabilityLevel, "blocked");
|
||||
assert.equal(busy.error.blockers[0].sourceIssue, "pikasTech/HWLAB#317");
|
||||
});
|
||||
|
||||
test("OpenAI fallback and codex one-shot do not pass the long-lived session gate", async () => {
|
||||
const fallback = await handleCodeAgentChat(
|
||||
{
|
||||
conversationId: "cnv_fallback_gate",
|
||||
traceId: "trc_fallback_gate",
|
||||
message: "你好"
|
||||
},
|
||||
{
|
||||
now: () => "2026-05-23T00:04:00.000Z",
|
||||
callProvider: async ({ providerPlan }) => ({
|
||||
provider: providerPlan.provider,
|
||||
model: providerPlan.model,
|
||||
backend: providerPlan.backend,
|
||||
content: "普通文本回复。",
|
||||
usage: null,
|
||||
providerTrace: {
|
||||
source: "test-provider"
|
||||
}
|
||||
})
|
||||
}
|
||||
);
|
||||
assert.equal(fallback.status, "completed");
|
||||
assert.equal(fallback.runner.kind, "openai-responses-fallback");
|
||||
assert.equal(fallback.capabilityLevel, "text-chat-only");
|
||||
assert.equal(fallback.longLivedSessionGate.status, "blocked");
|
||||
assert.ok(fallback.longLivedSessionGate.blockers.some((blocker) => blocker.code === "openai_responses_fallback_not_session"));
|
||||
assert.equal(classifyCodexRunnerCapability(fallback, { httpStatus: 200 }).capabilityPass, false);
|
||||
|
||||
const oneShot = {
|
||||
...fallback,
|
||||
provider: "codex-cli",
|
||||
backend: "hwlab-cloud-api/codex-cli",
|
||||
runner: {
|
||||
kind: "codex-cli-one-shot-ephemeral",
|
||||
codexStdio: false,
|
||||
writeCapable: false,
|
||||
durableSession: false
|
||||
},
|
||||
sessionMode: "ephemeral-one-shot",
|
||||
implementationType: "codex-cli-one-shot-ephemeral",
|
||||
longLivedSessionGate: {
|
||||
status: "blocked",
|
||||
pass: false,
|
||||
blockers: [{ code: "one_shot_runner_not_long_lived", sourceIssue: "pikasTech/HWLAB#317" }]
|
||||
}
|
||||
};
|
||||
assert.equal(classifyCodexRunnerCapability(oneShot, { httpStatus: 200 }).capabilityPass, false);
|
||||
assert.equal(classifyCodeAgentChatReadiness(oneShot, { realDevLive: true, httpStatus: 200 }).devLiveReplyPass, true);
|
||||
});
|
||||
@@ -343,7 +343,8 @@ async function handleCodeAgentChatHttp(request, response, options) {
|
||||
env: options.env,
|
||||
workspace: options.workspace,
|
||||
skillsDirs: options.skillsDirs,
|
||||
skillsDirsExact: options.skillsDirsExact
|
||||
skillsDirsExact: options.skillsDirsExact,
|
||||
sessionRegistry: options.sessionRegistry
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -73,7 +73,10 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => {
|
||||
assert.equal(healthPayload.codeAgent.reason, null);
|
||||
assert.equal(healthPayload.codeAgent.runner.kind, "hwlab-readonly-runner");
|
||||
assert.equal(healthPayload.codeAgent.runner.ready, true);
|
||||
assert.equal(healthPayload.codeAgent.capabilityLevel, "read-only-tools");
|
||||
assert.equal(healthPayload.codeAgent.capabilityLevel, "read-only-session-tools");
|
||||
assert.equal(healthPayload.codeAgent.sessionRegistry.status, "available");
|
||||
assert.equal(healthPayload.codeAgent.longLivedSessionGate.status, "blocked");
|
||||
assert.ok(healthPayload.codeAgent.longLivedSessionGate.blockers.some((blocker) => blocker.code === "stdio_protocol_not_wired"));
|
||||
assert.deepEqual(healthPayload.codeAgent.missingEnv, ["OPENAI_API_KEY"]);
|
||||
assert.equal(healthPayload.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider");
|
||||
assert.equal(healthPayload.codeAgent.secretRefs[0].secretKey, "openai-api-key");
|
||||
@@ -94,7 +97,7 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => {
|
||||
assert.equal(healthLivePayload.db.ready, false);
|
||||
assert.equal(healthLivePayload.codeAgent.status, "available");
|
||||
assert.equal(healthLivePayload.codeAgent.ready, true);
|
||||
assert.equal(healthLivePayload.codeAgent.capabilityLevel, "read-only-tools");
|
||||
assert.equal(healthLivePayload.codeAgent.capabilityLevel, "read-only-session-tools");
|
||||
|
||||
const readiness = await fetch(`http://127.0.0.1:${port}/health/live`);
|
||||
assert.equal(readiness.status, 200);
|
||||
@@ -848,7 +851,9 @@ test("cloud api /v1 describes Code Agent provider blocker without leaking secret
|
||||
assert.equal(payload.codeAgent.reason, null);
|
||||
assert.equal(payload.codeAgent.runner.kind, "hwlab-readonly-runner");
|
||||
assert.equal(payload.codeAgent.runner.ready, true);
|
||||
assert.equal(payload.codeAgent.capabilityLevel, "read-only-tools");
|
||||
assert.equal(payload.codeAgent.capabilityLevel, "read-only-session-tools");
|
||||
assert.equal(payload.codeAgent.sessionRegistry.status, "available");
|
||||
assert.equal(payload.codeAgent.longLivedSessionGate.status, "blocked");
|
||||
assert.deepEqual(payload.codeAgent.missingEnv, ["OPENAI_API_KEY", "HWLAB_CODE_AGENT_OPENAI_BASE_URL"]);
|
||||
assert.equal(payload.codeAgent.secretRefs[0].env, "OPENAI_API_KEY");
|
||||
assert.equal(payload.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider");
|
||||
@@ -978,9 +983,14 @@ test("cloud api /v1/agent/chat runs read-only runner pwd with workspace evidence
|
||||
assert.equal(payload.status, "completed");
|
||||
assert.equal(payload.provider, "codex-readonly-runner");
|
||||
assert.equal(payload.backend, "hwlab-cloud-api/codex-readonly-runner");
|
||||
assert.equal(payload.capabilityLevel, "read-only-tools");
|
||||
assert.equal(payload.capabilityLevel, "read-only-session-tools");
|
||||
assert.equal(payload.workspace, workspace);
|
||||
assert.equal(payload.sandbox, "read-only");
|
||||
assert.equal(payload.session.status, "idle");
|
||||
assert.equal(payload.session.workspace, workspace);
|
||||
assert.equal(payload.session.sandbox, "read-only");
|
||||
assert.equal(payload.session.lastTraceId, "trc_server-test-runner-pwd");
|
||||
assert.equal(typeof payload.session.idleTimeoutMs, "number");
|
||||
assert.equal(payload.runner.kind, "hwlab-readonly-runner");
|
||||
assert.equal(payload.runner.longLivedSession, false);
|
||||
assert.equal(payload.runner.codexStdio, false);
|
||||
@@ -991,8 +1001,11 @@ test("cloud api /v1/agent/chat runs read-only runner pwd with workspace evidence
|
||||
assert.equal(payload.implementationType, "controlled-readonly-session-registry");
|
||||
assert.equal(payload.sessionReuse.reused, false);
|
||||
assert.equal(payload.sessionReuse.turn, 1);
|
||||
assert.equal(payload.sessionReuse.status, "idle");
|
||||
assert.ok(payload.runnerLimitations.includes("not-codex-stdio"));
|
||||
assert.ok(payload.codexStdioFeasibility.blockers.some((blocker) => blocker.code === "runner_lifecycle_missing"));
|
||||
assert.equal(payload.longLivedSessionGate.status, "blocked");
|
||||
assert.ok(payload.longLivedSessionGate.blockers.some((blocker) => blocker.code === "stdio_protocol_not_wired"));
|
||||
assert.equal(payload.toolCalls[0].name, "pwd");
|
||||
assert.equal(payload.toolCalls[0].status, "completed");
|
||||
assert.equal(payload.toolCalls[0].stdout, workspace);
|
||||
@@ -1135,7 +1148,7 @@ test("cloud api /v1/agent/chat discovers skills manifest with source and version
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.status, "completed");
|
||||
assert.equal(payload.provider, "codex-readonly-runner");
|
||||
assert.equal(payload.capabilityLevel, "read-only-tools");
|
||||
assert.equal(payload.capabilityLevel, "read-only-session-tools");
|
||||
assert.equal(payload.skills.status, "ready");
|
||||
assert.equal(payload.skills.items[0].name, "alpha-skill");
|
||||
assert.equal(payload.skills.items[0].summary, "Alpha skill summary.");
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -410,13 +410,15 @@
|
||||
},
|
||||
{
|
||||
"id": "live-gate-route",
|
||||
"status": "pass",
|
||||
"summary": "Direct /gate route opens the internal diagnostics view without becoming the default workbench.",
|
||||
"status": "blocked",
|
||||
"summary": "Direct /gate route was observed in an older DOM-only report, but the checked-in observations do not include the current single-table gate contract fields; kept BLOCKED instead of inferring DEV-LIVE gate readiness.",
|
||||
"evidence": [
|
||||
"pathname=/gate",
|
||||
"gateHidden=false",
|
||||
"diagnosticsTermsPresent=true",
|
||||
"codeAgentPostAttempts=0"
|
||||
"codeAgentPostAttempts=0",
|
||||
"single-table gate observations missing",
|
||||
"kept blocked to avoid false green"
|
||||
],
|
||||
"observations": {
|
||||
"pathname": "/gate",
|
||||
@@ -432,7 +434,8 @@
|
||||
"attemptedCount": 0,
|
||||
"attempts": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"blocker": "observability_blocker"
|
||||
},
|
||||
{
|
||||
"id": "live-help-markdown-route",
|
||||
@@ -517,6 +520,12 @@
|
||||
"scope": "live-browser-dom",
|
||||
"status": "open",
|
||||
"summary": "Browser DOM did not satisfy the workbench contract."
|
||||
},
|
||||
{
|
||||
"type": "observability_blocker",
|
||||
"scope": "live-gate-route",
|
||||
"status": "open",
|
||||
"summary": "Checked-in DOM-only /gate observations are missing current single-table contract fields; rerun DOM-only smoke after DEV rollout for fresh evidence."
|
||||
}
|
||||
],
|
||||
"safety": {
|
||||
|
||||
@@ -179,7 +179,12 @@ async function runLocalContractSmoke() {
|
||||
assert.equal(runnerPwd.provider, "codex-readonly-runner");
|
||||
assert.equal(runnerPwd.workspace, process.cwd());
|
||||
assert.equal(runnerPwd.sandbox, "read-only");
|
||||
assert.equal(runnerPwd.capabilityLevel, "read-only-tools");
|
||||
assert.equal(runnerPwd.capabilityLevel, "read-only-session-tools");
|
||||
assert.equal(runnerPwd.session.status, "idle");
|
||||
assert.equal(runnerPwd.session.workspace, process.cwd());
|
||||
assert.equal(runnerPwd.session.sandbox, "read-only");
|
||||
assert.equal(runnerPwd.session.idleTimeoutMs, 1800000);
|
||||
assert.equal(runnerPwd.session.lastTraceId, "trc_code-agent-chat-runner-pwd");
|
||||
assert.equal(runnerPwd.sessionMode, "controlled-readonly-session-registry");
|
||||
assert.equal(runnerPwd.implementationType, "controlled-readonly-session-registry");
|
||||
assert.equal(runnerPwd.runner.session, "controlled-readonly-session-registry");
|
||||
@@ -188,13 +193,18 @@ async function runLocalContractSmoke() {
|
||||
assert.equal(runnerPwd.runner.durableSession, false);
|
||||
assert.equal(runnerPwd.sessionReuse.reused, false);
|
||||
assert.equal(runnerPwd.sessionReuse.turn, 1);
|
||||
assert.equal(runnerPwd.sessionReuse.status, "idle");
|
||||
assert.ok(runnerPwd.runnerLimitations.includes("not-codex-stdio"));
|
||||
assert.equal(runnerPwd.codexStdioFeasibility.status, "blocked");
|
||||
assert.equal(runnerPwd.longLivedSessionGate.status, "blocked");
|
||||
assert.equal(runnerPwd.longLivedSessionGate.pass, false);
|
||||
assert.ok(runnerPwd.longLivedSessionGate.blockers.some((blocker) => blocker.code === "stdio_protocol_not_wired"));
|
||||
assert.equal(runnerPwd.toolCalls[0].name, "pwd");
|
||||
assert.equal(runnerPwd.toolCalls[0].status, "completed");
|
||||
const runnerCapability = classifyCodexRunnerCapability(runnerPwd, { httpStatus: 200 });
|
||||
assert.equal(runnerCapability.status, "pass");
|
||||
assert.equal(runnerCapability.capabilityPass, true);
|
||||
assert.equal(runnerCapability.longLivedSession, false);
|
||||
logOk("read-only runner pwd capability");
|
||||
|
||||
const runnerSecondTurn = await handleCodeAgentChat(
|
||||
@@ -214,8 +224,10 @@ async function runLocalContractSmoke() {
|
||||
validateCodeAgentChatSchema(runnerSecondTurn);
|
||||
assert.equal(runnerSecondTurn.status, "completed");
|
||||
assert.equal(runnerSecondTurn.sessionId, runnerPwd.sessionId);
|
||||
assert.equal(runnerSecondTurn.session.sessionId, runnerPwd.session.sessionId);
|
||||
assert.equal(runnerSecondTurn.sessionReuse.reused, true);
|
||||
assert.equal(runnerSecondTurn.sessionReuse.turn, 2);
|
||||
assert.equal(runnerSecondTurn.session.lastTraceId, "trc_code-agent-chat-runner-session-reuse");
|
||||
assert.equal(runnerSecondTurn.toolCalls[0].name, "ls");
|
||||
assert.equal(runnerSecondTurn.toolCalls[0].status, "completed");
|
||||
assert.match(runnerSecondTurn.reply.content, /package\.json/u);
|
||||
@@ -292,7 +304,8 @@ async function runLocalContractSmoke() {
|
||||
assert.equal(boundedRgFiles.status, "completed");
|
||||
assert.equal(boundedRgFiles.toolCalls[0].name, "rg --files");
|
||||
assert.equal(boundedRgFiles.toolCalls[0].status, "completed");
|
||||
assert.match(boundedRgFiles.reply.content, /package\.json/u);
|
||||
assert.ok(boundedRgFiles.toolCalls[0].stdout.length <= 4100);
|
||||
assert.match(boundedRgFiles.reply.content, /受控只读 rg --files 结果/u);
|
||||
logOk("bounded rg --files read-only tool capability");
|
||||
|
||||
const toolUnavailable = await handleCodeAgentChat(
|
||||
@@ -443,6 +456,8 @@ function summarizePayload(payload, options = {}) {
|
||||
skills: responseSummary?.skills ?? null,
|
||||
runnerTrace: responseSummary?.runnerTrace ?? null,
|
||||
codexStdioFeasibility: responseSummary?.codexStdioFeasibility ?? null,
|
||||
session: responseSummary?.session ?? null,
|
||||
longLivedSessionGate: responseSummary?.longLivedSessionGate ?? null,
|
||||
assistantReplyNonEmpty: responseSummary?.hasReply === true,
|
||||
assistantReplyLength: assistantReply.length,
|
||||
error: responseSummary?.error ?? null
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const trustedLiveProviders = new Set(["openai-responses", "codex-cli", "codex-readonly-runner"]);
|
||||
const trustedRunnerProviders = new Set(["codex-readonly-runner"]);
|
||||
const readonlySessionCapabilityLevels = new Set(["read-only-tools", "read-only-session-tools"]);
|
||||
const untrustedProviderPattern = /\b(?:echo|mock|fixture|stub|sample)\b/iu;
|
||||
const blockedCodeAgentUiLabels = new Set(["发送失败", "服务受阻", "BLOCKED 凭证缺口"]);
|
||||
const timeoutPattern = /\b(?:timeout|timed out|abort|aborted|超时|请求超过)\b/iu;
|
||||
@@ -86,6 +87,10 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = {
|
||||
? payload.runner.runnerLimitations.filter((item) => typeof item === "string")
|
||||
: [];
|
||||
const sessionReuse = payload?.sessionReuse && typeof payload.sessionReuse === "object" ? payload.sessionReuse : null;
|
||||
const session = payload?.session && typeof payload.session === "object" ? payload.session : null;
|
||||
const longLivedSessionGate = payload?.longLivedSessionGate && typeof payload.longLivedSessionGate === "object"
|
||||
? payload.longLivedSessionGate
|
||||
: null;
|
||||
|
||||
if (provider === "openai-responses" || runnerKind === "openai-responses-fallback") {
|
||||
return {
|
||||
@@ -101,11 +106,15 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = {
|
||||
if (payload?.status !== "completed") missing.push("completed status");
|
||||
if (!trustedRunnerProviders.has(provider)) missing.push("runner provider");
|
||||
if (runnerKind !== "hwlab-readonly-runner") missing.push("runner.kind");
|
||||
if (capabilityLevel !== "read-only-tools") missing.push("capabilityLevel=read-only-tools");
|
||||
if (!readonlySessionCapabilityLevels.has(capabilityLevel)) missing.push("capabilityLevel=read-only-session-tools");
|
||||
if (!workspace) missing.push("workspace");
|
||||
if (sandbox !== "read-only") missing.push("sandbox=read-only");
|
||||
if (sessionMode !== "controlled-readonly-session-registry") missing.push("sessionMode=controlled-readonly-session-registry");
|
||||
if (implementationType !== "controlled-readonly-session-registry") missing.push("implementationType=controlled-readonly-session-registry");
|
||||
if (!session) missing.push("session");
|
||||
if (session && !["idle", "ready", "busy"].includes(session.status)) missing.push("session.status active/idle");
|
||||
if (session && typeof session.idleTimeoutMs !== "number") missing.push("session.idleTimeoutMs");
|
||||
if (session && !session.lastTraceId) missing.push("session.lastTraceId");
|
||||
if (!sessionReuse) missing.push("sessionReuse");
|
||||
if (toolCalls.length === 0) missing.push("toolCalls");
|
||||
if (!runnerTrace) missing.push("runnerTrace");
|
||||
@@ -114,6 +123,7 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = {
|
||||
if (payload?.runner?.codexStdio !== false) missing.push("runner.codexStdio=false");
|
||||
if (payload?.runner?.writeCapable !== false) missing.push("runner.writeCapable=false");
|
||||
if (payload?.runner?.durableSession !== false) missing.push("runner.durableSession=false");
|
||||
if (!longLivedSessionGate) missing.push("longLivedSessionGate");
|
||||
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
@@ -125,6 +135,16 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = {
|
||||
};
|
||||
}
|
||||
|
||||
if (longLivedSessionGate?.status === "pass") {
|
||||
return {
|
||||
status: "blocked",
|
||||
level: "BLOCKED/runner-gate",
|
||||
blocker: "unexpected-long-lived-green",
|
||||
capabilityPass: false,
|
||||
reason: "read-only session registry responses must not claim the full long-lived Codex stdio gate."
|
||||
};
|
||||
}
|
||||
|
||||
if (toolCalls.some((tool) => tool.status !== "completed")) {
|
||||
return {
|
||||
status: "blocked",
|
||||
@@ -137,10 +157,11 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = {
|
||||
|
||||
return {
|
||||
status: "pass",
|
||||
level: "#275 read-only Codex runner capability pass",
|
||||
level: "#317 read-only session registry partial pass",
|
||||
blocker: null,
|
||||
capabilityPass: true,
|
||||
reason: "completed read-only runner response includes workspace, sandbox, toolCalls, skills, and runnerTrace evidence"
|
||||
longLivedSession: false,
|
||||
reason: "completed read-only session-registry response includes session, workspace, sandbox, toolCalls, skills, runnerTrace, and a blocked long-lived Codex stdio gate"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -240,9 +261,11 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId
|
||||
runnerTrace: null,
|
||||
sessionMode: null,
|
||||
sessionReuse: null,
|
||||
session: null,
|
||||
implementationType: null,
|
||||
runnerLimitations: [],
|
||||
codexStdioFeasibility: null
|
||||
codexStdioFeasibility: null,
|
||||
longLivedSessionGate: null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -285,9 +308,11 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId
|
||||
runnerTrace: summarizeRunnerTrace(payload.runnerTrace),
|
||||
sessionMode: stringOrNull(payload.sessionMode),
|
||||
sessionReuse: summarizeSessionReuse(payload.sessionReuse),
|
||||
session: summarizeSession(payload.session),
|
||||
implementationType: stringOrNull(payload.implementationType),
|
||||
runnerLimitations: summarizeStringList(payload.runnerLimitations),
|
||||
codexStdioFeasibility: summarizeCodexStdioFeasibility(payload.codexStdioFeasibility)
|
||||
codexStdioFeasibility: summarizeCodexStdioFeasibility(payload.codexStdioFeasibility),
|
||||
longLivedSessionGate: summarizeLongLivedSessionGate(payload.longLivedSessionGate)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -310,9 +335,11 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId
|
||||
runnerTrace: summarizeRunnerTrace(payload.runnerTrace),
|
||||
sessionMode: stringOrNull(payload.sessionMode),
|
||||
sessionReuse: summarizeSessionReuse(payload.sessionReuse),
|
||||
session: summarizeSession(payload.session),
|
||||
implementationType: stringOrNull(payload.implementationType),
|
||||
runnerLimitations: summarizeStringList(payload.runnerLimitations),
|
||||
codexStdioFeasibility: summarizeCodexStdioFeasibility(payload.codexStdioFeasibility)
|
||||
codexStdioFeasibility: summarizeCodexStdioFeasibility(payload.codexStdioFeasibility),
|
||||
longLivedSessionGate: summarizeLongLivedSessionGate(payload.longLivedSessionGate)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -409,6 +436,9 @@ export function inspectRealCompletionEvidence(payload) {
|
||||
if (provider === "openai-responses" && payload?.capabilityLevel && payload.capabilityLevel !== "text-chat-only") {
|
||||
return { ok: false, reason: "openai-responses cannot claim runner capabilityLevel" };
|
||||
}
|
||||
if (provider === "codex-readonly-runner" && payload?.longLivedSessionGate?.status === "pass") {
|
||||
return { ok: false, reason: "read-only runner cannot claim long-lived session gate pass" };
|
||||
}
|
||||
if (untrustedProviderPattern.test(`${provider} ${backend}`)) {
|
||||
return { ok: false, reason: `provider/backend looks non-real: ${payload.provider}/${payload.backend}` };
|
||||
}
|
||||
@@ -521,7 +551,32 @@ function summarizeSessionReuse(value) {
|
||||
reused: value.reused === true,
|
||||
turn: typeof value.turn === "number" ? value.turn : null,
|
||||
previousTurns: typeof value.previousTurns === "number" ? value.previousTurns : null,
|
||||
workspace: stringOrNull(value.workspace)
|
||||
workspace: stringOrNull(value.workspace),
|
||||
status: stringOrNull(value.status),
|
||||
idleTimeoutMs: typeof value.idleTimeoutMs === "number" ? value.idleTimeoutMs : null,
|
||||
lastTraceId: stringOrNull(value.lastTraceId)
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeSession(value) {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
return {
|
||||
sessionId: stringOrNull(value.sessionId),
|
||||
conversationId: stringOrNull(value.conversationId),
|
||||
status: stringOrNull(value.status),
|
||||
workspace: stringOrNull(value.workspace),
|
||||
sandbox: stringOrNull(value.sandbox),
|
||||
runnerKind: stringOrNull(value.runnerKind),
|
||||
capabilityLevel: stringOrNull(value.capabilityLevel),
|
||||
implementationType: stringOrNull(value.implementationType),
|
||||
createdAt: stringOrNull(value.createdAt),
|
||||
updatedAt: stringOrNull(value.updatedAt),
|
||||
idleTimeoutMs: typeof value.idleTimeoutMs === "number" ? value.idleTimeoutMs : null,
|
||||
lastTraceId: stringOrNull(value.lastTraceId),
|
||||
turn: typeof value.turn === "number" ? value.turn : null,
|
||||
longLivedSession: value.longLivedSession === true,
|
||||
codexStdio: value.codexStdio === true,
|
||||
secretMaterialStored: value.secretMaterialStored === true
|
||||
};
|
||||
}
|
||||
|
||||
@@ -542,6 +597,26 @@ function summarizeCodexStdioFeasibility(value) {
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeLongLivedSessionGate(value) {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
return {
|
||||
status: stringOrNull(value.status),
|
||||
pass: value.pass === true,
|
||||
requiredCapability: stringOrNull(value.requiredCapability),
|
||||
currentCapability: stringOrNull(value.currentCapability),
|
||||
sessionId: stringOrNull(value.sessionId),
|
||||
sessionStatus: stringOrNull(value.sessionStatus),
|
||||
runnerKind: stringOrNull(value.runnerKind),
|
||||
provider: stringOrNull(value.provider),
|
||||
blockers: Array.isArray(value.blockers)
|
||||
? value.blockers.map((blocker) => ({
|
||||
code: stringOrNull(blocker.code),
|
||||
sourceIssue: stringOrNull(blocker.sourceIssue)
|
||||
}))
|
||||
: []
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeStringList(value) {
|
||||
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
||||
}
|
||||
|
||||
@@ -2222,8 +2222,10 @@ function hasCodeAgentCompletedEvidenceVisibility({ app }) {
|
||||
/recordField\("workspace",\s*message\.workspace\)/u.test(messageEvidenceBody) &&
|
||||
/recordField\("sandbox",\s*message\.sandbox\)/u.test(messageEvidenceBody) &&
|
||||
/recordField\("capability",\s*message\.capabilityLevel\)/u.test(messageEvidenceBody) &&
|
||||
/recordField\("session",\s*sessionSummary\(message\.session\)\)/u.test(messageEvidenceBody) &&
|
||||
/recordField\("sessionMode",\s*message\.sessionMode\)/u.test(messageEvidenceBody) &&
|
||||
/recordField\("sessionReuse",\s*sessionReuseSummary\(message\.sessionReuse\)\)/u.test(messageEvidenceBody) &&
|
||||
/recordField\("longLivedGate",\s*longLivedSessionGateSummary\(message\.longLivedSessionGate\)\)/u.test(messageEvidenceBody) &&
|
||||
/recordField\("implementation",\s*message\.implementationType\)/u.test(messageEvidenceBody) &&
|
||||
/recordField\("limitations",\s*limitationsSummary\(message\.runnerLimitations\)\)/u.test(messageEvidenceBody) &&
|
||||
/recordField\("codexStdio",\s*codexStdioFeasibilitySummary\(message\.codexStdioFeasibility\)\)/u.test(messageEvidenceBody) &&
|
||||
@@ -2238,6 +2240,9 @@ function hasCodeAgentCompletedEvidenceVisibility({ app }) {
|
||||
/isCodexRunnerCapableEvidence\(value\)/u.test(realEvidenceBody) &&
|
||||
/CODEX_RUNNER_CAPABLE_PROVIDERS/u.test(app) &&
|
||||
/controlled-readonly-session-registry/u.test(runnerEvidenceBody) &&
|
||||
/read-only-session-tools/u.test(runnerEvidenceBody) &&
|
||||
/value\?\.session\?\.status === "idle"/u.test(runnerEvidenceBody) &&
|
||||
/value\?\.longLivedSessionGate\?\.status === "blocked"/u.test(runnerEvidenceBody) &&
|
||||
/not-codex-stdio/u.test(runnerEvidenceBody) &&
|
||||
/value\?\.runner\?\.codexStdio === false/u.test(runnerEvidenceBody) &&
|
||||
/value\?\.runner\?\.writeCapable === false/u.test(runnerEvidenceBody) &&
|
||||
|
||||
@@ -363,11 +363,13 @@ function initCommandBar() {
|
||||
backend: result.backend,
|
||||
workspace: result.workspace,
|
||||
sandbox: result.sandbox,
|
||||
session: result.session,
|
||||
sessionMode: result.sessionMode,
|
||||
sessionReuse: result.sessionReuse,
|
||||
implementationType: result.implementationType,
|
||||
runnerLimitations: result.runnerLimitations,
|
||||
codexStdioFeasibility: result.codexStdioFeasibility,
|
||||
longLivedSessionGate: result.longLivedSessionGate,
|
||||
toolCalls: result.toolCalls,
|
||||
skills: result.skills,
|
||||
runner: result.runner,
|
||||
@@ -2142,11 +2144,13 @@ function messageEvidenceFields(message) {
|
||||
recordField("workspace", message.workspace),
|
||||
recordField("sandbox", message.sandbox),
|
||||
recordField("capability", message.capabilityLevel),
|
||||
recordField("session", sessionSummary(message.session)),
|
||||
recordField("sessionMode", message.sessionMode),
|
||||
recordField("sessionReuse", sessionReuseSummary(message.sessionReuse)),
|
||||
recordField("implementation", message.implementationType),
|
||||
recordField("limitations", limitationsSummary(message.runnerLimitations)),
|
||||
recordField("codexStdio", codexStdioFeasibilitySummary(message.codexStdioFeasibility)),
|
||||
recordField("longLivedGate", longLivedSessionGateSummary(message.longLivedSessionGate)),
|
||||
recordField("toolCalls", toolCallsSummary(message.toolCalls)),
|
||||
recordField("skills", skillsSummary(message.skills)),
|
||||
recordField("runnerTrace", runnerTraceSummary(message.runnerTrace)),
|
||||
@@ -2197,7 +2201,19 @@ function runnerTraceSummary(runnerTrace) {
|
||||
function sessionReuseSummary(sessionReuse) {
|
||||
if (!sessionReuse || typeof sessionReuse !== "object") return null;
|
||||
const state = sessionReuse.reused ? "reused" : "new";
|
||||
return `${state}:turn${sessionReuse.turn ?? "?"}`;
|
||||
const status = sessionReuse.status ? `:${sessionReuse.status}` : "";
|
||||
return `${state}:turn${sessionReuse.turn ?? "?"}${status}`;
|
||||
}
|
||||
|
||||
function sessionSummary(session) {
|
||||
if (!session || typeof session !== "object") return null;
|
||||
const parts = [
|
||||
session.sessionId,
|
||||
session.status,
|
||||
typeof session.turn === "number" ? `turn${session.turn}` : null,
|
||||
session.lastTraceId ? `lastTrace=${session.lastTraceId}` : null
|
||||
].filter(Boolean);
|
||||
return parts.join(":");
|
||||
}
|
||||
|
||||
function limitationsSummary(limitations) {
|
||||
@@ -2205,6 +2221,15 @@ function limitationsSummary(limitations) {
|
||||
return limitations.slice(0, 3).join(",");
|
||||
}
|
||||
|
||||
function longLivedSessionGateSummary(gate) {
|
||||
if (!gate || typeof gate !== "object") return null;
|
||||
const status = gate.status ?? "unknown";
|
||||
const blockers = Array.isArray(gate.blockers)
|
||||
? gate.blockers.map((blocker) => blocker?.code).filter(Boolean).slice(0, 2).join(",")
|
||||
: "";
|
||||
return blockers ? `${status}:${blockers}` : status;
|
||||
}
|
||||
|
||||
function codexStdioFeasibilitySummary(feasibility) {
|
||||
if (!feasibility || typeof feasibility !== "object") return null;
|
||||
const blockers = Array.isArray(feasibility.blockers) ? feasibility.blockers.length : 0;
|
||||
@@ -2491,7 +2516,10 @@ function isCodexRunnerCapableEvidence(value) {
|
||||
return (
|
||||
CODEX_RUNNER_CAPABLE_PROVIDERS.includes(String(value?.provider ?? "").trim().toLowerCase()) &&
|
||||
value?.runner?.kind === "hwlab-readonly-runner" &&
|
||||
value?.capabilityLevel === "read-only-tools" &&
|
||||
(value?.capabilityLevel === "read-only-session-tools" || value?.capabilityLevel === "read-only-tools") &&
|
||||
value?.session?.status === "idle" &&
|
||||
typeof value?.session?.idleTimeoutMs === "number" &&
|
||||
Boolean(value?.session?.lastTraceId) &&
|
||||
value?.sessionMode === "controlled-readonly-session-registry" &&
|
||||
value?.implementationType === "controlled-readonly-session-registry" &&
|
||||
Boolean(value?.sessionReuse) &&
|
||||
@@ -2504,6 +2532,7 @@ function isCodexRunnerCapableEvidence(value) {
|
||||
(value?.sandbox || value?.runner?.sandbox) === "read-only" &&
|
||||
Array.isArray(value?.toolCalls) &&
|
||||
value.toolCalls.some((tool) => tool?.status === "completed") &&
|
||||
value?.longLivedSessionGate?.status === "blocked" &&
|
||||
Boolean(value?.runnerTrace) &&
|
||||
Boolean(value?.skills)
|
||||
);
|
||||
|
||||
@@ -496,24 +496,36 @@ assert.match(app, /Boolean\(value\?\.traceId\)/);
|
||||
assert.match(app, /Boolean\(value\?\.conversationId \|\| value\?\.sessionId\)/);
|
||||
assert.match(app, /value\?\.sessionMode === "controlled-readonly-session-registry"/);
|
||||
assert.match(app, /value\?\.implementationType === "controlled-readonly-session-registry"/);
|
||||
assert.match(app, /value\?\.capabilityLevel === "read-only-session-tools"/);
|
||||
assert.match(app, /value\?\.session\?\.status === "idle"/);
|
||||
assert.match(app, /typeof value\?\.session\?\.idleTimeoutMs === "number"/);
|
||||
assert.match(app, /Boolean\(value\?\.session\?\.lastTraceId\)/);
|
||||
assert.match(app, /value\?\.longLivedSessionGate\?\.status === "blocked"/);
|
||||
assert.match(app, /Boolean\(value\?\.sessionReuse\)/);
|
||||
assert.match(app, /value\?\.runner\?\.codexStdio === false/);
|
||||
assert.match(app, /value\?\.runner\?\.writeCapable === false/);
|
||||
assert.match(app, /value\?\.runner\?\.durableSession === false/);
|
||||
assert.match(app, /providerTrace:\s*result\.providerTrace/);
|
||||
assert.match(app, /session:\s*result\.session/);
|
||||
assert.match(app, /sessionMode:\s*result\.sessionMode/);
|
||||
assert.match(app, /sessionReuse:\s*result\.sessionReuse/);
|
||||
assert.match(app, /implementationType:\s*result\.implementationType/);
|
||||
assert.match(app, /runnerLimitations:\s*result\.runnerLimitations/);
|
||||
assert.match(app, /codexStdioFeasibility:\s*result\.codexStdioFeasibility/);
|
||||
assert.match(app, /longLivedSessionGate:\s*result\.longLivedSessionGate/);
|
||||
assert.match(app, /providerTraceSummary\(message\.providerTrace\)/);
|
||||
assert.match(app, /recordField\("conversation", message\.conversationId\)/);
|
||||
assert.match(app, /recordField\("provider", message\.provider\)/);
|
||||
assert.match(app, /recordField\("session", sessionSummary\(message\.session\)\)/);
|
||||
assert.match(app, /recordField\("sessionMode", message\.sessionMode\)/);
|
||||
assert.match(app, /recordField\("sessionReuse", sessionReuseSummary\(message\.sessionReuse\)\)/);
|
||||
assert.match(app, /recordField\("implementation", message\.implementationType\)/);
|
||||
assert.match(app, /recordField\("limitations", limitationsSummary\(message\.runnerLimitations\)\)/);
|
||||
assert.match(app, /recordField\("codexStdio", codexStdioFeasibilitySummary\(message\.codexStdioFeasibility\)\)/);
|
||||
assert.match(app, /recordField\("longLivedGate", longLivedSessionGateSummary\(message\.longLivedSessionGate\)\)/);
|
||||
assert.match(app, /function sessionSummary/);
|
||||
assert.match(app, /function longLivedSessionGateSummary/);
|
||||
assert.match(app, /read-only-session-tools/);
|
||||
assert.match(app, /controlled-readonly-session-registry/);
|
||||
assert.match(app, /not-codex-stdio/);
|
||||
assert.match(app, /not-write-capable/);
|
||||
|
||||
Reference in New Issue
Block a user