Merge pull request #458 from pikasTech/fix/code-agent-session-retry-438

fix: preserve Code Agent session continuity
This commit is contained in:
Lyon
2026-05-24 19:20:12 +08:00
committed by GitHub
7 changed files with 845 additions and 56 deletions
@@ -10,6 +10,7 @@ import {
runDevCloudWorkbenchExternalNetworkFixtureSmoke,
runDevCloudWorkbenchLayoutSmoke,
runDevCloudWorkbenchQuickPromptsFixtureSmoke,
runDevCloudWorkbenchSessionContinuityFixtureSmoke,
runDevCloudWorkbenchSlowBlockerFixtureSmoke,
runDevCloudWorkbenchStaticSmoke,
runDevCloudWorkbenchTimeoutFixtureSmoke,
@@ -746,6 +747,29 @@ test("local external network fixture renders GitHub blocker with trace and witho
assert.equal(blockerCheck.observations.prompt.classification.blocker, "runner-blocked");
});
test("local session continuity fixture reuses Code Agent context and retries with degraded-copy guard", async () => {
const report = await runDevCloudWorkbenchSessionContinuityFixtureSmoke();
if (report.status === "skip") {
assert.match(report.summary, /Playwright is unavailable/u);
return;
}
assert.equal(report.status, "pass", JSON.stringify(report.blockers, null, 2));
assert.equal(report.evidenceLevel, "SOURCE");
assert.equal(report.devLive, false);
assert.equal(report.checks.find((check) => check.id === "local-agent-session-continuity-two-turns")?.status, "pass");
assert.equal(report.checks.find((check) => check.id === "local-agent-session-continuity-retry")?.status, "pass");
assert.equal(report.checks.find((check) => check.id === "local-agent-session-continuity-degraded-copy")?.status, "pass");
const continuity = report.checks.find((check) => check.id === "local-agent-session-continuity-two-turns")?.observations;
assert.equal(continuity.secondRequest.conversationId, continuity.firstRequest.conversationId);
assert.equal(continuity.secondRequest.sessionId, "ses_source_fixture_continuity");
assert.equal(continuity.secondRequest.threadId, "thread_source_fixture_continuity");
const retry = report.checks.find((check) => check.id === "local-agent-session-continuity-retry")?.observations;
assert.equal(retry.retryRequest.message, retry.failedRequest.message);
assert.equal(retry.retryRequest.sessionId, retry.failedRequest.sessionId);
assert.equal(retry.retryRequest.threadId, retry.failedRequest.threadId);
});
test("Code Agent quick prompt fixture fills input, does not autosend writes, and fits mobile", async () => {
const report = await runDevCloudWorkbenchQuickPromptsFixtureSmoke();
if (report.status === "skip") {
+398 -5
View File
@@ -321,6 +321,7 @@ const requiredCodeAgentEvidenceTerms = Object.freeze([
"providerTrace.command",
"providerTrace.terminalStatus",
"message-runtime-path",
"threadId",
"not-codex-stdio",
"not-write-capable",
"process-local-session-registry",
@@ -393,6 +394,7 @@ export async function runDevCloudWorkbenchSmoke(argv = []) {
if (args.mode === "m3-status-fixture") return runM3StatusFixtureSmoke();
if (args.mode === "layout") return runDevCloudWorkbenchLayoutSmoke(args);
if (args.mode === "local-agent-fixture") return runDevCloudWorkbenchLocalAgentFixtureSmoke();
if (args.mode === "session-continuity-fixture") return runDevCloudWorkbenchSessionContinuityFixtureSmoke();
if (args.mode === "quick-prompts-fixture") return runDevCloudWorkbenchQuickPromptsFixtureSmoke();
if (args.mode === "mobile") return runDevCloudWorkbenchMobileSmoke();
return runStaticSmoke();
@@ -456,6 +458,8 @@ export function parseSmokeArgs(argv) {
args.mobile = true;
} else if (arg === "--local-agent-fixture") {
args.mode = "local-agent-fixture";
} else if (arg === "--session-continuity-fixture") {
args.mode = "session-continuity-fixture";
} else if (arg === "--quick-prompts-fixture") {
args.mode = "quick-prompts-fixture";
} else if (arg === "--auth-fixture") {
@@ -1607,6 +1611,7 @@ function baseReport({
"node scripts/dev-cloud-workbench-smoke.mjs --layout",
"node scripts/dev-cloud-workbench-smoke.mjs --mobile",
"node scripts/dev-cloud-workbench-smoke.mjs --local-agent-fixture",
"node scripts/dev-cloud-workbench-smoke.mjs --session-continuity-fixture",
"node scripts/dev-cloud-workbench-smoke.mjs --quick-prompts-fixture",
"node scripts/dev-cloud-workbench-smoke.mjs --local-agent-timeout-fixture",
"node scripts/dev-cloud-workbench-smoke.mjs --layout --url http://74.48.78.17:16666/",
@@ -1624,10 +1629,11 @@ function baseReport({
"node scripts/dev-cloud-workbench-smoke.mjs --layout",
"node scripts/dev-cloud-workbench-smoke.mjs --mobile",
"node scripts/dev-cloud-workbench-smoke.mjs --local-agent-fixture",
"node scripts/dev-cloud-workbench-smoke.mjs --session-continuity-fixture",
"node scripts/dev-cloud-workbench-smoke.mjs --quick-prompts-fixture",
"node scripts/dev-cloud-workbench-smoke.mjs --local-agent-timeout-fixture"
],
evidence: ["Source mode verifies the source contract and same-origin Code Agent wiring only.", "Layout browser mode verifies desktop/mobile sidebar collapse, hit targets, non-overlap, and outer-scroll lock at SOURCE level.", "Mobile browser mode verifies the 390x844 outer-scroll lock and help route at SOURCE level.", "Local fixture browser mode verifies both Code Agent prompts past the old 4500ms window at SOURCE level.", "Quick prompt fixture verifies fill-only behavior, explicit-send write semantics, copy boundary, and mobile containment.", "Local timeout fixture verifies bounded timeout UI classification without live acceptance."],
evidence: ["Source mode verifies the source contract and same-origin Code Agent wiring only.", "Layout browser mode verifies desktop/mobile sidebar collapse, hit targets, non-overlap, and outer-scroll lock at SOURCE level.", "Mobile browser mode verifies the 390x844 outer-scroll lock and help route at SOURCE level.", "Local fixture browser mode verifies both Code Agent prompts past the old 4500ms window at SOURCE level.", "Session continuity fixture verifies same-browser conversation/session/thread reuse and retry request context at SOURCE level.", "Quick prompt fixture verifies fill-only behavior, explicit-send write semantics, copy boundary, and mobile containment.", "Local timeout fixture verifies bounded timeout UI classification without live acceptance."],
summary: "Source, layout, mobile, and local fixture workbench smokes are SOURCE-level evidence and do not prove the deployed browser journey."
},
dryRun: {
@@ -2410,7 +2416,7 @@ function hasStableRouteControls({ html, styles }) {
/\.(?:status-dot|state-tag|badge)[^{]*\{[^}]*max-width:\s*100%;[^}]*line-height:\s*1\.25;[^}]*white-space:\s*normal;[^}]*overflow-wrap:\s*anywhere;/su.test(styles) &&
/\.probe-card\s*\{[^}]*min-width:\s*0;/su.test(styles) &&
/\.probe-card strong\s*\{[^}]*line-height:\s*1\.2;[^}]*overflow-wrap:\s*anywhere;/su.test(styles) &&
/@media\s*\(max-width:\s*860px\)[\s\S]*?\.message-trace,\s*\n\s*\.message-m3-evidence(?:,\s*\n\s*\.message-actions)?\s*\{[\s\S]*?grid-column:\s*1;/u.test(styles)
/@media\s*\(max-width:\s*860px\)[\s\S]*?\.message-runtime-path,\s*\n\s*\.message-session-context,\s*\n\s*\.message-trace,\s*\n\s*\.message-m3-evidence,\s*\n\s*\.message-actions\s*\{[\s\S]*?grid-column:\s*1;/u.test(styles)
);
}
@@ -2596,6 +2602,8 @@ function hasCodeAgentCompletedEvidenceVisibility({ app, codeAgentM3Evidence }) {
/CODEX_APP_SERVER_IMPLEMENTATION_TYPE/u.test(runnerEvidenceBody) &&
/CODEX_APP_SERVER_PROTOCOL/u.test(runnerEvidenceBody) &&
/long-lived-codex-stdio-session/u.test(runnerEvidenceBody) &&
/threadIdFrom\(value\)/u.test(runnerEvidenceBody) &&
/hasProviderTrace\(value\)/u.test(runnerEvidenceBody) &&
/isTextFallbackChatResult/u.test(app) &&
/value\?\.session\?\.status === "idle"/u.test(runnerEvidenceBody) &&
/value\?\.longLivedSessionGate\?\.status === "pass"/u.test(runnerEvidenceBody) &&
@@ -2606,8 +2614,10 @@ function hasCodeAgentCompletedEvidenceVisibility({ app, codeAgentM3Evidence }) {
/TRUSTED_CODE_AGENT_PROVIDERS/u.test(app) &&
/UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN/u.test(realEvidenceBody) &&
/value\?\.conversationId \|\| value\?\.sessionId/u.test(realEvidenceBody) &&
/codeAgentContinuityBlocker/u.test(app) &&
/code_agent_session_evidence_missing/u.test(app) &&
/isRealCompletedChatResult\(result\)/u.test(app) &&
/classifyCodeAgentCompletion\(result\)/u.test(app) &&
/classifyCodeAgentCompletion\(result,\s*\{\s*blockedError\s*\}\)/u.test(app) &&
/status:\s*"completed"/u.test(functionBody(app, "classifyCodeAgentCompletion")) &&
/m3_io_result/u.test(functionBody(app, "classifyCodeAgentCompletion")) &&
/sourceKind:\s*result\?\.m3Io\?\.type === "m3_io_result"[\s\S]*?"CONTROLLED"[\s\S]*?"DEV-LIVE"/u.test(functionBody(app, "classifyCodeAgentCompletion")) &&
@@ -2725,6 +2735,7 @@ function hasCodeAgentConversationUxStates({ app, styles }) {
/message-user/u.test(styles) &&
/message-agent/u.test(styles) &&
/message-pending-context\s*\{/u.test(styles) &&
/message-session-context\s*\{/u.test(styles) &&
/message-m3-evidence\s*\{/u.test(styles) &&
/message-m3-rows\s*\{/u.test(styles) &&
/copy-chip\s*\{/u.test(styles) &&
@@ -4067,7 +4078,11 @@ async function runCodeAgentPromptJourney(page, prompt, options = {}) {
method: request.method(),
urlPath: new URL(request.url()).pathname,
traceId: stringOrFallback(body?.traceId, request.headers()["x-trace-id"] ?? "unknown"),
messageLength: typeof body?.message === "string" ? body.message.length : 0
message: typeof body?.message === "string" ? body.message : "",
messageLength: typeof body?.message === "string" ? body.message.length : 0,
conversationId: typeof body?.conversationId === "string" ? body.conversationId : null,
sessionId: typeof body?.sessionId === "string" ? body.sessionId : null,
threadId: typeof body?.threadId === "string" ? body.threadId : null
});
};
const responseListener = async (response) => {
@@ -4566,6 +4581,180 @@ export async function runDevCloudWorkbenchExternalNetworkFixtureSmoke(options =
});
}
export async function runDevCloudWorkbenchSessionContinuityFixtureSmoke(options = {}) {
let chromium;
try {
({ chromium } = await importPlaywright());
} catch (error) {
const summary = `Code Agent session continuity fixture skipped because Playwright is unavailable: ${error.message}`;
return {
status: "skip",
task: "DC-DCSN-P0-2026-003",
mode: "local-agent-session-continuity-fixture-browser",
evidenceLevel: "SOURCE",
devLive: false,
summary,
checks: [{
id: "local-agent-session-continuity-dependency",
status: "skip",
summary: playwrightDependencySummary,
evidence: [playwrightDependencyCommand, "package.json dependencies.playwright"]
}],
blockers: [{
type: "environment_blocker",
scope: "local-agent-session-continuity-dependency",
status: "open",
summary
}],
safety: staticSafety()
};
}
const server = await startStaticWebServer({
agentFixture: true,
agentFixtureMode: "session-continuity",
agentDelayMs: options.responseDelayMs ?? 80
});
let browser;
const requests = [];
try {
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1366, height: 768 } });
page.on("request", (request) => {
const requestUrl = new URL(request.url());
if (request.method() !== "POST" || requestUrl.pathname !== "/v1/agent/chat") return;
const body = parseJsonOrNull(request.postData() ?? "");
requests.push({
message: body?.message ?? "",
conversationId: body?.conversationId ?? null,
sessionId: body?.sessionId ?? null,
threadId: body?.threadId ?? null,
traceId: body?.traceId ?? null
});
});
await page.goto(server.url, { waitUntil: "networkidle", timeout: 15000 });
await loginWithDefaultCredentials(page);
await page.locator("#command-input").waitFor({ state: "visible", timeout: 12000 });
await page.waitForFunction(
() => document.querySelector("#live-status")?.textContent?.trim() === "只读模式",
null,
{ timeout: 8000 }
);
const first = await runCodeAgentPromptJourney(page, {
id: "continuity-first",
label: "第一轮",
text: "第一轮:请记住当前会话"
}, { sourceFixture: true });
const second = await runCodeAgentPromptJourney(page, {
id: "continuity-second",
label: "第二轮",
text: "第二轮:继续同一会话"
}, { sourceFixture: true });
await page.locator("#command-input").fill("触发 providerTrace 缺失");
await page.locator("#command-send").click();
await page.waitForFunction(
() => document.querySelector("#agent-chat-status")?.textContent?.trim() === "Session 受阻",
null,
{ timeout: 8000 }
);
await page.locator(".message-card.status-failed .message-action-retry").last().click();
await page.waitForFunction(
() => document.querySelectorAll(".message-card.status-failed").length >= 2,
null,
{ timeout: 8000 }
);
const ui = await inspectJourneyUi(page);
const text = await page.evaluate(() => document.body.textContent?.replace(/\s+/gu, " ").trim() ?? "");
const [firstRequest, secondRequest, failedRequest, retryRequest] = requests;
const continuityOk = Boolean(
firstRequest?.conversationId &&
secondRequest?.conversationId === firstRequest.conversationId &&
secondRequest?.sessionId === "ses_source_fixture_continuity" &&
secondRequest?.threadId === "thread_source_fixture_continuity"
);
const retryOk = Boolean(
retryRequest?.message === failedRequest?.message &&
retryRequest?.conversationId === failedRequest?.conversationId &&
retryRequest?.sessionId === failedRequest?.sessionId &&
retryRequest?.threadId === failedRequest?.threadId
);
const degradedOk = /Code Agent completed 但会话证据缺失|会话证据缺失|新会话\/会话降级|按会话降级处理/u.test(text);
const checks = [
{
id: "local-agent-session-continuity-two-turns",
status: continuityOk && first.status === "pass" && second.status === "pass" ? "pass" : "blocked",
summary: "第二轮 /v1/agent/chat 请求复用第一轮建立的 conversationId、sessionId 和 threadId。",
observations: { firstRequest, secondRequest, first, second }
},
{
id: "local-agent-session-continuity-retry",
status: retryOk ? "pass" : "blocked",
summary: "重试上一条会重发上一条用户输入,并携带同一 conversation/session/thread 上下文。",
observations: { failedRequest, retryRequest }
},
{
id: "local-agent-session-continuity-degraded-copy",
status: degradedOk && ui.failedRetryActionVisible && ui.failedTraceActionVisible ? "pass" : "blocked",
summary: "缺失 providerTrace/thread 的 completed 响应显示结构化会话降级/受阻文案,不静默冒充同一会话成功。",
observations: { degradedOk, ui, visibleTextMatched: degradedOk }
}
];
const blockers = checks
.filter((check) => check.status !== "pass")
.map((check) => ({
type: "observability_blocker",
scope: check.id,
status: "open",
summary: check.summary
}));
return {
status: blockers.length === 0 ? "pass" : "blocked",
task: "DC-DCSN-P0-2026-003",
issue: "pikasTech/HWLAB#438",
mode: "local-agent-session-continuity-fixture-browser",
url: server.url,
generatedAt: new Date().toISOString(),
evidenceLevel: "SOURCE",
devLive: false,
summary: "Local SOURCE fixture verifies browser Code Agent session continuity, retry context, and degraded missing providerTrace/thread copy without claiming DEV-LIVE.",
checks,
blockers,
safety: {
...staticSafety(),
localFixtureOnly: true,
fixtureTrustBoundary: "SOURCE fixture verifies browser request semantics only and never updates DEV live.",
observedPostCount: requests.length
}
};
} catch (error) {
return {
status: "blocked",
task: "DC-DCSN-P0-2026-003",
mode: "local-agent-session-continuity-fixture-browser",
evidenceLevel: "SOURCE",
devLive: false,
summary: `Code Agent session continuity fixture failed: ${error.message}`,
checks: [{
id: "local-agent-session-continuity-fixture-runtime",
status: "blocked",
summary: error.message,
observations: { requests }
}],
blockers: [{
type: "observability_blocker",
scope: "local-agent-session-continuity-fixture-runtime",
status: "open",
summary: error.message
}],
safety: staticSafety()
};
} finally {
if (browser) await browser.close();
await server.close();
}
}
export async function runDevCloudWorkbenchQuickPromptsFixtureSmoke() {
let chromium;
try {
@@ -5670,6 +5859,198 @@ function longSourceFixtureTraceEvents(traceId, count = 18) {
});
}
function sessionContinuityFixturePayload({ body, traceId, conversationId, messageId, timestamp, options }) {
const fixtureState = options.sessionContinuityState ?? {
sessionId: "ses_source_fixture_continuity",
threadId: "thread_source_fixture_continuity",
turn: 0,
traceIds: []
};
options.sessionContinuityState = fixtureState;
fixtureState.turn += 1;
fixtureState.traceIds.push(traceId);
const message = String(body?.message ?? "");
const missingProviderTrace = /providerTrace\s*缺失|缺失\s*providerTrace/u.test(message);
const sessionId = fixtureState.sessionId;
const threadId = missingProviderTrace ? null : fixtureState.threadId;
const turn = fixtureState.turn;
const toolName = turn > 1 ? "codex-app-server.thread/resume+turn/start" : "codex-app-server.thread/start+turn/start";
const events = longSourceFixtureTraceEvents(traceId, missingProviderTrace ? 6 : 18).map((event, index) => ({
...event,
label: index === 0 ? (turn > 1 ? "session:reused" : "session:created") : event.label,
sessionId,
...(threadId ? { threadId } : {})
}));
const session = {
sessionId,
conversationId,
conversationIds: [conversationId],
status: "idle",
workspace: "/workspace/hwlab",
sandbox: "workspace-write",
runnerKind: "codex-app-server-stdio-runner",
sessionMode: "codex-app-server-stdio-long-lived",
capabilityLevel: "long-lived-codex-stdio-session",
implementationType: "repo-owned-codex-app-server-stdio-session",
createdAt: timestamp,
updatedAt: timestamp,
idleTimeoutMs: 1800000,
expiresAt: "2026-05-22T00:30:00.000Z",
lastTraceId: traceId,
currentTraceId: null,
turn,
threadId,
reused: turn > 1,
durable: true,
longLivedSession: true,
codexStdio: true,
writeCapable: true,
secretMaterialStored: false,
valuesRedacted: true
};
const payload = {
conversationId,
sessionId,
messageId,
status: "completed",
createdAt: timestamp,
updatedAt: timestamp,
traceId,
provider: "codex-stdio",
model: "gpt-5.5",
backend: "local-source-fixture/codex-app-server-stdio",
projectId: stringOrFallback(body?.projectId, gateSummary.topology.projectId),
workspace: "/workspace/hwlab",
sandbox: "workspace-write",
session,
sessionMode: "codex-app-server-stdio-long-lived",
sessionReuse: {
conversationId,
sessionId,
threadId,
mapped: true,
reused: turn > 1,
turn,
previousTurns: Math.max(0, turn - 1),
workspace: "/workspace/hwlab",
status: "idle"
},
implementationType: "repo-owned-codex-app-server-stdio-session",
runnerLimitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"],
codexStdioFeasibility: {
ready: true,
canStartLongLivedCodexStdio: true,
commandProbe: { ready: true },
blockers: [],
blockerCodes: []
},
longLivedSessionGate: {
status: "pass",
pass: true,
blockers: []
},
toolCalls: [{
id: `tool_${turn}`,
type: "codex-app-server-stdio",
name: toolName,
status: "completed",
cwd: "/workspace/hwlab",
command: "codex app-server --listen stdio://",
exitCode: 0,
stdout: [threadId ? `threadId=${threadId}` : "threadId=not_observed", `turnId=turn_source_fixture_${turn}`, "terminalStatus=completed"].join(" "),
stderrSummary: "",
outputTruncated: false,
traceId
}],
skills: { status: "not_requested", count: 0, totalCount: 0, items: [], blockers: [] },
runner: {
kind: "codex-app-server-stdio-runner",
provider: "codex-stdio",
backend: "hwlab-cloud-api/codex-app-server-stdio",
workspace: "/workspace/hwlab",
sandbox: "workspace-write",
session: "codex-app-server-stdio-long-lived",
sessionMode: "codex-app-server-stdio-long-lived",
sessionId,
turn,
sessionReused: turn > 1,
implementationType: "repo-owned-codex-app-server-stdio-session",
codexStdio: true,
longLivedSession: true,
durableSession: true,
writeCapable: true,
readOnly: false,
capabilityLevel: "long-lived-codex-stdio-session"
},
runnerTrace: {
traceId,
runnerKind: "codex-app-server-stdio-runner",
workspace: "/workspace/hwlab",
sandbox: "workspace-write",
sessionMode: "codex-app-server-stdio-long-lived",
implementationType: "repo-owned-codex-app-server-stdio-session",
sessionId,
threadId,
sessionStatus: "idle",
turn,
sessionReused: turn > 1,
events,
eventLabels: events.map((event) => event.label),
lastEvent: events.at(-1),
updatedAt: timestamp,
startedAt: timestamp,
elapsedMs: 120,
outputTruncated: false
},
conversationFacts: {
conversationId,
sessionId,
sessionStatus: "idle",
sessionMode: "codex-app-server-stdio-long-lived",
capabilityLevel: "long-lived-codex-stdio-session",
runnerKind: "codex-app-server-stdio-runner",
workspace: "/workspace/hwlab",
sandbox: "workspace-write",
turnCount: turn,
latestTraceId: traceId,
traceIds: [...fixtureState.traceIds],
latestSkills: null,
recentToolCalls: [{ name: toolName, status: "completed" }],
facts: [],
valuesRedacted: true,
secretMaterialStored: false
},
capabilityLevel: "long-lived-codex-stdio-session",
reply: {
messageId,
role: "assistant",
content: missingProviderTrace
? "本响应故意缺少 providerTrace/thread,用于验证前端会话降级显示。"
: `SOURCE fixture:第 ${turn} 轮已在同一 Code Agent conversation/session/thread 中处理。`,
createdAt: timestamp
},
usage: null
};
if (!missingProviderTrace) {
payload.sourceKind = "SOURCE";
payload.evidenceLevel = "SOURCE";
payload.providerTrace = {
source: "SOURCE-local-browser-fixture",
sourceKind: "SOURCE",
transport: "stdio",
protocol: "codex-app-server-jsonrpc-stdio",
command: "codex app-server --listen stdio://",
toolName,
threadId,
turnId: `turn_source_fixture_${turn}`,
terminalStatus: "completed",
valuesPrinted: false,
redacted: true
};
}
return payload;
}
async function startStaticWebServer(options = {}) {
const rootDir = path.resolve(options.rootDir ?? webRoot);
const authFixtureSessions = new Set();
@@ -6446,6 +6827,17 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {}
const messageId = isExternalNetworkPrompt
? "msg_source_fixture_browser_external_network"
: isSkillListPrompt ? "msg_source_fixture_browser_skills" : "msg_source_fixture_browser_simple";
if (options.agentFixtureMode === "session-continuity") {
jsonResponse(response, 200, sessionContinuityFixturePayload({
body,
traceId,
conversationId,
messageId,
timestamp,
options
}));
return true;
}
if (options.agentFixtureMode === "external-network-blocker") {
const blocker = {
code: "external_network_blocked",
@@ -9086,12 +9478,13 @@ function escapeRegExp(value) {
export function printSmokeHelp() {
return {
status: "usage",
command: `node scripts/dev-cloud-workbench-smoke.mjs --source | --layout [--url http://74.48.78.17:16666/] | --mobile | --local-agent-fixture | --quick-prompts-fixture | --local-agent-timeout-fixture | --dom-only --url http://74.48.78.17:16666/ [--report ${domOnlyReportPath}] | --live --confirm-dev-live --url http://74.48.78.17:16666/ [--report ${liveReportPath}]`,
command: `node scripts/dev-cloud-workbench-smoke.mjs --source | --layout [--url http://74.48.78.17:16666/] | --mobile | --local-agent-fixture | --session-continuity-fixture | --quick-prompts-fixture | --local-agent-timeout-fixture | --dom-only --url http://74.48.78.17:16666/ [--report ${domOnlyReportPath}] | --live --confirm-dev-live --url http://74.48.78.17:16666/ [--report ${liveReportPath}]`,
notes: [
"Default/source mode reads repository files and emits SOURCE-level evidence only; it never calls the live provider and must not claim DEV-LIVE.",
"--layout runs desktop and mobile browser geometry/hit-target checks for sidebar collapse/expand; with --url it remains layout-only live evidence and does not claim M3 acceptance.",
"--mobile runs a local static 390x844 browser hit-test and still emits SOURCE-level evidence only.",
"--local-agent-fixture runs two local browser send/reply paths with a sanitized SOURCE fixture delayed beyond 4500ms and does not claim DEV-LIVE.",
"--session-continuity-fixture verifies same-browser conversation/session/thread reuse, retry context, and degraded missing providerTrace/thread copy without claiming DEV-LIVE.",
"--quick-prompts-fixture clicks Code Agent quick prompts locally and verifies write prompts fill only, without automatic send or hardware write.",
"--local-agent-timeout-fixture runs a local slow SOURCE fixture to verify bounded timeout UI classification and retry preservation without live acceptance.",
"--dom-only preserves runtime/web-asset identity preflight, inspects read-only deployed DOM/help wiring, and never posts /v1/agent/chat.",
+317 -15
View File
@@ -167,6 +167,7 @@ await ensureWorkbenchAuth({
const state = {
conversationId: null,
sessionId: null,
threadId: null,
codeAgentAvailability: null,
chatMessages: [],
traceStreams: new Map(),
@@ -666,6 +667,7 @@ function initCommandBar() {
state.chatMessages = [];
state.conversationId = null;
state.sessionId = null;
state.threadId = null;
state.sessionStatus = null;
state.currentRequest = null;
state.canceledTraces.clear();
@@ -684,6 +686,13 @@ async function submitAgentMessage(value, options = {}) {
const traceId = options.traceId ?? nextProtocolId("trc");
const activeConversationId = options.conversationId ?? state.conversationId ?? nextProtocolId("cnv");
const requestedSessionId = options.sessionId === undefined ? sessionIdForNextRequest() : options.sessionId;
const requestedThreadId = options.threadId === undefined ? threadIdForNextRequest() : options.threadId;
const pendingContinuity = pendingSessionContinuity({
conversationId: activeConversationId,
sessionId: requestedSessionId,
threadId: requestedThreadId,
retryOf: options.retryOf
});
state.conversationId = activeConversationId;
const userMessage = {
id: nextProtocolId("msg"),
@@ -694,6 +703,7 @@ async function submitAgentMessage(value, options = {}) {
traceId,
conversationId: activeConversationId,
sessionId: requestedSessionId,
threadId: requestedThreadId,
retryOf: options.retryOf ?? null,
createdAt: new Date().toISOString()
};
@@ -706,6 +716,8 @@ async function submitAgentMessage(value, options = {}) {
traceId,
conversationId: activeConversationId,
sessionId: requestedSessionId,
threadId: requestedThreadId,
sessionContinuity: pendingContinuity,
retryInput: value,
retryOf: options.retryOf ?? null,
sourceKind: "PENDING",
@@ -717,6 +729,7 @@ async function submitAgentMessage(value, options = {}) {
traceId,
conversationId: activeConversationId,
sessionId: requestedSessionId,
threadId: requestedThreadId,
messageId: pendingMessage.id,
input: value,
lastActivityAt: Date.now(),
@@ -735,24 +748,29 @@ async function submitAgentMessage(value, options = {}) {
const stopTraceStream = subscribeRunnerTrace(traceId, pendingMessage.id);
try {
const result = await sendAgentMessage(value, activeConversationId, traceId, requestedSessionId);
const result = await sendAgentMessage(value, activeConversationId, traceId, requestedSessionId, requestedThreadId);
stopTraceStream();
if (state.canceledTraces.has(traceId)) return;
state.conversationId = result.conversationId || result.sessionId || state.conversationId;
state.sessionId = result.sessionId || result.session?.sessionId || state.sessionId || state.conversationId;
const resultThreadId = threadIdFrom(result);
if (resultThreadId) state.threadId = resultThreadId;
state.sessionStatus = result.session?.status ?? result.runnerTrace?.sessionStatus ?? result.status ?? state.sessionStatus;
const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id);
const completion = classifyCodeAgentCompletion(result);
const sessionContinuity = completedSessionContinuity(result, pendingContinuity);
const resultBlockedError = structuredBlockedErrorFromResult(result);
const continuityBlocker = codeAgentContinuityBlocker(result, sessionContinuity);
const blockedError = resultBlockedError ?? continuityBlocker;
const completion = classifyCodeAgentCompletion(result, { blockedError });
const status = completion.status;
const structuredBlockedError = structuredBlockedErrorFromResult(result);
const m3Presentation = m3IoPresentation(result);
state.chatMessages[index] = {
...pendingMessage,
title: m3Presentation?.title ?? completion.title,
text: m3Presentation?.text ?? (completion.replied
? result.reply?.content || "Code Agent 没有返回文本。"
: structuredBlockedError
? failureMessage({ ...result, error: structuredBlockedError })
: blockedError
? failureMessage({ ...result, error: blockedError })
: result.status === "completed"
? untrustedCompletionMessage(result)
: failureMessage(result)),
@@ -760,6 +778,8 @@ async function submitAgentMessage(value, options = {}) {
traceId: result.traceId,
conversationId: result.conversationId || result.sessionId || state.conversationId,
sessionId: result.sessionId || result.session?.sessionId || result.sessionReuse?.sessionId || state.sessionId,
threadId: resultThreadId || requestedThreadId || state.threadId,
sessionContinuity,
messageId: result.messageId,
provider: result.provider,
model: result.model,
@@ -778,6 +798,8 @@ async function submitAgentMessage(value, options = {}) {
runner: result.runner,
runnerTrace: result.runnerTrace,
conversationFacts: result.conversationFacts,
responseType: result.responseType,
m3Io: result.m3Io,
m3Evidence: extractCodeAgentM3Evidence(result),
capabilityLevel: result.capabilityLevel,
sourceKind: completion.sourceKind,
@@ -787,7 +809,7 @@ async function submitAgentMessage(value, options = {}) {
updatedAt: result.updatedAt,
retryInput: value,
retryOf: options.retryOf ?? null,
error: structuredBlockedError ?? result.error ?? (result.status === "completed" && !completion.replied
error: blockedError ?? result.error ?? (result.status === "completed" && !completion.replied
? {
code: "untrusted_completion",
message: "completed 回复缺少真实 provider/model/trace/conversation 证据,或 provider 属于 echo/mock/stub。"
@@ -801,7 +823,7 @@ async function submitAgentMessage(value, options = {}) {
if (!completion.replied) {
el.commandInput.value = value;
}
renderAgentChatStatus(status, result);
renderAgentChatStatus(status, state.chatMessages[index]);
renderCodeAgentSummary();
} catch (error) {
stopTraceStream();
@@ -815,6 +837,8 @@ async function submitAgentMessage(value, options = {}) {
text: presentation.text,
status: failedStatus,
traceId: error.traceId || traceId,
threadId: requestedThreadId,
sessionContinuity: failedSessionContinuity(pendingContinuity, error),
updatedAt: new Date().toISOString(),
retryInput: value,
error: {
@@ -860,6 +884,10 @@ function sessionIdForNextRequest() {
return isTerminalSessionStatus(state.sessionStatus) ? undefined : state.sessionId;
}
function threadIdForNextRequest() {
return isTerminalSessionStatus(state.sessionStatus) ? undefined : state.threadId;
}
function isTerminalSessionStatus(status) {
return ["failed", "interrupted", "timeout", "error", "canceled", "expired"].includes(String(status ?? "").toLowerCase());
}
@@ -870,6 +898,189 @@ function errorStatusFromPresentation(presentation, error) {
return "failed";
}
function pendingSessionContinuity({ conversationId, sessionId, threadId, retryOf = null } = {}) {
const hasReusableSession = Boolean(nonEmptyString(sessionId));
const hasReusableThread = Boolean(nonEmptyString(threadId));
const status = hasReusableSession || hasReusableThread
? "continued"
: retryOf ? "degraded" : "new";
return {
status,
tone: status === "continued" ? "ok" : status === "degraded" ? "warn" : "source",
label: status === "continued" ? "继续当前会话" : status === "degraded" ? "重试降级" : "新会话待建立",
summary: status === "continued"
? "本次请求会带上已保存的 conversation/session/thread 上下文。"
: status === "degraded"
? "上一轮未留下可复用 session/thread;重试会保留输入和 conversation,并等待后端建立新会话。"
: "这是当前浏览器会话中的首轮请求,等待后端分配 session/thread。",
requested: {
conversationId: nonEmptyString(conversationId),
sessionId: nonEmptyString(sessionId),
threadId: nonEmptyString(threadId),
retryOf: nonEmptyString(retryOf)
},
returned: {
conversationId: null,
sessionId: null,
threadId: null
},
missing: [],
changed: []
};
}
function completedSessionContinuity(result, pending = null) {
const requested = pending?.requested ?? {};
const returned = {
conversationId: nonEmptyString(result?.conversationId),
sessionId: sessionIdFrom(result),
threadId: threadIdFrom(result)
};
const missing = [];
const changed = [];
if (!returned.conversationId) missing.push("conversationId");
if (requiresCodexSessionEvidence(result) && !returned.sessionId) missing.push("sessionId");
if (requiresCodexThreadEvidence(result) && !returned.threadId) missing.push("threadId");
if (requiresCodexProviderTrace(result) && !hasProviderTrace(result)) missing.push("providerTrace");
if (requested.conversationId && returned.conversationId && requested.conversationId !== returned.conversationId) changed.push("conversationId");
if (requested.sessionId && returned.sessionId && requested.sessionId !== returned.sessionId) changed.push("sessionId");
if (requested.threadId && returned.threadId && requested.threadId !== returned.threadId) changed.push("threadId");
const reused = result?.sessionReuse?.reused ?? result?.session?.reused ?? result?.runnerTrace?.sessionReused ?? null;
const requestedReusable = Boolean(requested.sessionId || requested.threadId);
const status = missing.length > 0 || changed.length > 0 || (requestedReusable && reused === false)
? "degraded"
: requestedReusable ? "continued" : "new";
const label = status === "continued" ? "继续当前会话" : status === "new" ? "新会话已建立" : "新会话/会话降级";
const summary = status === "continued"
? "后端确认复用当前 conversation/session/thread。"
: status === "new"
? "后端已为当前 conversation 建立新的 session/thread。"
: `后端未完整确认原 session/thread 复用;${continuityIssueText({ missing, changed, reused })}`;
return {
status,
tone: status === "continued" ? "ok" : status === "new" ? "source" : "warn",
label,
summary,
requested,
returned,
missing,
changed,
reused
};
}
function failedSessionContinuity(pending = null, error = null) {
const base = pending ?? pendingSessionContinuity();
const missing = [...(base.missing ?? [])];
if (!base.requested?.sessionId) missing.push("sessionId");
if (!base.requested?.threadId) missing.push("threadId");
return {
...base,
status: "blocked",
tone: "blocked",
label: "会话受阻",
summary: `本次请求没有完成可复用会话确认;输入已保留。${error?.code ? ` code=${error.code}` : ""}`,
missing: [...new Set(missing)]
};
}
function continuityIssueText({ missing = [], changed = [], reused = null } = {}) {
const parts = [
missing.length ? `缺失 ${missing.join("、")}` : null,
changed.length ? `${changed.join("、")} 已变化` : null,
reused === false ? "sessionReuse=false" : null
].filter(Boolean);
return parts.length ? parts.join("") : "会话复用证据不足";
}
function codeAgentContinuityBlocker(result, continuity) {
if (
!result ||
result.status !== "completed" ||
isSourceFixtureCompletion(result) ||
isTextFallbackChatResult(result) ||
isCodeAgentM3SkillCompletion(result) ||
!isTrustedCodeAgentProvider(result.provider)
) {
return null;
}
const missing = [
...(!hasProviderTrace(result) ? ["providerTrace"] : []),
...(!sessionIdFrom(result) ? ["sessionId"] : []),
...(requiresCodexThreadEvidence(result) && !threadIdFrom(result) ? ["threadId"] : [])
];
if (missing.length === 0) return null;
return {
code: "code_agent_session_evidence_missing",
category: "session_blocked",
layer: "cloud-web",
retryable: true,
missingFields: missing,
traceId: result.traceId,
message: `completed response missing Code Agent session evidence: ${missing.join(", ")}`,
userMessage: `Code Agent completed 但会话证据缺失:${missing.join("、")};本次按会话降级处理,输入已保留,可重试建立新会话。`,
blocker: {
code: "code_agent_session_evidence_missing",
category: "session_blocked",
layer: "cloud-web",
retryable: true,
missingFields: missing,
traceId: result.traceId,
userMessage: `Code Agent completed 但会话证据缺失:${missing.join("、")};本次按会话降级处理,输入已保留,可重试建立新会话。`,
sessionContinuity: continuity
}
};
}
function hasProviderTrace(value) {
return Boolean(value?.providerTrace && typeof value.providerTrace === "object" && Object.keys(value.providerTrace).length > 0);
}
function requiresCodexProviderTrace(value) {
return isTrustedCodeAgentProvider(value?.provider) && !isSourceFixtureCompletion(value) && !isCodeAgentM3SkillCompletion(value);
}
function requiresCodexSessionEvidence(value) {
return isTrustedCodeAgentProvider(value?.provider) && !isCodeAgentM3SkillCompletion(value);
}
function requiresCodexThreadEvidence(value) {
return isCodexStdioLongLivedShape(value) && !isCodeAgentM3SkillCompletion(value);
}
function isCodexStdioLongLivedShape(value) {
return (
isTrustedCodeAgentProvider(value?.provider) ||
String(value?.runner?.kind ?? value?.runnerTrace?.runnerKind ?? "").trim() === CODEX_APP_SERVER_RUNNER_KIND ||
value?.runner?.codexStdio === true ||
value?.session?.codexStdio === true
) && (
String(value?.sessionMode ?? value?.session?.sessionMode ?? value?.runnerTrace?.sessionMode ?? "").trim() === CODEX_APP_SERVER_SESSION_MODE ||
String(value?.implementationType ?? value?.session?.implementationType ?? "").trim() === CODEX_APP_SERVER_IMPLEMENTATION_TYPE
);
}
function sessionIdFrom(value) {
return firstNonEmptyString(value?.sessionId, value?.session?.sessionId, value?.sessionReuse?.sessionId, value?.runner?.sessionId, value?.runnerTrace?.sessionId);
}
function threadIdFrom(value) {
return firstNonEmptyString(value?.threadId, value?.session?.threadId, value?.sessionReuse?.threadId, value?.providerTrace?.threadId, value?.runnerTrace?.threadId);
}
function firstNonEmptyString(...values) {
for (const value of values) {
const text = nonEmptyString(value);
if (text) return text;
}
return null;
}
function nonEmptyString(value) {
const text = String(value ?? "").trim();
return text ? text : null;
}
function subscribeRunnerTrace(traceId, messageId) {
if (typeof EventSource !== "function") return pollRunnerTrace(traceId, messageId);
const existing = state.traceStreams.get(traceId);
@@ -963,19 +1174,25 @@ function updateMessageTrace(messageId, snapshot, options = {}) {
const current = state.chatMessages[index];
const runnerTrace = runnerTraceFromSnapshot(snapshot, current.runnerTrace);
const sessionId = snapshot.sessionId ?? runnerTrace.sessionId ?? current.sessionId;
const threadId = snapshot.threadId ?? runnerTrace.threadId ?? current.threadId;
noteCurrentRequestTraceActivity(snapshot);
state.chatMessages[index] = {
...current,
runnerTrace,
traceId: snapshot.traceId ?? current.traceId,
sessionId,
threadId,
updatedAt: snapshot.updatedAt ?? current.updatedAt
};
if (sessionId) state.sessionId = sessionId;
if (threadId) state.threadId = threadId;
if (runnerTrace.sessionStatus) state.sessionStatus = runnerTrace.sessionStatus;
if (state.currentRequest?.traceId === (snapshot.traceId ?? current.traceId) && sessionId) {
state.currentRequest.sessionId = sessionId;
}
if (state.currentRequest?.traceId === (snapshot.traceId ?? current.traceId) && threadId) {
state.currentRequest.threadId = threadId;
}
if (options.quiet !== true) {
renderCodeAgentSummary();
renderConversation();
@@ -1015,6 +1232,8 @@ function runnerTraceFromSnapshot(snapshot, previous = null) {
sandbox: snapshot.sandbox ?? previous?.sandbox,
sessionMode: snapshot.sessionMode ?? previous?.sessionMode,
sessionId: snapshot.sessionId ?? previous?.sessionId,
threadId: snapshot.threadId ?? previous?.threadId,
turnId: snapshot.turnId ?? previous?.turnId,
sessionStatus: snapshot.sessionStatus ?? previous?.sessionStatus,
implementationType: snapshot.implementationType ?? previous?.implementationType,
startedAt: snapshot.startedAt ?? previous?.startedAt,
@@ -1070,8 +1289,9 @@ function renderProbePending() {
renderCodeAgentSummary();
}
async function sendAgentMessage(message, conversationId, traceId = nextProtocolId("trc"), requestedSessionId = sessionIdForNextRequest()) {
async function sendAgentMessage(message, conversationId, traceId = nextProtocolId("trc"), requestedSessionId = sessionIdForNextRequest(), requestedThreadId = threadIdForNextRequest()) {
const sessionId = requestedSessionId || undefined;
const threadId = requestedThreadId || undefined;
const response = await fetchJson("/v1/agent/chat", {
method: "POST",
headers: {
@@ -1085,6 +1305,7 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI
message,
conversationId,
sessionId,
threadId,
traceId,
projectId: gateSummary.topology.projectId
})
@@ -2374,6 +2595,7 @@ function codeAgentRecordCards() {
recordField("conversation", message.conversationId ?? state.conversationId),
recordField("trace", message.traceId),
recordField("session", message.sessionId ?? sessionSummary(message.session)),
recordField("thread", message.threadId ?? threadIdFrom(message)),
recordField("message", message.messageId),
message.error?.message ? `失败原因=${safeFailureReason(message.error.message)}` : null
].filter(Boolean).join(" / "),
@@ -2839,7 +3061,7 @@ function textFallbackTitle(result) {
return `Code Agent 文本 fallback 回复 ${shortTime(result.updatedAt ?? new Date().toISOString())}`;
}
function classifyCodeAgentCompletion(result) {
function classifyCodeAgentCompletion(result, { blockedError = null } = {}) {
if (result?.status === "canceled") {
return {
status: "canceled",
@@ -2880,6 +3102,14 @@ function classifyCodeAgentCompletion(result) {
title: agentFailurePresentation(structuredBlockedErrorFromResult(result), { result }).title
};
}
if (blockedError) {
return {
status: "failed",
replied: false,
sourceKind: "BLOCKED",
title: agentFailurePresentation(blockedError, { result }).title
};
}
if (isRealCompletedChatResult(result)) {
return {
status: "completed",
@@ -3178,6 +3408,8 @@ function messageCard(message) {
article.append(textSpan(message.text, "message-copy"));
const pendingContext = messagePendingContextPanel(message);
if (pendingContext) article.append(pendingContext);
const sessionContext = messageSessionContinuityPanel(message);
if (sessionContext) article.append(sessionContext);
const m3Evidence = messageM3EvidencePanel(message);
if (m3Evidence) article.append(m3Evidence);
const runtimePath = messageRuntimePathPanel(message);
@@ -3245,6 +3477,9 @@ async function restartRunningAgentMessage(messageId) {
sessionId: isTerminalSessionStatus(canceled.session?.status ?? canceled.runnerTrace?.sessionStatus ?? canceled.status)
? undefined
: canceled.sessionId,
threadId: isTerminalSessionStatus(canceled.session?.status ?? canceled.runnerTrace?.sessionStatus ?? canceled.status)
? undefined
: canceled.threadId ?? threadIdFrom(canceled),
retryOf: canceled.traceId
});
}
@@ -3261,6 +3496,9 @@ async function retryAgentMessage(messageId) {
sessionId: isTerminalSessionStatus(message.session?.status ?? message.runnerTrace?.sessionStatus ?? message.status)
? undefined
: message.sessionId,
threadId: isTerminalSessionStatus(message.session?.status ?? message.runnerTrace?.sessionStatus ?? message.status)
? undefined
: message.threadId ?? threadIdFrom(message),
retryOf: message.traceId
});
}
@@ -3282,10 +3520,12 @@ async function cancelAgentMessage(messageId) {
const message = state.chatMessages[index];
const traceId = message.traceId;
const sessionId = message.sessionId || message.runnerTrace?.sessionId || state.currentRequest?.sessionId || state.sessionId;
const threadId = message.threadId || message.runnerTrace?.threadId || state.currentRequest?.threadId || state.threadId;
const response = await cancelAgentRequest({
traceId,
conversationId: message.conversationId || state.conversationId,
sessionId
sessionId,
threadId
});
const payload = response.data ?? {};
const runnerTrace = payload.runnerTrace ?? latestTraceSnapshot(traceId);
@@ -3301,6 +3541,8 @@ async function cancelAgentMessage(messageId) {
text: payload.userMessage || "当前 Codex stdio 请求已取消;输入、sessionId、traceId 和最后 trace event 已保留,可重试上一条消息。",
status: "canceled",
sessionId: payload.sessionId || sessionId || message.sessionId,
threadId: payload.threadId || threadId || message.threadId,
sessionContinuity: failedSessionContinuity(message.sessionContinuity, { code: "codex_stdio_canceled" }),
session: payload.session ?? message.session,
runnerTrace,
traceReplayStatus: lastTraceEventLabel(runnerTrace),
@@ -3319,6 +3561,8 @@ async function cancelAgentMessage(messageId) {
title: "Code Agent 取消受阻",
text: payload.error?.userMessage || response.error || "当前请求没有可取消的 in-flight Codex stdio session;输入和 trace 已保留。",
status: "failed",
threadId,
sessionContinuity: failedSessionContinuity(message.sessionContinuity, { code: "cancel_blocked" }),
runnerTrace,
traceReplayStatus: lastTraceEventLabel(runnerTrace),
updatedAt: new Date().toISOString(),
@@ -3338,7 +3582,7 @@ async function cancelAgentMessage(messageId) {
renderRecords(state.liveSurface);
}
async function cancelAgentRequest({ traceId, conversationId, sessionId }) {
async function cancelAgentRequest({ traceId, conversationId, sessionId, threadId }) {
return fetchJson("/v1/agent/chat/cancel", {
method: "POST",
headers: {
@@ -3350,7 +3594,8 @@ async function cancelAgentRequest({ traceId, conversationId, sessionId }) {
body: JSON.stringify({
traceId,
conversationId,
sessionId
sessionId,
threadId
})
});
}
@@ -3369,6 +3614,7 @@ async function replayAgentTrace(messageId) {
state.chatMessages[index] = {
...message,
runnerTrace,
threadId: runnerTrace.threadId ?? message.threadId,
traceReplayStatus: `已回放 ${runnerTrace.events?.length ?? 0} 个真实 trace event${lastTraceEventLabel(runnerTrace)}`,
updatedAt: response.data?.updatedAt ?? new Date().toISOString()
};
@@ -3390,13 +3636,61 @@ function lastTraceEventLabel(runnerTrace) {
return `lastEvent=${event.label ?? `${event.type ?? "event"}:${event.status ?? "observed"}`}`;
}
function messageSessionContinuityPanel(message) {
if (message.role !== "agent" || message.status === "running") return null;
const continuity = message.sessionContinuity ?? completedSessionContinuity(message, pendingSessionContinuity({
conversationId: message.conversationId,
sessionId: message.sessionId,
threadId: message.threadId,
retryOf: message.retryOf
}));
if (!continuity) return null;
const fields = [
["状态", continuity.label],
["conversation", continuity.returned?.conversationId ?? message.conversationId],
["session", continuity.returned?.sessionId ?? message.sessionId ?? "未确认"],
["thread", continuity.returned?.threadId ?? message.threadId ?? "未确认"],
["requestedSession", continuity.requested?.sessionId],
["requestedThread", continuity.requested?.threadId],
["retryOf", continuity.requested?.retryOf ?? message.retryOf],
["missing", continuity.missing?.join("、")],
["changed", continuity.changed?.join("、")]
];
const section = document.createElement("section");
section.className = `message-session-context tone-border-${toneClass(continuity.tone)}`;
const header = document.createElement("div");
header.className = "message-session-head";
header.append(badge(continuity.label, continuity.tone), textSpan(continuity.summary, "message-session-summary"));
const list = document.createElement("div");
list.className = "message-session-grid";
for (const [label, value] of fields) {
if (value === undefined || value === null || value === "") continue;
const item = document.createElement("div");
item.className = "message-session-row";
item.append(textSpan(label, "message-session-key"), textSpan(value, "message-session-value mono"));
if (label === "conversation" || label === "session" || label === "thread") {
item.append(copyButton(value, label));
}
list.append(item);
}
section.append(header, list);
return section;
}
function messagePendingContextPanel(message) {
if (message.status !== "running") return null;
const continuity = message.sessionContinuity ?? pendingSessionContinuity({
conversationId: message.conversationId,
sessionId: message.sessionId,
threadId: message.threadId,
retryOf: message.retryOf
});
const fields = [
["状态", "后端处理中"],
["状态", continuity.label],
["traceId", message.traceId],
["conversation", message.conversationId],
["session", message.sessionId ?? message.runnerTrace?.sessionId ?? "等待后端分配"],
["thread", message.threadId ?? message.runnerTrace?.threadId ?? "等待后端分配"],
["sessionStatus", message.runnerTrace?.sessionStatus],
["activityTimeout", `${CODE_AGENT_TIMEOUT_MS}ms 无新事件`]
];
@@ -3404,7 +3698,7 @@ function messagePendingContextPanel(message) {
section.className = "message-pending-context tone-border-pending";
const header = document.createElement("div");
header.className = "message-pending-head";
header.append(badge("处理中", "pending"), textSpan("请求仍在同一会话上下文中等待后端结果。", "message-pending-summary"));
header.append(badge("处理中", "pending"), textSpan(continuity.summary, "message-pending-summary"));
const list = document.createElement("div");
list.className = "message-pending-grid";
for (const [label, value] of fields) {
@@ -3636,7 +3930,8 @@ function runnerTraceHeadline(message, trace) {
const last = trace?.lastEvent?.label ?? trace?.eventLabels?.at?.(-1) ?? "等待事件";
const elapsed = typeof trace?.elapsedMs === "number" ? ` / ${trace.elapsedMs}ms` : "";
const waiting = trace?.waitingFor ? ` / 等待 ${trace.waitingFor}` : "";
return `运行 trace ${trace?.traceId ?? message.traceId ?? "pending"} / events=${count} / last=${last}${elapsed}${waiting}`;
const thread = trace?.threadId ?? message.threadId ? ` / thread=${trace?.threadId ?? message.threadId}` : "";
return `运行 trace ${trace?.traceId ?? message.traceId ?? "pending"} / events=${count} / last=${last}${elapsed}${waiting}${thread}`;
}
function traceEventMeta(event) {
@@ -3713,6 +4008,7 @@ function sessionSummary(session) {
session.sessionId,
session.status,
typeof session.turn === "number" ? `turn${session.turn}` : null,
session.threadId ? `thread=${session.threadId}` : null,
session.lastTraceId ? `lastTrace=${session.lastTraceId}` : null
].filter(Boolean);
return parts.join(":");
@@ -4110,6 +4406,9 @@ function hasRealCodeAgentEvidence(value) {
Boolean(value?.backend) &&
Boolean(value?.traceId) &&
Boolean(value?.conversationId || value?.sessionId) &&
hasProviderTrace(value) &&
Boolean(sessionIdFrom(value)) &&
(!requiresCodexThreadEvidence(value) || Boolean(threadIdFrom(value))) &&
!UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN.test(`${value?.provider ?? ""} ${value?.backend ?? ""}`)
);
}
@@ -4125,6 +4424,9 @@ function isCodexRunnerCapableEvidence(value) {
value?.sessionMode === CODEX_APP_SERVER_SESSION_MODE &&
value?.implementationType === CODEX_APP_SERVER_IMPLEMENTATION_TYPE &&
Boolean(value?.sessionReuse) &&
hasProviderTrace(value) &&
Boolean(sessionIdFrom(value)) &&
Boolean(threadIdFrom(value)) &&
value?.session?.longLivedSession === true &&
value?.session?.codexStdio === true &&
value?.runner?.codexStdio === true &&
+13 -3
View File
@@ -9,6 +9,9 @@ const GREEN_TONES = new Set(["ok", "available", "ready", "dev-live", "live", "pa
const SECRET_MARKER = /\b(?:OPENAI_API_KEY|DATABASE_URL|secret|secretref|token|password|passwd|credential|private key|kubeconfig|密钥|密码|凭证)\b/iu;
const NON_LIVE_VALUES = new Set(["", "n/a", "none", "null", "undefined", "unknown", "not_observed", "not-observed"]);
const MAX_FIELD_LENGTH = 96;
const CODEX_APP_SERVER_RUNNER_KIND = "codex-app-server-stdio-runner";
const CODEX_APP_SERVER_SESSION_MODE = "codex-app-server-stdio-long-lived";
const CODEX_APP_SERVER_IMPLEMENTATION_TYPE = "repo-owned-codex-app-server-stdio-session";
export function classifyCodeAgentStatusSummary({ availability = null, latestMessage = null, live = null } = {}) {
const observedAvailability = availability ?? codeAgentAvailabilityFromLive(live);
@@ -23,6 +26,7 @@ export function classifyCodeAgentStatusSummary({ availability = null, latestMess
const capabilityLevel = safeField(firstString(payload.capabilityLevel, payload.runner?.capabilityLevel, observedAvailability?.capabilityLevel));
const sessionMode = safeField(firstString(payload.sessionMode, payload.session?.sessionMode, payload.runnerTrace?.sessionMode, payload.runner?.sessionMode));
const sessionId = safeField(firstString(payload.session?.sessionId, payload.sessionId, payload.runner?.sessionId, payload.sessionReuse?.sessionId));
const threadId = safeField(firstString(payload.threadId, payload.session?.threadId, payload.sessionReuse?.threadId, payload.providerTrace?.threadId, payload.runnerTrace?.threadId));
const sessionStatus = safeField(firstString(payload.session?.status, payload.sessionStatus, payload.runnerTrace?.sessionStatus, payload.sessionReuse?.status));
const workspace = safeField(firstString(payload.workspace, payload.session?.workspace, payload.runner?.workspace, payload.sessionReuse?.workspace));
const sandbox = safeField(firstString(payload.sandbox, payload.session?.sandbox, payload.runner?.sandbox));
@@ -60,9 +64,10 @@ export function classifyCodeAgentStatusSummary({ availability = null, latestMess
capabilityLevel,
sessionMode,
sessionId,
threadId,
sessionStatus,
sessionSummary: compactJoin([sessionMode, sessionStatus], " / "),
sessionIdStatus: compactJoin([sessionId, sessionStatus], " / "),
sessionIdStatus: compactJoin([sessionId, threadId, sessionStatus], " / "),
workspace,
sandbox,
runnerKind,
@@ -88,6 +93,7 @@ export function classifyCodeAgentStatusSummary({ availability = null, latestMess
capabilityLevel,
sessionMode,
sessionId,
threadId,
sessionStatus,
workspace,
sandbox,
@@ -331,12 +337,16 @@ function isLongLivedReady(payload) {
) &&
(
payload.provider === "codex-stdio" ||
payload.runner?.kind === "codex-app-server-stdio-runner" ||
payload.runner?.kind === CODEX_APP_SERVER_RUNNER_KIND ||
payload.runner?.codexStdio === true ||
payload.session?.codexStdio === true
) &&
payload.capabilityLevel === "long-lived-codex-stdio-session" &&
payload.sessionMode === "codex-app-server-stdio-long-lived"
payload.sessionMode === CODEX_APP_SERVER_SESSION_MODE &&
(
!payload.implementationType ||
payload.implementationType === CODEX_APP_SERVER_IMPLEMENTATION_TYPE
)
) && payload.status !== "failed" && payload.status !== "blocked" && payload.ready !== false;
}
@@ -100,6 +100,52 @@ test("does not mark legacy MCP-shaped Codex stdio as app-server long-lived sessi
assert.equal(summary.runtimePathLabel, "DEGRADEDMCP/其他 runner");
});
test("summarizes Codex app-server stdio thread as reusable long-lived session", () => {
const summary = classifyCodeAgentStatusSummary({
latestMessage: {
status: "completed",
provider: "codex-stdio",
mode: "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",
session: {
sessionId: "ses_app_server",
threadId: "thread_app_server",
status: "idle",
lastTraceId: "trc_app_server",
codexStdio: true
},
sessionReuse: {
sessionId: "ses_app_server",
threadId: "thread_app_server",
reused: true,
turn: 2
},
runner: {
kind: "codex-app-server-stdio-runner",
codexStdio: true,
writeCapable: true,
durableSession: true
},
longLivedSessionGate: {
status: "pass"
},
providerTrace: {
protocol: "codex-app-server-jsonrpc-stdio",
threadId: "thread_app_server",
terminalStatus: "completed"
}
}
});
assert.equal(summary.kind, "long-lived-session");
assert.equal(summary.tone, "ok");
assert.equal(summary.sessionIdStatus, "ses_app_server / thread_app_server / idle");
assert.equal(summary.fields.threadId, "thread_app_server");
});
test("does not mark Codex stdio-shaped fields green without runner and gate evidence", () => {
const summary = classifyCodeAgentStatusSummary({
availability: {
+13 -1
View File
@@ -759,6 +759,8 @@ assert.match(app, /Boolean\(value\?\.session\?\.lastTraceId\)/);
assert.match(app, /Boolean\(value\?\.sessionReuse\)/);
assert.match(app, /value\?\.sessionMode === CODEX_APP_SERVER_SESSION_MODE/);
assert.match(app, /value\?\.implementationType === CODEX_APP_SERVER_IMPLEMENTATION_TYPE/);
assert.match(app, /threadIdFrom\(value\)/);
assert.match(app, /hasProviderTrace\(value\)/);
assert.match(app, /value\?\.capabilityLevel === "long-lived-codex-stdio-session"/);
assert.match(app, /value\?\.session\?\.codexStdio === true/);
assert.match(app, /value\?\.runner\?\.codexStdio === true/);
@@ -890,6 +892,7 @@ assert.match(styles, /\.message-card\.status-source\s*{/);
assert.match(styles, /\.message-user\s*{/);
assert.match(styles, /\.message-agent,\s*\n\.message-system\s*{/);
assert.match(styles, /\.message-pending-context\s*{/);
assert.match(styles, /\.message-session-context\s*{/);
assert.match(styles, /\.message-m3-evidence\s*{/);
assert.match(styles, /\.message-m3-rows\s*{/);
assert.match(styles, /\.message-m3-row\s*{/);
@@ -912,7 +915,7 @@ assert.match(styles, /\.(?:status-dot|state-tag|badge)[^{]*{[^}]*max-width:\s*10
assert.match(styles, /\.probe-card\s*{[^}]*min-width:\s*0;/s);
assert.match(styles, /\.probe-card strong\s*{[^}]*line-height:\s*1\.2;[^}]*overflow-wrap:\s*anywhere;/s);
assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.message-m3-rows\s*{[\s\S]*?grid-template-columns:\s*1fr;/);
assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.message-trace,\s*\n\s*\.message-m3-evidence(?:,\s*\n\s*\.message-actions)?\s*{[\s\S]*?grid-column:\s*1;/);
assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.message-runtime-path,\s*\n\s*\.message-session-context,\s*\n\s*\.message-trace,\s*\n\s*\.message-m3-evidence,\s*\n\s*\.message-actions\s*{[\s\S]*?grid-column:\s*1;/);
for (const userFacingFunctionName of [
"codeAgentStatusMessage",
"codeAgentPromptText",
@@ -926,6 +929,15 @@ for (const userFacingFunctionName of [
}
assert.match(app, /state\.conversationId/);
assert.match(app, /conversationId/);
assert.match(app, /state\.threadId/);
assert.match(app, /threadIdForNextRequest/);
assert.match(app, /threadId:\s*requestedThreadId/);
assert.match(app, /function\s+completedSessionContinuity/);
assert.match(app, /function\s+codeAgentContinuityBlocker/);
assert.match(app, /code_agent_session_evidence_missing/);
assert.match(app, /Code Agent completed 但会话证据缺失/);
assert.match(app, /继续当前会话/);
assert.match(app, /新会话\/会话降级/);
assert.match(app, /messageId/);
assert.match(app, /createdAt/);
assert.match(app, /updatedAt/);
+34 -32
View File
@@ -967,6 +967,7 @@ h3 {
.message-pending-context,
.message-runtime-path,
.message-session-context,
.message-trace,
.message-m3-evidence,
.message-actions {
@@ -987,6 +988,11 @@ h3 {
border-left-width: 3px;
}
.message-session-context {
background: rgba(18, 25, 24, 0.64);
border-left-width: 3px;
}
.message-trace {
background: rgba(20, 24, 23, 0.76);
border-left-width: 3px;
@@ -1037,6 +1043,7 @@ h3 {
.message-pending-head,
.message-runtime-head,
.message-session-head,
.message-trace-head {
min-width: 0;
display: grid;
@@ -1051,8 +1058,15 @@ h3 {
align-items: center;
}
.message-session-head {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.message-pending-summary,
.message-runtime-summary,
.message-session-summary,
.message-trace-summary {
min-width: 0;
color: var(--muted);
@@ -1060,21 +1074,18 @@ h3 {
overflow-wrap: anywhere;
}
.message-pending-grid {
.message-pending-grid,
.message-runtime-grid,
.message-session-grid {
min-width: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 5px;
}
.message-runtime-grid {
min-width: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 5px;
}
.message-pending-row {
.message-pending-row,
.message-runtime-row,
.message-session-row {
min-width: 0;
display: grid;
grid-template-columns: minmax(70px, auto) minmax(0, 1fr) auto;
@@ -1086,14 +1097,7 @@ h3 {
}
.message-runtime-row {
min-width: 0;
display: grid;
grid-template-columns: minmax(132px, 0.8fr) minmax(0, 1.2fr) auto;
gap: 5px;
align-items: center;
padding: 5px 6px;
background: rgba(24, 27, 24, 0.68);
border: 1px solid var(--line-soft);
}
.message-runtime-row-wide {
@@ -1106,34 +1110,29 @@ h3 {
.message-pending-key,
.message-runtime-key,
.message-pending-value {
.message-session-key,
.message-pending-value,
.message-runtime-value,
.message-session-value {
min-width: 0;
font-size: 10px;
overflow-wrap: anywhere;
}
.message-runtime-value {
min-width: 0;
font-size: 10px;
overflow-wrap: anywhere;
word-break: break-word;
}
.message-pending-key {
.message-pending-key,
.message-runtime-key,
.message-session-key {
color: var(--dim);
font-family: var(--mono);
}
.message-runtime-key {
color: var(--dim);
font-family: var(--mono);
}
.message-pending-value {
color: var(--text);
}
.message-runtime-value {
.message-pending-value,
.message-runtime-value,
.message-session-value {
color: var(--text);
}
@@ -2463,6 +2462,7 @@ tbody tr:last-child td {
.message-pending-context,
.message-runtime-path,
.message-session-context,
.message-trace,
.message-m3-evidence,
.message-actions {
@@ -2471,7 +2471,9 @@ tbody tr:last-child td {
.message-pending-grid,
.message-runtime-grid,
.message-pending-row {
.message-pending-row,
.message-session-grid,
.message-session-row {
grid-template-columns: 1fr;
}