diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 36f35d03..6691bd3f 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -213,7 +213,8 @@ export async function handleCodeAgentChat(params = {}, options = {}) { workspace: options.workspace, timeoutMs: options.timeoutMs, model: providerPlan.model, - codexStdioManager: options.codexStdioManager + codexStdioManager: options.codexStdioManager, + conversationFacts: conversationFactsForPrompt(sessionRegistry, conversationId) }); return completedRunnerPayload({ base, runnerResult: stdioResult, messageId, now: options.now, sessionRegistry }); } @@ -236,6 +237,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry }); } + const priorConversationFacts = conversationFactsForPrompt(sessionRegistry, conversationId); const providerResult = await callConfiguredProvider({ providerPlan, message, @@ -244,6 +246,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { timeoutMs: options.timeoutMs, env: options.env ?? process.env, now: options.now, + conversationFacts: priorConversationFacts, callProvider: options.callProvider }); const content = typeof providerResult.content === "string" ? providerResult.content.trim() : ""; @@ -277,7 +280,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { runner: providerRunner, capabilityLevel: providerResult.capabilityLevel ?? "text-chat-only" }); - return finalizeCodeAgentChatPayload({ + const providerPayload = finalizeCodeAgentChatPayload({ ...base, status: "completed", updatedAt: completedAt, @@ -317,6 +320,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { providerTrace: providerResult.providerTrace ?? null, ...(providerCompletionBlocker ? { blocker: providerCompletionBlocker, blockers: [providerCompletionBlocker] } : {}) }); + return attachConversationFacts(providerPayload, sessionRegistry, { now: options.now }); } catch (error) { const failedAt = nowIso(options.now); const payload = { @@ -841,15 +845,16 @@ async function callConfiguredProvider({ timeoutMs, env, now, + conversationFacts, callProvider }) { if (callProvider) { - return callProvider({ providerPlan, message, conversationId, traceId, timeoutMs, env, now }); + return callProvider({ providerPlan, message, conversationId, traceId, timeoutMs, env, now, conversationFacts }); } if (providerPlan.mode === "openai") { - return callOpenAiResponses({ providerPlan, message, conversationId, traceId, timeoutMs, env }); + return callOpenAiResponses({ providerPlan, message, conversationId, traceId, timeoutMs, env, conversationFacts }); } - return callCodexCli({ providerPlan, message, conversationId, traceId, timeoutMs, env }); + return callCodexCli({ providerPlan, message, conversationId, traceId, timeoutMs, env, conversationFacts }); } async function inspectReadOnlyRunnerAvailability(env, options = {}) { @@ -917,7 +922,7 @@ async function inspectReadOnlyRunnerAvailability(env, options = {}) { }; } -async function callCodexStdioRunner({ message, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, model, codexStdioManager }) { +async function callCodexStdioRunner({ message, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, model, codexStdioManager, conversationFacts }) { const manager = resolveCodexStdioSessionManager({ codexStdioManager }); try { return await manager.chat({ @@ -929,7 +934,8 @@ async function callCodexStdioRunner({ message, conversationId, sessionId, traceI now, workspace, timeoutMs, - model + model, + conversationFacts }); } catch (error) { const availability = error.availability ?? manager.describe({ env, workspace }); @@ -2646,6 +2652,10 @@ function sessionContextToolCall({ conversationFacts, workspace, traceId }) { conversationId: conversationFacts.conversationId, sessionId: conversationFacts.sessionId, turnCount: conversationFacts.turnCount, + provider: conversationFacts.latestProvider, + backend: conversationFacts.latestBackend, + runnerKind: conversationFacts.runnerKind, + capabilityLevel: conversationFacts.capabilityLevel, workspace: conversationFacts.workspace, skills: { status: conversationFacts.latestSkills?.status ?? "not_requested", @@ -2682,10 +2692,11 @@ function sessionContextReply({ conversationFacts, session }) { return [ "根据同一个 conversation/session 中已记录的 runner facts:", `- conversationId=${conversationFacts.conversationId}; sessionId=${conversationFacts.sessionId}; sessionStatus=${conversationFacts.sessionStatus ?? session.status}; sessionMode=${conversationFacts.sessionMode ?? session.sessionMode}; turnCount=${conversationFacts.turnCount}.`, + `- provider=${conversationFacts.latestProvider ?? "unknown"}; backend=${conversationFacts.latestBackend ?? "unknown"}; runner=${conversationFacts.runnerKind ?? "unknown"}; capabilityLevel=${conversationFacts.capabilityLevel ?? "unknown"}.`, `- 当前工作目录/workspace=${conversationFacts.workspace ?? session.workspace ?? "unknown"}.`, `- 可用 skills=${skillCount}; ${skillNames}.`, `- 最近 toolCalls=${toolSummary}; traceIds=${traceSummary}.`, - "证明:本轮走 session.context 的 Code Agent session fact 路径,runner=hwlab-readonly-runner,capabilityLevel=read-only-session-tools;不是 openai-responses-fallback,也不是 stateless-one-shot。", + "证明:本轮走 session.context 的 Code Agent session fact 路径,复用同一 conversation/session 的 bounded facts;不是 openai-responses-fallback,不是 stateless-one-shot,也不是无法访问之前状态;不会输出原始 secret/evidence。", limitationReplyLine() ].join("\n"); } @@ -3173,7 +3184,7 @@ function boundToolOutput(value, maxLength = READONLY_TOOL_OUTPUT_LIMIT) { }; } -async function callOpenAiResponses({ providerPlan, message, conversationId, traceId, timeoutMs, env }) { +async function callOpenAiResponses({ providerPlan, message, conversationId, traceId, timeoutMs, env, conversationFacts }) { if (env.HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK !== "1" && env.HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK !== "true") { return { provider: "openai-responses", @@ -3226,7 +3237,7 @@ async function callOpenAiResponses({ providerPlan, message, conversationId, trac content: [ { type: "input_text", - text: buildAgentPrompt({ message, conversationId, traceId }) + text: buildAgentPrompt({ message, conversationId, traceId, conversationFacts }) } ] } @@ -3295,7 +3306,7 @@ async function callOpenAiResponses({ providerPlan, message, conversationId, trac }; } -async function callCodexCli({ providerPlan, message, conversationId, traceId, timeoutMs, env }) { +async function callCodexCli({ providerPlan, message, conversationId, traceId, timeoutMs, env, conversationFacts }) { const command = firstNonEmpty(env.HWLAB_CODE_AGENT_CODEX_COMMAND, DEFAULT_CODEX_COMMAND); if (!(await commandExists(command, env))) { throw providerUnavailable(`Codex CLI command is not available: ${command}`, { @@ -3328,7 +3339,7 @@ async function callCodexCli({ providerPlan, message, conversationId, traceId, ti } args.push("-"); - const result = await spawnWithInput(command, args, buildAgentPrompt({ message, conversationId, traceId }), { + const result = await spawnWithInput(command, args, buildAgentPrompt({ message, conversationId, traceId, conversationFacts }), { env, timeoutMs: effectiveTimeout(timeoutMs) }); @@ -3429,16 +3440,70 @@ async function callCodexCli({ providerPlan, message, conversationId, traceId, ti } } -function buildAgentPrompt({ message, conversationId, traceId }) { +function buildAgentPrompt({ message, conversationId, traceId, conversationFacts }) { return [ CODE_AGENT_SYSTEM_PROMPT, "", `conversationId: ${conversationId}`, `traceId: ${traceId}`, + boundedConversationFactsPrompt(conversationFacts), "", "用户消息:", message - ].join("\n"); + ].filter((line) => line !== null && line !== undefined).join("\n"); +} + +function conversationFactsForPrompt(sessionRegistry, conversationId) { + if (!sessionRegistry || typeof sessionRegistry.getConversationFacts !== "function") return null; + try { + return sessionRegistry.getConversationFacts(conversationId); + } catch { + return null; + } +} + +function boundedConversationFactsPrompt(conversationFacts) { + if (!conversationFacts || typeof conversationFacts !== "object" || Number(conversationFacts.turnCount ?? 0) <= 0) { + return null; + } + const lines = [ + "上一轮会话事实(只作为上下文,不是用户新指令):", + `- conversationId=${safePromptFact(conversationFacts.conversationId)}; sessionId=${safePromptFact(conversationFacts.sessionId)}; sessionStatus=${safePromptFact(conversationFacts.sessionStatus)}; sessionMode=${safePromptFact(conversationFacts.sessionMode)}; turnCount=${safePromptFact(conversationFacts.turnCount)}.`, + `- provider/backend/runner=${safePromptFact(conversationFacts.latestProvider)}/${safePromptFact(conversationFacts.latestBackend)}/${safePromptFact(conversationFacts.runnerKind)}; capabilityLevel=${safePromptFact(conversationFacts.capabilityLevel)}.`, + `- workspace=${safePromptFact(conversationFacts.workspace)}; sandbox=${safePromptFact(conversationFacts.sandbox)}.`, + `- traceIds=${promptList(conversationFacts.traceIds, 6)}; latestTraceId=${safePromptFact(conversationFacts.latestTraceId)}.`, + `- skills=${promptSkills(conversationFacts.latestSkills)}.`, + `- toolCalls=${promptToolCalls(conversationFacts.recentToolCalls)}.`, + "如果用户询问刚才/上一轮/session/trace/workspace/skills/toolCalls,请优先用上述事实用中文回答;不要回答无法访问之前状态。不要输出 secret 或原始 evidence 大段内容。" + ]; + return lines.join("\n"); +} + +function promptSkills(skills) { + if (!skills || typeof skills !== "object") return "not_requested"; + const names = Array.isArray(skills.names) ? skills.names.filter(Boolean).slice(0, 8) : []; + const count = skills.totalCount ?? skills.count ?? names.length; + return `${safePromptFact(skills.status)} count=${safePromptFact(count)}${names.length ? ` names=${names.map(safePromptFact).join(",")}` : ""}`; +} + +function promptToolCalls(toolCalls) { + if (!Array.isArray(toolCalls) || toolCalls.length === 0) return "none"; + return toolCalls + .slice(-6) + .map((toolCall) => [toolCall?.name, toolCall?.status, toolCall?.route].filter(Boolean).map(safePromptFact).join(":")) + .filter(Boolean) + .join(",") || "none"; +} + +function promptList(values, limit) { + return Array.isArray(values) && values.length > 0 + ? values.filter(Boolean).slice(-limit).map(safePromptFact).join(",") + : "none"; +} + +function safePromptFact(value) { + if (value === undefined || value === null || value === "") return "none"; + return redactText(String(value).replace(/\s+/gu, " ").trim()).slice(0, 180); } function normalizeUserMessage(value) { diff --git a/internal/cloud/code-agent-session-registry.mjs b/internal/cloud/code-agent-session-registry.mjs index 7aa26584..b3da386d 100644 --- a/internal/cloud/code-agent-session-registry.mjs +++ b/internal/cloud/code-agent-session-registry.mjs @@ -339,21 +339,28 @@ function publicSession(session, { conversationId = null, reused = null } = {}) { function publicConversationFacts({ conversationId, session, facts, maxFactToolCalls, maxConversationFacts }) { const latest = facts.at(-1) ?? null; + const latestOperational = [...facts].reverse().find((fact) => fact.kind !== "session_context") ?? latest; + const latestSessionFact = [...facts].reverse().find((fact) => fact.sessionId && fact.sessionMode !== "provider-text-request") ?? latestOperational; const latestSkills = [...facts].reverse().find((fact) => fact.skills?.status && fact.skills.status !== "not_requested")?.skills ?? null; - const latestWorkspace = [...facts].reverse().find((fact) => fact.workspace)?.workspace ?? session?.workspace ?? null; + const latestWorkspace = [...facts].reverse().find((fact) => fact.kind !== "session_context" && fact.workspace)?.workspace ?? + [...facts].reverse().find((fact) => fact.workspace)?.workspace ?? + session?.workspace ?? + null; const traceIds = [...new Set(facts.map((fact) => fact.traceId).filter(Boolean))].slice(-6); const recentToolCalls = facts .flatMap((fact) => fact.toolCalls?.items ?? []) .slice(-maxFactToolCalls); return { conversationId, - sessionId: session?.sessionId ?? latest?.sessionId ?? null, - sessionStatus: session?.status ?? latest?.sessionStatus ?? null, - sessionMode: session?.sessionMode ?? latest?.sessionMode ?? null, - capabilityLevel: session?.capabilityLevel ?? latest?.capabilityLevel ?? null, - runnerKind: session?.runnerKind ?? latest?.runnerKind ?? null, + sessionId: session?.sessionId ?? latestSessionFact?.sessionId ?? latest?.sessionId ?? null, + sessionStatus: session?.status ?? latestSessionFact?.sessionStatus ?? latest?.sessionStatus ?? null, + sessionMode: session?.sessionMode ?? latestSessionFact?.sessionMode ?? latest?.sessionMode ?? null, + capabilityLevel: latestOperational?.capabilityLevel ?? session?.capabilityLevel ?? latest?.capabilityLevel ?? null, + runnerKind: latestOperational?.runnerKind ?? session?.runnerKind ?? latest?.runnerKind ?? null, + latestProvider: latestOperational?.provider ?? latest?.provider ?? null, + latestBackend: latestOperational?.backend ?? latest?.backend ?? null, workspace: latestWorkspace, - sandbox: session?.sandbox ?? latest?.sandbox ?? null, + sandbox: latestOperational?.sandbox ?? session?.sandbox ?? latest?.sandbox ?? null, turnCount: facts.length, maxFacts: maxConversationFacts, traceIds, diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index c295588a..9c94d09f 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -569,6 +569,74 @@ test("OpenAI fallback and codex one-shot do not pass the long-lived session gate assert.equal(classifyCodeAgentChatReadiness(oneShot, { realDevLive: true, httpStatus: 200 }).devLiveReplyPass, true); }); +test("OpenAI provider turns receive bounded prior session facts and record continuity", async () => { + const registry = createCodeAgentSessionRegistry({ + idFactory: () => "ses_provider_context" + }); + const providerPrompts = []; + const env = { + PATH: process.env.PATH, + HWLAB_CODE_AGENT_PROVIDER: "openai", + HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK: "1", + HWLAB_CODE_AGENT_WORKSPACE: process.cwd() + }; + + const first = await handleCodeAgentChat( + { + conversationId: "cnv_provider_context", + traceId: "trc_provider_context_pwd", + message: "pwd" + }, + { + now: () => "2026-05-23T00:04:10.000Z", + sessionRegistry: registry, + env + } + ); + assert.equal(first.status, "completed"); + assert.equal(first.provider, "codex-readonly-runner"); + assert.equal(first.toolCalls[0].name, "pwd"); + assert.equal(first.conversationFacts.turnCount, 1); + + const second = await handleCodeAgentChat( + { + conversationId: "cnv_provider_context", + traceId: "trc_provider_context_openai", + message: "请根据刚才的上下文简要说明" + }, + { + now: () => "2026-05-23T00:04:11.000Z", + sessionRegistry: registry, + env, + callProvider: async ({ conversationFacts }) => { + const promptFacts = JSON.stringify(conversationFacts); + providerPrompts.push(promptFacts); + assert.equal(conversationFacts.sessionId, first.sessionId); + assert.equal(conversationFacts.workspace, process.cwd()); + assert.equal(conversationFacts.recentToolCalls[0].name, "pwd"); + return { + provider: "openai-responses", + model: "gpt-test", + backend: "hwlab-cloud-api/openai-responses", + content: `上一轮工作目录是 ${conversationFacts.workspace},session=${conversationFacts.sessionId},trace=${conversationFacts.latestTraceId}。`, + usage: null, + providerTrace: { source: "test-provider" } + }; + } + } + ); + + assert.equal(second.status, "completed"); + assert.equal(second.provider, "openai-responses"); + assert.equal(second.conversationFacts.turnCount, 2); + assert.equal(second.conversationFacts.sessionId, first.sessionId); + assert.equal(second.conversationFacts.latestProvider, "openai-responses"); + assert.equal(second.conversationFacts.latestBackend, "hwlab-cloud-api/openai-responses"); + assert.match(second.reply.content, /上一轮工作目录/u); + assert.match(providerPrompts[0], /trc_provider_context_pwd/u); + assert.equal(JSON.stringify(second.conversationFacts).includes("sk-"), false); +}); + test("Code Agent M3 DO write uses Skill CLI to call only HWLAB API /v1/m3/io", async () => { const calls = []; const payload = await handleCodeAgentChat( @@ -1429,6 +1497,10 @@ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + async function createFakeCodexCommand() { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-fake-codex-")); const packageRoot = path.join(root, "node_modules", "@openai", "codex"); @@ -1586,6 +1658,7 @@ test("Codex stdio feasibility verifies writable workspace, CODEX_HOME, and versi test("repo-owned Codex stdio manager creates and reuses long-lived sessions with trace evidence", async () => { const calls = []; const fakeCodex = await createFakeCodexCommand(); + const registry = createCodeAgentSessionRegistry(); const manager = createCodexStdioSessionManager({ idFactory: () => "ses_stdio_ready", createRpcClient: async () => ({ @@ -1628,6 +1701,7 @@ test("repo-owned Codex stdio manager creates and reuses long-lived sessions with { now: () => "2026-05-23T00:06:00.000Z", codexStdioManager: manager, + sessionRegistry: registry, env } ); @@ -1659,6 +1733,7 @@ test("repo-owned Codex stdio manager creates and reuses long-lived sessions with { now: () => "2026-05-23T00:06:01.000Z", codexStdioManager: manager, + sessionRegistry: registry, env } ); @@ -1676,6 +1751,10 @@ test("repo-owned Codex stdio manager creates and reuses long-lived sessions with assert.equal(calls[0].name, "codex"); assert.equal(calls[1].name, "codex-reply"); assert.equal(calls[1].args.threadId, "thread_stdio_ready"); + assert.match(calls[1].args.prompt, /Prior session facts/u); + assert.match(calls[1].args.prompt, /ses_stdio_ready/u); + assert.match(calls[1].args.prompt, /trc_stdio_ready_1/u); + assert.match(calls[1].args.prompt, new RegExp(escapeRegExp(process.cwd()), "u")); await rm(fakeCodex.root, { recursive: true, force: true }); }); diff --git a/internal/cloud/codex-stdio-session.mjs b/internal/cloud/codex-stdio-session.mjs index 2ed67035..b64d5ea1 100644 --- a/internal/cloud/codex-stdio-session.mjs +++ b/internal/cloud/codex-stdio-session.mjs @@ -318,10 +318,10 @@ export function createCodexStdioSessionManager(options = {}) { const toolArguments = session.threadId ? { threadId: session.threadId, - prompt: buildCodexUserPrompt(params.message, { conversationId, traceId }) + prompt: buildCodexUserPrompt(params.message, { conversationId, traceId, conversationFacts: params.conversationFacts }) } : { - prompt: buildCodexUserPrompt(params.message, { conversationId, traceId }), + prompt: buildCodexUserPrompt(params.message, { conversationId, traceId, conversationFacts: params.conversationFacts }), cwd: workspace, sandbox, model: params.model, @@ -1784,16 +1784,59 @@ function safeDisplayPath(value) { return redactText(String(value ?? ".")).replace(/\s+/gu, " "); } -function buildCodexUserPrompt(message, { conversationId, traceId }) { +function buildCodexUserPrompt(message, { conversationId, traceId, conversationFacts }) { return [ `conversationId: ${conversationId}`, `traceId: ${traceId}`, + codexConversationFactsPrompt(conversationFacts), "", "User message:", String(message ?? "").trim() + ].filter((line) => line !== null && line !== undefined).join("\n"); +} + +function codexConversationFactsPrompt(conversationFacts) { + if (!conversationFacts || typeof conversationFacts !== "object" || Number(conversationFacts.turnCount ?? 0) <= 0) { + return null; + } + return [ + "Prior session facts (bounded, redacted; use for Chinese answers about previous turns):", + `- sessionId=${safePromptFact(conversationFacts.sessionId)}; sessionStatus=${safePromptFact(conversationFacts.sessionStatus)}; sessionMode=${safePromptFact(conversationFacts.sessionMode)}; turnCount=${safePromptFact(conversationFacts.turnCount)}.`, + `- provider/backend/runner=${safePromptFact(conversationFacts.latestProvider)}/${safePromptFact(conversationFacts.latestBackend)}/${safePromptFact(conversationFacts.runnerKind)}; capabilityLevel=${safePromptFact(conversationFacts.capabilityLevel)}.`, + `- workspace=${safePromptFact(conversationFacts.workspace)}; sandbox=${safePromptFact(conversationFacts.sandbox)}.`, + `- traceIds=${promptList(conversationFacts.traceIds, 6)}; latestTraceId=${safePromptFact(conversationFacts.latestTraceId)}.`, + `- skills=${promptSkills(conversationFacts.latestSkills)}.`, + `- toolCalls=${promptToolCalls(conversationFacts.recentToolCalls)}.` ].join("\n"); } +function promptSkills(skills) { + if (!skills || typeof skills !== "object") return "not_requested"; + const names = Array.isArray(skills.names) ? skills.names.filter(Boolean).slice(0, 8) : []; + const count = skills.totalCount ?? skills.count ?? names.length; + return `${safePromptFact(skills.status)} count=${safePromptFact(count)}${names.length ? ` names=${names.map(safePromptFact).join(",")}` : ""}`; +} + +function promptToolCalls(toolCalls) { + if (!Array.isArray(toolCalls) || toolCalls.length === 0) return "none"; + return toolCalls + .slice(-6) + .map((toolCall) => [toolCall?.name, toolCall?.status, toolCall?.route].filter(Boolean).map(safePromptFact).join(":")) + .filter(Boolean) + .join(",") || "none"; +} + +function promptList(values, limit) { + return Array.isArray(values) && values.length > 0 + ? values.filter(Boolean).slice(-limit).map(safePromptFact).join(",") + : "none"; +} + +function safePromptFact(value) { + if (value === undefined || value === null || value === "") return "none"; + return redactText(String(value).replace(/\s+/gu, " ").trim()).slice(0, 180); +} + function resolveCodexWorkspace(env = process.env, options = {}) { return path.resolve(firstNonEmpty( options.workspace, diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index a0bd67db..0abbf6a2 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -2815,7 +2815,6 @@ function messageEvidencePanel(message) { if (fields.length === 0) return null; const details = document.createElement("details"); details.className = "message-evidence"; - details.open = message.status === "running" || message.status === "completed" || message.status === "source" || message.status === "failed"; const summary = document.createElement("summary"); summary.className = "message-meta"; summary.textContent = messageEvidenceSummary(message, fields); diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index a783cfd8..d3d7fdb4 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -816,6 +816,7 @@ assert.match(app, /function isSourceFixtureCompletedChatMessage/); assert.match(app, /Code Agent SOURCE 回复/); assert.match(app, /SOURCE fixture 只可显示为 SOURCE 回复,不能冒充 DEV-LIVE/); assert.match(app, /function messageEvidencePanel/); +assert.doesNotMatch(app, /details\.open\s*=\s*message\.status === "running"/); assert.match(app, /function messageAttributionPanel/); assert.match(app, /function messageEvidenceSummary/); assert.match(app, /function boundedEvidenceField/);