From 5d4d6d809b674007629df87a704c4ac7314afc78 Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sun, 24 May 2026 11:14:48 +0000 Subject: [PATCH] fix: preserve code agent session continuity --- scripts/dev-cloud-workbench-smoke.test.mjs | 24 ++ scripts/src/dev-cloud-workbench-smoke-lib.mjs | 403 +++++++++++++++++- web/hwlab-cloud-web/app.mjs | 332 ++++++++++++++- web/hwlab-cloud-web/code-agent-status.mjs | 16 +- .../code-agent-status.test.mjs | 46 ++ web/hwlab-cloud-web/scripts/check.mjs | 14 +- web/hwlab-cloud-web/styles.css | 66 +-- 7 files changed, 845 insertions(+), 56 deletions(-) diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index 990fb13c..8bfe4f5f 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -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") { diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 6ce52349..e2764307 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -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.", diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 39e650aa..92488ce7 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -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 && diff --git a/web/hwlab-cloud-web/code-agent-status.mjs b/web/hwlab-cloud-web/code-agent-status.mjs index 03de51a3..4c4f4e4a 100644 --- a/web/hwlab-cloud-web/code-agent-status.mjs +++ b/web/hwlab-cloud-web/code-agent-status.mjs @@ -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; } diff --git a/web/hwlab-cloud-web/code-agent-status.test.mjs b/web/hwlab-cloud-web/code-agent-status.test.mjs index 010a0509..6f9d4e1d 100644 --- a/web/hwlab-cloud-web/code-agent-status.test.mjs +++ b/web/hwlab-cloud-web/code-agent-status.test.mjs @@ -100,6 +100,52 @@ test("does not mark legacy MCP-shaped Codex stdio as app-server long-lived sessi assert.equal(summary.runtimePathLabel, "DEGRADED:MCP/其他 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: { diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index adcfb9d3..818233fe 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -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/); diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css index 36abcec5..66906a13 100644 --- a/web/hwlab-cloud-web/styles.css +++ b/web/hwlab-cloud-web/styles.css @@ -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; }