From c5d00cb47ddb7236980de8e0fbc938b23fdeaae0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 8 Aug 2026 01:45:44 -0700 Subject: [PATCH] fix(ui): keep task transcripts in the task sidebar (#120463) * fix(ui): keep task transcripts in the task sidebar * refactor(ui): split background task rail rendering * fix(ui): reset stale task rail views * fix(sessions): honor context usage provenance Co-authored-by: Sarah Fortune * fix(qa): split suite runtime agent process below line cap --------- Co-authored-by: Sarah Fortune --- extensions/qa-lab/src/qa-cli-process.ts | 322 ++++++++++++++++++ ...-runtime-agent-process.integration.test.ts | 2 +- .../src/suite-runtime-agent-process.test.ts | 2 +- .../qa-lab/src/suite-runtime-agent-process.ts | 317 +---------------- extensions/qa-lab/src/suite-runtime-agent.ts | 2 +- .../run/attempt-normalization.ts | 4 +- .../agent-session-loop-correctness.test.ts | 70 +--- ...ssion-loop-resource-loader.test-support.ts | 66 ++++ .../agent-runner.misc.runreplyagent.test.ts | 4 + .../reply/commands-context-report.test.ts | 1 + src/auto-reply/reply/commands-status.test.ts | 8 +- src/auto-reply/reply/inbound-meta.test.ts | 1 + src/auto-reply/reply/memory-flush.ts | 6 +- src/auto-reply/reply/reply-state.test.ts | 26 +- src/auto-reply/reply/session-fork.test.ts | 3 +- src/commands/sessions.ts | 4 +- src/commands/status.summary.test.ts | 22 ++ src/commands/status.test.ts | 16 +- src/config/sessions/types.ts | 4 +- src/status/summary.ts | 19 +- ui/src/i18n/locales/en.ts | 5 + .../pages/chat/background-tasks.e2e.test.ts | 112 +++++- ui/src/pages/chat/chat-history.ts | 18 +- ui/src/pages/chat/chat-pane-base.ts | 1 + ui/src/pages/chat/chat-pane-header.ts | 6 +- ui/src/pages/chat/chat-pane-render.ts | 4 +- .../chat/chat-pane-session-access.test.ts | 4 +- ui/src/pages/chat/chat-pane.test.ts | 2 +- ui/src/pages/chat/chat-view.test.ts | 6 +- ui/src/pages/chat/chat-view.ts | 59 +++- .../components/chat-background-task-row.ts | 7 +- .../chat-background-tasks-concurrency.test.ts | 41 +-- .../chat-background-tasks-render.ts | 218 ++++++++++++ .../chat-background-tasks-status.ts | 3 +- .../components/chat-background-tasks.test.ts | 271 +++++++++++---- .../chat/components/chat-background-tasks.ts | 302 ++++++---------- .../components/chat-background-tasks.types.ts | 17 +- ui/src/pages/chat/components/chat-thread.ts | 9 +- ui/src/styles/chat/sidebar.css | 17 + 39 files changed, 1251 insertions(+), 750 deletions(-) create mode 100644 extensions/qa-lab/src/qa-cli-process.ts create mode 100644 src/agents/sessions/agent-session-loop-resource-loader.test-support.ts create mode 100644 ui/src/pages/chat/components/chat-background-tasks-render.ts diff --git a/extensions/qa-lab/src/qa-cli-process.ts b/extensions/qa-lab/src/qa-cli-process.ts new file mode 100644 index 000000000000..b0fdad7518fc --- /dev/null +++ b/extensions/qa-lab/src/qa-cli-process.ts @@ -0,0 +1,322 @@ +// Qa Lab plugin module runs CLI processes and parses their structured output. +import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; +import path from "node:path"; +import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; +import { + appendQaChildOutput, + appendQaChildOutputTail, + createQaChildOutputCapture, + createQaChildOutputTail, + formatQaChildOutputTail, + QA_CHILD_STDOUT_MAX_BYTES, + readQaChildOutput, +} from "./child-output.js"; +import { QaSuiteInfraError } from "./errors.js"; +import { resolveQaNodeExecPath } from "./node-exec.js"; +import { createQaPosixCommandSettlement } from "./posix-command-settlement.js"; +import type { QaSuiteRuntimeEnv } from "./suite-runtime-types.js"; +import { resolveQaWindowsSystem32ExePath } from "./windows-system-tools.js"; + +const ANSI_ESCAPE_PATTERN = new RegExp(String.raw`\x1B\[[0-?]*[ -/]*[@-~]`, "g"); + +function stripAnsiCodes(text: string) { + return text.replace(ANSI_ESCAPE_PATTERN, ""); +} + +function findBalancedJsonEnd(text: string, startIndex: number) { + const opening = text[startIndex]; + const firstClosing = opening === "{" ? "}" : opening === "[" ? "]" : ""; + if (!firstClosing) { + return -1; + } + + const stack = [firstClosing]; + let inString = false; + let escaping = false; + for (let index = startIndex + 1; index < text.length; index += 1) { + const char = text[index]; + if (inString) { + if (escaping) { + escaping = false; + } else if (char === "\\") { + escaping = true; + } else if (char === '"') { + inString = false; + } + continue; + } + if (char === '"') { + inString = true; + } else if (char === "{" || char === "[") { + stack.push(char === "{" ? "}" : "]"); + } else if (char === "}" || char === "]") { + if (stack.at(-1) !== char) { + return -1; + } + stack.pop(); + if (stack.length === 0) { + return index; + } + } + } + return -1; +} + +function parseBalancedJsonPayloadStart(text: string) { + const trimmedStart = text.search(/\S/u); + if (trimmedStart < 0) { + return undefined; + } + const char = text[trimmedStart]; + if (char !== "{" && char !== "[") { + return undefined; + } + const end = findBalancedJsonEnd(text, trimmedStart); + if (end <= trimmedStart) { + return undefined; + } + try { + return JSON.parse(text.slice(trimmedStart, end + 1)) as unknown; + } catch { + return undefined; + } +} + +function isJsonRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function isStructuredDiagnosticJson(value: unknown) { + if (!isJsonRecord(value)) { + return false; + } + const level = value.level ?? value.logLevel ?? value.severity; + if (typeof level !== "string") { + return false; + } + return ( + typeof value.message === "string" || + typeof value.msg === "string" || + typeof value.time === "string" || + typeof value.timestamp === "string" + ); +} + +function isMemorySearchJsonPayload(value: unknown) { + return isJsonRecord(value) && Array.isArray(value.results); +} + +function isMemoryStatusJsonPayload(value: unknown) { + if (Array.isArray(value)) { + return true; + } + return isJsonRecord(value) && value.command === "memory" && value.subcommand === "status"; +} + +function resolveQaCliJsonPayloadMatcher(args: readonly string[]) { + if (!args.includes("--json")) { + return undefined; + } + if (args[0] === "memory" && args[1] === "search") { + return isMemorySearchJsonPayload; + } + if (args[0] === "memory" && args[1] === "status") { + return isMemoryStatusJsonPayload; + } + return undefined; +} + +function parseQaCliJsonOutput(text: string, args: readonly string[]) { + const cleaned = stripAnsiCodes(text).trim(); + if (!cleaned) { + return {}; + } + const matchesExpectedPayload = resolveQaCliJsonPayloadMatcher(args); + try { + return JSON.parse(cleaned) as unknown; + } catch { + // Some startup repair logs are emitted on stdout before command JSON. + const lines = cleaned.split(/\r?\n/); + const candidates: unknown[] = []; + for (const [index, line] of lines.entries()) { + const candidate = line.trimStart(); + if (candidate !== line || (!candidate.startsWith("{") && !candidate.startsWith("["))) { + continue; + } + const jsonTail = lines.slice(index).join("\n"); + try { + candidates.push(JSON.parse(jsonTail) as unknown); + } catch { + const balanced = parseBalancedJsonPayloadStart(jsonTail); + if (balanced !== undefined) { + candidates.push(balanced); + } + } + } + const expectedPayload = candidates.find((value) => matchesExpectedPayload?.(value) === true); + if (expectedPayload !== undefined) { + return expectedPayload; + } + const payload = candidates.toReversed().find((value) => !isStructuredDiagnosticJson(value)); + if (payload !== undefined) { + return payload; + } + const diagnosticOnly = candidates.at(-1); + if (diagnosticOnly !== undefined) { + return diagnosticOnly; + } + + // Keep a line-oriented fallback for compact payloads followed by diagnostics. + for (const line of lines.toReversed()) { + const candidate = line.trim(); + if (!candidate.startsWith("{") && !candidate.startsWith("[")) { + continue; + } + try { + return JSON.parse(candidate) as unknown; + } catch { + // Keep looking for the actual payload line. + } + } + throw new Error(`qa cli returned non-JSON stdout: ${truncateUtf16Safe(cleaned, 240)}`); + } +} + +function killQaCliWindowsProcessTree(child: Pick) { + if (child.pid) { + const result = spawnSync( + resolveQaWindowsSystem32ExePath("taskkill.exe"), + ["/PID", String(child.pid), "/T", "/F"], + { + stdio: "ignore", + windowsHide: true, + timeout: 5_000, + }, + ); + if (!result.error && result.status === 0) { + return; + } + } + child.kill("SIGKILL"); +} + +async function runQaCli( + env: Pick< + QaSuiteRuntimeEnv, + "gateway" | "repoRoot" | "primaryModel" | "alternateModel" | "providerMode" + >, + args: string[], + opts?: { timeoutMs?: number; json?: boolean; env?: NodeJS.ProcessEnv }, +) { + const stdout = createQaChildOutputCapture(); + const stdoutTail = createQaChildOutputTail(); + const stderr = createQaChildOutputTail(); + const distEntryPath = path.join(env.repoRoot, "dist", "index.js"); + const nodeExecPath = await resolveQaNodeExecPath(); + await new Promise((resolve, reject) => { + const child = spawn(nodeExecPath, [distEntryPath, ...args], { + cwd: env.gateway.tempRoot, + env: { + ...env.gateway.runtimeEnv, + ...opts?.env, + }, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + }); + const timeoutMs = resolveTimerTimeoutMs(opts?.timeoutMs, 60_000); + const rejectTimeout = () => { + const stdoutText = formatQaChildOutputTail(stdoutTail, "qa cli stdout"); + const stderrText = formatQaChildOutputTail(stderr, "qa cli stderr"); + const diagnostics = [ + stdoutText ? `stdout:\n${stdoutText}` : "", + stderrText ? `stderr:\n${stderrText}` : "", + ] + .filter(Boolean) + .join("\n"); + return new QaSuiteInfraError( + "qa_cli_timeout", + `qa cli timed out: openclaw ${args.join(" ")}${diagnostics ? `\n${diagnostics}` : ""}`, + ); + }; + const getExitError = (code: number | null) => { + if (code === 0) { + if (stdout.exceeded) { + return new Error( + `qa cli stdout exceeded ${QA_CHILD_STDOUT_MAX_BYTES} bytes; refusing to parse truncated output`, + ); + } + return undefined; + } + const stderrText = formatQaChildOutputTail(stderr, "qa cli stderr"); + return new Error(`qa cli failed (${code ?? "unknown"}): ${stderrText}`); + }; + if (process.platform !== "win32") { + createQaPosixCommandSettlement({ + child, + settlementFailureMessage: "qa cli settlement failed", + executionTimeoutMs: timeoutMs, + forceKillAfterMs: 0, + initialSignal: "SIGKILL", + onSettled: (outcome) => { + const primary = outcome.primary; + const primaryError = + primary.type === "spawn-error" || primary.type === "stream-error" + ? primary.error + : primary.type === "timeout" + ? rejectTimeout() + : getExitError(primary.type === "exit" ? primary.exitCode : 1); + if (outcome.settlementFailure) { + reject( + primaryError + ? new AggregateError( + [primaryError, outcome.settlementFailure], + "qa cli command and settlement failed", + ) + : outcome.settlementFailure, + ); + return; + } + if (primaryError) { + reject(primaryError); + return; + } + resolve(); + }, + onStderrData: (chunk) => appendQaChildOutputTail(stderr, chunk), + onStdoutData: (chunk) => { + appendQaChildOutput(stdout, chunk); + appendQaChildOutputTail(stdoutTail, chunk); + }, + processGroupId: child.pid, + verifyAfterMs: 500, + }); + return; + } + const timeout = setTimeout(() => { + killQaCliWindowsProcessTree(child); + reject(rejectTimeout()); + }, timeoutMs); + child.stdout.on("data", (chunk) => { + appendQaChildOutput(stdout, chunk); + appendQaChildOutputTail(stdoutTail, chunk); + }); + child.stderr.on("data", (chunk) => appendQaChildOutputTail(stderr, chunk)); + child.once("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.once("close", (code) => { + clearTimeout(timeout); + const error = getExitError(code); + return error ? reject(error) : resolve(); + }); + }); + const text = readQaChildOutput(stdout).trim(); + if (!opts?.json) { + return text; + } + return parseQaCliJsonOutput(text, args); +} + +export { runQaCli }; diff --git a/extensions/qa-lab/src/suite-runtime-agent-process.integration.test.ts b/extensions/qa-lab/src/suite-runtime-agent-process.integration.test.ts index 374b50bbad93..cb03fce196a9 100644 --- a/extensions/qa-lab/src/suite-runtime-agent-process.integration.test.ts +++ b/extensions/qa-lab/src/suite-runtime-agent-process.integration.test.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { runQaCli } from "./suite-runtime-agent-process.js"; +import { runQaCli } from "./qa-cli-process.js"; const cleanups: Array<() => Promise> = []; diff --git a/extensions/qa-lab/src/suite-runtime-agent-process.test.ts b/extensions/qa-lab/src/suite-runtime-agent-process.test.ts index 64002e8eb90b..ce1f577291b6 100644 --- a/extensions/qa-lab/src/suite-runtime-agent-process.test.ts +++ b/extensions/qa-lab/src/suite-runtime-agent-process.test.ts @@ -30,12 +30,12 @@ vi.mock("./suite-runtime-agent-session.js", () => ({ })); import { QA_CHILD_STDERR_TAIL_BYTES, QA_CHILD_STDOUT_MAX_BYTES } from "./child-output.js"; +import { runQaCli } from "./qa-cli-process.js"; import { findManagedDreamingCronJob, listCronJobs, readDoctorMemoryStatus, runAgentPrompt, - runQaCli, startAgentRun, waitForAgentRun, waitForAgentHistoryReply, diff --git a/extensions/qa-lab/src/suite-runtime-agent-process.ts b/extensions/qa-lab/src/suite-runtime-agent-process.ts index 1ab1db4a2ad0..e751cac08a82 100644 --- a/extensions/qa-lab/src/suite-runtime-agent-process.ts +++ b/extensions/qa-lab/src/suite-runtime-agent-process.ts @@ -1,31 +1,17 @@ // Qa Lab plugin module implements suite runtime agent process behavior. -import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; import { randomUUID } from "node:crypto"; -import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { - appendQaChildOutput, - appendQaChildOutputTail, - createQaChildOutputCapture, - createQaChildOutputTail, - formatQaChildOutputTail, - QA_CHILD_STDOUT_MAX_BYTES, - readQaChildOutput, -} from "./child-output.js"; import { QaSuiteInfraError } from "./errors.js"; import { extractGatewayMessageText } from "./gateway-log-sentinel.js"; -import { resolveQaNodeExecPath } from "./node-exec.js"; -import { createQaPosixCommandSettlement } from "./posix-command-settlement.js"; +import { runQaCli } from "./qa-cli-process.js"; import { liveTurnTimeoutMs } from "./suite-runtime-agent-common.js"; import { readSessionTranscriptSummary } from "./suite-runtime-agent-session.js"; import { waitForGatewayHealthy, waitForTransportReady } from "./suite-runtime-gateway.js"; import type { QaDreamingStatus, QaSuiteRuntimeEnv } from "./suite-runtime-types.js"; import { resolveQaGatewayTimeoutWithGraceMs } from "./timer-timeouts.js"; -import { resolveQaWindowsSystem32ExePath } from "./windows-system-tools.js"; type QaMemorySearchResult = { results?: Array<{ snippet?: string; text?: string; path?: string }>; @@ -62,7 +48,6 @@ type QaAgentWaitResult = { terminalReply?: QaAgentTerminalReply; }; -const ANSI_ESCAPE_PATTERN = new RegExp(String.raw`\x1B\[[0-?]*[ -/]*[@-~]`, "g"); const MANAGED_DREAMING_CRON_MARKER = "[managed-by=memory-core.short-term-promotion]"; const MANAGED_DREAMING_CRON_NAME = "Memory Dreaming Promotion"; const MANAGED_DREAMING_PROMPT = "__openclaw_memory_core_short_term_promotion_dream__"; @@ -72,305 +57,6 @@ const QA_HISTORY_RETRY_MAX_MS = 5_000; const QA_TRANSCRIPT_EVIDENCE_TIMEOUT_MS = 5_000; const QA_TRANSCRIPT_EVIDENCE_POLL_MS = 50; -function stripAnsiCodes(text: string) { - return text.replace(ANSI_ESCAPE_PATTERN, ""); -} - -function findBalancedJsonEnd(text: string, startIndex: number) { - const opening = text[startIndex]; - const firstClosing = opening === "{" ? "}" : opening === "[" ? "]" : ""; - if (!firstClosing) { - return -1; - } - - const stack = [firstClosing]; - let inString = false; - let escaping = false; - for (let index = startIndex + 1; index < text.length; index += 1) { - const char = text[index]; - if (inString) { - if (escaping) { - escaping = false; - } else if (char === "\\") { - escaping = true; - } else if (char === '"') { - inString = false; - } - continue; - } - if (char === '"') { - inString = true; - } else if (char === "{" || char === "[") { - stack.push(char === "{" ? "}" : "]"); - } else if (char === "}" || char === "]") { - if (stack.at(-1) !== char) { - return -1; - } - stack.pop(); - if (stack.length === 0) { - return index; - } - } - } - return -1; -} - -function parseBalancedJsonPayloadStart(text: string) { - const trimmedStart = text.search(/\S/u); - if (trimmedStart < 0) { - return undefined; - } - const char = text[trimmedStart]; - if (char !== "{" && char !== "[") { - return undefined; - } - const end = findBalancedJsonEnd(text, trimmedStart); - if (end <= trimmedStart) { - return undefined; - } - try { - return JSON.parse(text.slice(trimmedStart, end + 1)) as unknown; - } catch { - return undefined; - } -} - -function isJsonRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - -function isStructuredDiagnosticJson(value: unknown) { - if (!isJsonRecord(value)) { - return false; - } - const level = value.level ?? value.logLevel ?? value.severity; - if (typeof level !== "string") { - return false; - } - return ( - typeof value.message === "string" || - typeof value.msg === "string" || - typeof value.time === "string" || - typeof value.timestamp === "string" - ); -} - -function isMemorySearchJsonPayload(value: unknown) { - return isJsonRecord(value) && Array.isArray(value.results); -} - -function isMemoryStatusJsonPayload(value: unknown) { - if (Array.isArray(value)) { - return true; - } - return isJsonRecord(value) && value.command === "memory" && value.subcommand === "status"; -} - -function resolveQaCliJsonPayloadMatcher(args: readonly string[]) { - if (!args.includes("--json")) { - return undefined; - } - if (args[0] === "memory" && args[1] === "search") { - return isMemorySearchJsonPayload; - } - if (args[0] === "memory" && args[1] === "status") { - return isMemoryStatusJsonPayload; - } - return undefined; -} - -function parseQaCliJsonOutput(text: string, args: readonly string[]) { - const cleaned = stripAnsiCodes(text).trim(); - if (!cleaned) { - return {}; - } - const matchesExpectedPayload = resolveQaCliJsonPayloadMatcher(args); - try { - return JSON.parse(cleaned) as unknown; - } catch { - // Some startup repair logs are emitted on stdout before command JSON. - const lines = cleaned.split(/\r?\n/); - const candidates: unknown[] = []; - for (const [index, line] of lines.entries()) { - const candidate = line.trimStart(); - if (candidate !== line || (!candidate.startsWith("{") && !candidate.startsWith("["))) { - continue; - } - const jsonTail = lines.slice(index).join("\n"); - try { - candidates.push(JSON.parse(jsonTail) as unknown); - } catch { - const balanced = parseBalancedJsonPayloadStart(jsonTail); - if (balanced !== undefined) { - candidates.push(balanced); - } - } - } - const expectedPayload = candidates.find((value) => matchesExpectedPayload?.(value) === true); - if (expectedPayload !== undefined) { - return expectedPayload; - } - const payload = candidates.toReversed().find((value) => !isStructuredDiagnosticJson(value)); - if (payload !== undefined) { - return payload; - } - const diagnosticOnly = candidates.at(-1); - if (diagnosticOnly !== undefined) { - return diagnosticOnly; - } - - // Keep a line-oriented fallback for compact payloads followed by diagnostics. - for (const line of lines.toReversed()) { - const candidate = line.trim(); - if (!candidate.startsWith("{") && !candidate.startsWith("[")) { - continue; - } - try { - return JSON.parse(candidate) as unknown; - } catch { - // Keep looking for the actual payload line. - } - } - throw new Error(`qa cli returned non-JSON stdout: ${truncateUtf16Safe(cleaned, 240)}`); - } -} - -function killQaCliWindowsProcessTree(child: Pick) { - if (child.pid) { - const result = spawnSync( - resolveQaWindowsSystem32ExePath("taskkill.exe"), - ["/PID", String(child.pid), "/T", "/F"], - { - stdio: "ignore", - windowsHide: true, - timeout: 5_000, - }, - ); - if (!result.error && result.status === 0) { - return; - } - } - child.kill("SIGKILL"); -} - -async function runQaCli( - env: Pick< - QaSuiteRuntimeEnv, - "gateway" | "repoRoot" | "primaryModel" | "alternateModel" | "providerMode" - >, - args: string[], - opts?: { timeoutMs?: number; json?: boolean; env?: NodeJS.ProcessEnv }, -) { - const stdout = createQaChildOutputCapture(); - const stdoutTail = createQaChildOutputTail(); - const stderr = createQaChildOutputTail(); - const distEntryPath = path.join(env.repoRoot, "dist", "index.js"); - const nodeExecPath = await resolveQaNodeExecPath(); - await new Promise((resolve, reject) => { - const child = spawn(nodeExecPath, [distEntryPath, ...args], { - cwd: env.gateway.tempRoot, - env: { - ...env.gateway.runtimeEnv, - ...opts?.env, - }, - detached: process.platform !== "win32", - stdio: ["ignore", "pipe", "pipe"], - }); - const timeoutMs = resolveTimerTimeoutMs(opts?.timeoutMs, 60_000); - const rejectTimeout = () => { - const stdoutText = formatQaChildOutputTail(stdoutTail, "qa cli stdout"); - const stderrText = formatQaChildOutputTail(stderr, "qa cli stderr"); - const diagnostics = [ - stdoutText ? `stdout:\n${stdoutText}` : "", - stderrText ? `stderr:\n${stderrText}` : "", - ] - .filter(Boolean) - .join("\n"); - return new QaSuiteInfraError( - "qa_cli_timeout", - `qa cli timed out: openclaw ${args.join(" ")}${diagnostics ? `\n${diagnostics}` : ""}`, - ); - }; - const getExitError = (code: number | null) => { - if (code === 0) { - if (stdout.exceeded) { - return new Error( - `qa cli stdout exceeded ${QA_CHILD_STDOUT_MAX_BYTES} bytes; refusing to parse truncated output`, - ); - } - return undefined; - } - const stderrText = formatQaChildOutputTail(stderr, "qa cli stderr"); - return new Error(`qa cli failed (${code ?? "unknown"}): ${stderrText}`); - }; - if (process.platform !== "win32") { - createQaPosixCommandSettlement({ - child, - settlementFailureMessage: "qa cli settlement failed", - executionTimeoutMs: timeoutMs, - forceKillAfterMs: 0, - initialSignal: "SIGKILL", - onSettled: (outcome) => { - const primary = outcome.primary; - const primaryError = - primary.type === "spawn-error" || primary.type === "stream-error" - ? primary.error - : primary.type === "timeout" - ? rejectTimeout() - : getExitError(primary.type === "exit" ? primary.exitCode : 1); - if (outcome.settlementFailure) { - reject( - primaryError - ? new AggregateError( - [primaryError, outcome.settlementFailure], - "qa cli command and settlement failed", - ) - : outcome.settlementFailure, - ); - return; - } - if (primaryError) { - reject(primaryError); - return; - } - resolve(); - }, - onStderrData: (chunk) => appendQaChildOutputTail(stderr, chunk), - onStdoutData: (chunk) => { - appendQaChildOutput(stdout, chunk); - appendQaChildOutputTail(stdoutTail, chunk); - }, - processGroupId: child.pid, - verifyAfterMs: 500, - }); - return; - } - const timeout = setTimeout(() => { - killQaCliWindowsProcessTree(child); - reject(rejectTimeout()); - }, timeoutMs); - child.stdout.on("data", (chunk) => { - appendQaChildOutput(stdout, chunk); - appendQaChildOutputTail(stdoutTail, chunk); - }); - child.stderr.on("data", (chunk) => appendQaChildOutputTail(stderr, chunk)); - child.once("error", (error) => { - clearTimeout(timeout); - reject(error); - }); - child.once("close", (code) => { - clearTimeout(timeout); - const error = getExitError(code); - return error ? reject(error) : resolve(); - }); - }); - const text = readQaChildOutput(stdout).trim(); - if (!opts?.json) { - return text; - } - return parseQaCliJsonOutput(text, args); -} - async function startAgentRun( env: Pick, params: { @@ -741,7 +427,6 @@ export { listCronJobs, readDoctorMemoryStatus, runAgentPrompt, - runQaCli, startAgentRun, waitForAgentHistoryReply, waitForAgentRun, diff --git a/extensions/qa-lab/src/suite-runtime-agent.ts b/extensions/qa-lab/src/suite-runtime-agent.ts index f5304fb5610d..b769b3275ba0 100644 --- a/extensions/qa-lab/src/suite-runtime-agent.ts +++ b/extensions/qa-lab/src/suite-runtime-agent.ts @@ -13,11 +13,11 @@ export { listCronJobs, readDoctorMemoryStatus, runAgentPrompt, - runQaCli, startAgentRun, waitForAgentHistoryReply, waitForAgentRun, } from "./suite-runtime-agent-process.js"; +export { runQaCli } from "./qa-cli-process.js"; export { ensureImageGenerationConfigured, extractMediaPathFromText, diff --git a/src/agents/embedded-agent-runner/run/attempt-normalization.ts b/src/agents/embedded-agent-runner/run/attempt-normalization.ts index deee26a615fd..1a2abe763051 100644 --- a/src/agents/embedded-agent-runner/run/attempt-normalization.ts +++ b/src/agents/embedded-agent-runner/run/attempt-normalization.ts @@ -164,7 +164,9 @@ export async function normalizeEmbeddedRunAttempt(input: { const promptCacheLastCallUsage = normalizeUsage(attempt.promptCache?.lastCallUsage as UsageLike); const callUsage = resolveLatestCallUsage({ currentAttemptCandidates: [currentAttemptAssistantUsage, promptCacheLastCallUsage], - carriedCandidates: [input.lastRunPromptUsage, lastAssistantUsage], + // The latest assistant sentinel must invalidate stale carried usage; reversing this order + // would resurrect a prior exact value after the runtime reports context as unavailable. + carriedCandidates: [lastAssistantUsage, input.lastRunPromptUsage], }); const attemptUsage = attempt.attemptUsage ?? callUsage.currentAttempt; mergeUsageIntoAccumulator(input.usageAccumulator, attemptUsage); diff --git a/src/agents/sessions/agent-session-loop-correctness.test.ts b/src/agents/sessions/agent-session-loop-correctness.test.ts index b9df326b9bec..e1ffa2dee8a7 100644 --- a/src/agents/sessions/agent-session-loop-correctness.test.ts +++ b/src/agents/sessions/agent-session-loop-correctness.test.ts @@ -14,17 +14,19 @@ const streamMocks = vi.hoisted(() => ({ import type { AgentTool } from "../runtime/index.js"; import { agentSessionAutomaticCompaction } from "./agent-session-compaction.js"; +import { + createCompactionHandlers, + createResourceLoader, +} from "./agent-session-loop-resource-loader.test-support.js"; import type { AgentSessionEvent } from "./agent-session-types.js"; import { AgentSession } from "./agent-session.js"; import { AuthStorage } from "./auth-storage.js"; -import { createExtensionRuntime } from "./extensions/loader.js"; -import type { LoadExtensionsResult, ToolDefinition } from "./extensions/types.js"; +import type { ToolDefinition } from "./extensions/types.js"; import { ModelRegistry } from "./model-registry.js"; import type { ResourceLoader } from "./resource-loader.js"; import { createAgentSession, createAgentSessionForEmbeddedRunner } from "./sdk.js"; import { SessionManager } from "./session-manager.js"; import { SettingsManager } from "./settings-manager.js"; -import { createSyntheticSourceInfo } from "./source-info.js"; const testModel: Model = { id: "test-model", @@ -114,68 +116,6 @@ function mockInvalidThenTextSummary(recoveredText: string) { return () => requests; } -function createResourceLoader( - handlers: Map Promise>> = new Map(), -): ResourceLoader { - const extensionsResult: LoadExtensionsResult = { - extensions: - handlers.size > 0 - ? [ - { - path: "", - resolvedPath: "", - sourceInfo: createSyntheticSourceInfo("", { - source: "temporary", - }), - handlers, - tools: new Map(), - messageRenderers: new Map(), - commands: new Map(), - flags: new Map(), - shortcuts: new Map(), - }, - ] - : [], - errors: [], - runtime: createExtensionRuntime(), - }; - return { - getExtensions: () => extensionsResult, - getSkills: () => ({ skills: [], diagnostics: [] }), - getPrompts: () => ({ prompts: [], diagnostics: [] }), - getThemes: () => ({ themes: [], diagnostics: [] }), - getAgentsFiles: () => ({ agentsFiles: [] }), - getSystemPrompt: () => undefined, - getAppendSystemPrompt: () => [], - extendResources: () => {}, - reload: async () => {}, - }; -} - -function createCompactionHandlers() { - return new Map Promise>>([ - [ - "session_before_compact", - [ - async (event: unknown) => { - const preparation = ( - event as { - preparation: { firstKeptEntryId: string; tokensBefore: number }; - } - ).preparation; - return { - compaction: { - summary: "condensed history", - firstKeptEntryId: preparation.firstKeptEntryId, - tokensBefore: preparation.tokensBefore, - }, - }; - }, - ], - ], - ]); -} - async function createTestSession( options: { model?: Model; diff --git a/src/agents/sessions/agent-session-loop-resource-loader.test-support.ts b/src/agents/sessions/agent-session-loop-resource-loader.test-support.ts new file mode 100644 index 000000000000..ec961a254924 --- /dev/null +++ b/src/agents/sessions/agent-session-loop-resource-loader.test-support.ts @@ -0,0 +1,66 @@ +import { createExtensionRuntime } from "./extensions/loader.js"; +import type { LoadExtensionsResult } from "./extensions/types.js"; +import type { ResourceLoader } from "./resource-loader.js"; +import { createSyntheticSourceInfo } from "./source-info.js"; + +export function createResourceLoader( + handlers: Map Promise>> = new Map(), +): ResourceLoader { + const extensionsResult: LoadExtensionsResult = { + extensions: + handlers.size > 0 + ? [ + { + path: "", + resolvedPath: "", + sourceInfo: createSyntheticSourceInfo("", { + source: "temporary", + }), + handlers, + tools: new Map(), + messageRenderers: new Map(), + commands: new Map(), + flags: new Map(), + shortcuts: new Map(), + }, + ] + : [], + errors: [], + runtime: createExtensionRuntime(), + }; + return { + getExtensions: () => extensionsResult, + getSkills: () => ({ skills: [], diagnostics: [] }), + getPrompts: () => ({ prompts: [], diagnostics: [] }), + getThemes: () => ({ themes: [], diagnostics: [] }), + getAgentsFiles: () => ({ agentsFiles: [] }), + getSystemPrompt: () => undefined, + getAppendSystemPrompt: () => [], + extendResources: () => {}, + reload: async () => {}, + }; +} + +export function createCompactionHandlers() { + return new Map Promise>>([ + [ + "session_before_compact", + [ + async (event: unknown) => { + const preparation = ( + event as { + preparation: { firstKeptEntryId: string; tokensBefore: number }; + } + ).preparation; + return { + compaction: { + summary: "condensed history", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + }, + }; + }, + ], + ], + ]); +} diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts index 527aeb82eea5..5ed417eaa286 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -557,6 +557,8 @@ describe("runReplyAgent auto-compaction token update", () => { sessionId: "session", updatedAt: Date.now(), totalTokens: 200_000, + totalTokensFresh: true, + totalTokensVersion: 1 as const, }; await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); compactState.compactEmbeddedAgentSessionMock.mockRejectedValueOnce(new GatewayDrainingError()); @@ -2779,6 +2781,8 @@ describe("runReplyAgent fallback reasoning tags", () => { sessionId: "session", updatedAt: Date.now(), totalTokens: 1_000_000, + totalTokensFresh: true, + totalTokensVersion: 1 as const, compactionCount: 0, }, }); diff --git a/src/auto-reply/reply/commands-context-report.test.ts b/src/auto-reply/reply/commands-context-report.test.ts index 5d6447312271..080dda3b7c40 100644 --- a/src/auto-reply/reply/commands-context-report.test.ts +++ b/src/auto-reply/reply/commands-context-report.test.ts @@ -51,6 +51,7 @@ function makeParams( ...(options?.sessionId ? { sessionId: options.sessionId } : {}), totalTokens: options?.totalTokens ?? 123, totalTokensFresh: options?.totalTokensFresh ?? true, + totalTokensVersion: 1 as const, inputTokens: 100, outputTokens: 23, systemPromptReport: { diff --git a/src/auto-reply/reply/commands-status.test.ts b/src/auto-reply/reply/commands-status.test.ts index 1893fd5573f3..831faa4a5be2 100644 --- a/src/auto-reply/reply/commands-status.test.ts +++ b/src/auto-reply/reply/commands-status.test.ts @@ -715,7 +715,7 @@ describe("buildStatusReply subagent summary", () => { model: "kimi-k2.7-code", totalTokens: 0, totalTokensFresh: true, - totalTokensVersion: 1, + totalTokensVersion: 1 as const, }, sessionKey: "agent:main:main", parentSessionKey: "agent:main:main", @@ -1405,7 +1405,7 @@ describe("buildStatusReply subagent summary", () => { }, totalTokens: 49_000, totalTokensFresh: true, - totalTokensVersion: 1, + totalTokensVersion: 1 as const, contextTokens: 1_048_576, }, sessionKey: "agent:main:main", @@ -2027,6 +2027,8 @@ describe("buildStatusReply subagent summary", () => { sessionId: "sess-status-codex-context", updatedAt: 0, totalTokens: 25_000, + totalTokensFresh: true, + totalTokensVersion: 1, }, sessionKey: "agent:main:main", parentSessionKey: "agent:main:main", @@ -2073,6 +2075,8 @@ describe("buildStatusReply subagent summary", () => { sessionId: "sess-status-codex-stale-context", updatedAt: 0, totalTokens: 181_000, + totalTokensFresh: true, + totalTokensVersion: 1, contextTokens: 400_000, }, sessionKey: "agent:main:main", diff --git a/src/auto-reply/reply/inbound-meta.test.ts b/src/auto-reply/reply/inbound-meta.test.ts index 1ec9b9bf1a05..a57b29d0b2e9 100644 --- a/src/auto-reply/reply/inbound-meta.test.ts +++ b/src/auto-reply/reply/inbound-meta.test.ts @@ -436,6 +436,7 @@ describe("buildInboundUserContextPrefix", () => { const entry = createGoalSessionEntry("active"); entry.totalTokens = 10; entry.totalTokensFresh = true; + entry.totalTokensVersion = 1; entry.goal = { ...entry.goal!, tokenBudget: 10 }; expect(buildInboundUserContextPrefix({} as TemplateContext, undefined, entry)).toBe(""); diff --git a/src/auto-reply/reply/memory-flush.ts b/src/auto-reply/reply/memory-flush.ts index 5c5a52f54dcc..d44b42c69ed0 100644 --- a/src/auto-reply/reply/memory-flush.ts +++ b/src/auto-reply/reply/memory-flush.ts @@ -92,7 +92,7 @@ export function resolveResponsesServerCompactionThreshold(params: { } function resolveMemoryFlushGateState< - TEntry extends Pick, + TEntry extends Pick, >(params: { entry?: TEntry; tokenCount?: number; @@ -129,7 +129,7 @@ function resolveMemoryFlushGateState< export function shouldRunMemoryFlush(params: { entry?: Pick< SessionEntry, - "totalTokens" | "totalTokensFresh" | "compactionCount" | "memoryFlush" + "totalTokens" | "totalTokensFresh" | "totalTokensVersion" | "compactionCount" | "memoryFlush" >; /** * Optional token count override for flush gating. When provided, this value is @@ -154,7 +154,7 @@ export function shouldRunMemoryFlush(params: { } export function shouldRunPreflightCompaction(params: { - entry?: Pick; + entry?: Pick; /** * Optional projected token count override for pre-run compaction gating. * When provided, this value is treated as a fresh estimate and used instead diff --git a/src/auto-reply/reply/reply-state.test.ts b/src/auto-reply/reply/reply-state.test.ts index f051bc919e12..91d57863c074 100644 --- a/src/auto-reply/reply/reply-state.test.ts +++ b/src/auto-reply/reply/reply-state.test.ts @@ -262,7 +262,7 @@ describe("shouldRunMemoryFlush", () => { it("requires totalTokens and threshold", () => { expect( shouldRunMemoryFlush({ - entry: { totalTokens: 0 }, + entry: { totalTokens: 0, totalTokensFresh: true, totalTokensVersion: 1 }, contextWindowTokens: 16_000, reserveTokensFloor: 20_000, softThresholdTokens: 4_000, @@ -284,7 +284,7 @@ describe("shouldRunMemoryFlush", () => { it("skips when under threshold", () => { expect( shouldRunMemoryFlush({ - entry: { totalTokens: 10_000 }, + entry: { totalTokens: 10_000, totalTokensFresh: true, totalTokensVersion: 1 }, contextWindowTokens: 100_000, reserveTokensFloor: 20_000, softThresholdTokens: 10_000, @@ -295,7 +295,7 @@ describe("shouldRunMemoryFlush", () => { it("triggers at the threshold boundary", () => { expect( shouldRunMemoryFlush({ - entry: { totalTokens: 85 }, + entry: { totalTokens: 85, totalTokensFresh: true, totalTokensVersion: 1 }, contextWindowTokens: 100, reserveTokensFloor: 10, softThresholdTokens: 5, @@ -308,6 +308,8 @@ describe("shouldRunMemoryFlush", () => { shouldRunMemoryFlush({ entry: { totalTokens: 90_000, + totalTokensFresh: true, + totalTokensVersion: 1, compactionCount: 2, memoryFlush: { kind: "succeeded", compactionCount: 2 }, }, @@ -321,7 +323,12 @@ describe("shouldRunMemoryFlush", () => { it("runs when above threshold and not flushed", () => { expect( shouldRunMemoryFlush({ - entry: { totalTokens: 96_000, compactionCount: 1 }, + entry: { + totalTokens: 96_000, + totalTokensFresh: true, + totalTokensVersion: 1, + compactionCount: 1, + }, contextWindowTokens: 100_000, reserveTokensFloor: 5_000, softThresholdTokens: 2_000, @@ -337,14 +344,23 @@ describe("shouldRunMemoryFlush", () => { }; for (const entry of [ - { totalTokens: 95_000, compactionCount: 1 }, { totalTokens: 95_000, + totalTokensFresh: true, + totalTokensVersion: 1 as const, + compactionCount: 1, + }, + { + totalTokens: 95_000, + totalTokensFresh: true, + totalTokensVersion: 1 as const, compactionCount: 2, memoryFlush: { kind: "succeeded" as const, compactionCount: 1 }, }, { totalTokens: 95_000, + totalTokensFresh: true, + totalTokensVersion: 1 as const, compactionCount: 3, memoryFlush: { kind: "succeeded" as const, compactionCount: 2 }, }, diff --git a/src/auto-reply/reply/session-fork.test.ts b/src/auto-reply/reply/session-fork.test.ts index 4c32093ee03f..a315637f1e36 100644 --- a/src/auto-reply/reply/session-fork.test.ts +++ b/src/auto-reply/reply/session-fork.test.ts @@ -247,6 +247,7 @@ describe("forkSessionEntryFromParent", () => { sessionId: "parent-session", totalTokens: 150_000, totalTokensFresh: true, + totalTokensVersion: 1 as const, updatedAt: 1, }, ); @@ -371,7 +372,7 @@ describe("forkSessionEntryFromParent", () => { await expect(resolveParentForkDecision({ parentEntry, storePath })).resolves.toMatchObject({ status: "fork", - parentTokens: 4_567, + parentTokens: 67, }); }); diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index bf2fa5af560a..f1320fb575d6 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -19,7 +19,7 @@ import { import { resolveRuntimePolicySessionKey } from "../auto-reply/reply/runtime-policy-session-key.js"; import { normalizeChatType } from "../channels/chat-type.js"; import { getRuntimeConfig } from "../config/config.js"; -import { resolveFreshSessionTotalTokens } from "../config/sessions.js"; +import { resolveFreshSessionTotalTokens, resolveSessionTotalTokens } from "../config/sessions.js"; import { listSessionEntriesReadOnly } from "../config/sessions/session-accessor.js"; import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js"; import type { SessionEntry } from "../config/sessions/types.js"; @@ -460,7 +460,7 @@ export async function sessionsCommand( const modelRef = row.displayModelRef; return { ...r, - totalTokens: resolveFreshSessionTotalTokens(r) ?? null, + totalTokens: resolveSessionTotalTokens(r) ?? null, totalTokensFresh: resolveFreshSessionTotalTokens(r) !== undefined, // Prefer row-level context tokens, then config/model lookup, so JSON // mirrors the terminal percentage calculation. diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index ac10fa707bdb..4655bed3c11f 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -555,6 +555,28 @@ describe("getStatusSummary", () => { ).toBe(false); }); + it("keeps stale totals visible without deriving context utilization", async () => { + statusSummaryMocks.listSessionEntries.mockReturnValue( + toSessionEntrySummaries({ + "agent:main:main": { + sessionId: "stale-total", + updatedAt: Date.now(), + totalTokens: 50_000, + totalTokensFresh: false, + }, + }), + ); + + const summary = await getStatusSummary(); + + expect(summary.sessions.recent[0]).toMatchObject({ + totalTokens: 50_000, + totalTokensFresh: false, + remainingTokens: null, + percentUsed: null, + }); + }); + it("uses bundled provider static catalogs for cold status context", async () => { vi.mocked(statusSummaryRuntime.resolveConfiguredStatusModelRef).mockReturnValue({ provider: "google", diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index a494944f0489..6d0acef549d1 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -231,6 +231,8 @@ function createSessionStatusRows() { const recent = Object.entries(store).map(([key, entry]) => { const contextTokens = typeof entry.contextTokens === "number" ? entry.contextTokens : null; const total = typeof entry.totalTokens === "number" ? entry.totalTokens : null; + const freshTotal = + total !== null && entry.totalTokensFresh && entry.totalTokensVersion === 1 ? total : null; return { agentId: agent.id, key, @@ -243,13 +245,17 @@ function createSessionStatusRows() { inputTokens: entry.inputTokens, outputTokens: entry.outputTokens, totalTokens: total, - totalTokensFresh: typeof entry.totalTokens === "number" ? entry.totalTokensFresh : false, + totalTokensFresh: freshTotal !== null, cacheRead: entry.cacheRead, cacheWrite: entry.cacheWrite, remainingTokens: - total !== null && contextTokens !== null ? Math.max(0, contextTokens - total) : null, + freshTotal !== null && contextTokens !== null + ? Math.max(0, contextTokens - freshTotal) + : null, percentUsed: - total !== null && contextTokens ? Math.round((total / contextTokens) * 100) : null, + freshTotal !== null && contextTokens + ? Math.round((freshTotal / contextTokens) * 100) + : null, model: typeof entry.model === "string" ? entry.model : null, contextTokens, flags: [ @@ -1139,8 +1145,8 @@ describe("statusCommand", () => { const payload = JSON.parse(getLastRuntimeLog()); expect(payload.sessions.recent[0].totalTokens).toBe(5000); expect(payload.sessions.recent[0].totalTokensFresh).toBe(false); - expect(payload.sessions.recent[0].percentUsed).toBe(50); - expect(payload.sessions.recent[0].remainingTokens).toBe(5000); + expect(payload.sessions.recent[0].percentUsed).toBeNull(); + expect(payload.sessions.recent[0].remainingTokens).toBeNull(); }); it("prints formatted lines with verbose cache details", async () => { diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 10915637ef0f..37805105f46a 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -800,7 +800,7 @@ export function mergeSessionEntryPreserveActivity( }); } -function resolveSessionTotalTokensValue(entry?: Pick | null) { +export function resolveSessionTotalTokens(entry?: Pick | null) { const total = entry?.totalTokens; if (typeof total !== "number" || !Number.isFinite(total) || total < 0) { return undefined; @@ -811,7 +811,7 @@ function resolveSessionTotalTokensValue(entry?: Pick | null, ): number | undefined { - const total = resolveSessionTotalTokensValue(entry); + const total = resolveSessionTotalTokens(entry); if (total === undefined) { return undefined; } diff --git a/src/status/summary.ts b/src/status/summary.ts index 621c3d77e423..d138d7a53a18 100644 --- a/src/status/summary.ts +++ b/src/status/summary.ts @@ -11,7 +11,11 @@ import { } from "../config/sessions/model-override-provenance.js"; import { resolveStorePath } from "../config/sessions/paths.js"; import { listSessionEntriesReadOnly } from "../config/sessions/session-accessor.js"; -import { resolveFreshSessionTotalTokens, type SessionEntry } from "../config/sessions/types.js"; +import { + resolveFreshSessionTotalTokens, + resolveSessionTotalTokens, + type SessionEntry, +} from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.js"; import { listGatewayAgentsBasic } from "../gateway/agent-list.js"; import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-summary.js"; @@ -457,13 +461,16 @@ export async function getStatusSummary( fallbackContextTokens: configContextTokens ?? undefined, allowAsyncLoad: false, }) ?? null; - const total = resolveFreshSessionTotalTokens(entry); - const totalTokensFresh = total !== undefined; + const total = resolveSessionTotalTokens(entry); + const freshTotal = resolveFreshSessionTotalTokens(entry); + const totalTokensFresh = freshTotal !== undefined; const remaining = - contextTokens != null && total !== undefined ? Math.max(0, contextTokens - total) : null; + contextTokens != null && freshTotal !== undefined + ? Math.max(0, contextTokens - freshTotal) + : null; const pct = - contextTokens && contextTokens > 0 && total !== undefined - ? Math.min(999, Math.round((total / contextTokens) * 100)) + contextTokens && contextTokens > 0 && freshTotal !== undefined + ? Math.min(999, Math.round((freshTotal / contextTokens) * 100)) : null; const runtime = resolveSessionRuntimeLabel({ cfg, diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 620653778f52..42620a8393fd 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -5177,9 +5177,14 @@ export const en: TranslationMap = { toolUseMany: "{count} tool uses", expandTask: "Show details for {title}", detailTitle: "Task details", + transcriptTitle: "Task transcript", backToTasks: "Back to background tasks", + backToDetail: "Back to task details", detailLoading: "Loading task details…", detailFailed: "Could not load task details.", + transcriptLoading: "Loading task transcript…", + transcriptEmpty: "No transcript messages yet.", + transcriptFailed: "Could not load task transcript.", prompt: "Prompt", output: "Output", promptUnavailable: "Prompt unavailable.", diff --git a/ui/src/pages/chat/background-tasks.e2e.test.ts b/ui/src/pages/chat/background-tasks.e2e.test.ts index 68614528db2a..ae0a4f1fec6e 100644 --- a/ui/src/pages/chat/background-tasks.e2e.test.ts +++ b/ui/src/pages/chat/background-tasks.e2e.test.ts @@ -2,7 +2,7 @@ import { mkdir, rm } from "node:fs/promises"; import path from "node:path"; import { expect, it } from "vitest"; import { createControlUiE2eSuite } from "../../e2e/control-ui-e2e-suite.test-support.ts"; -import { installMockGateway } from "../../test-helpers/control-ui-e2e.ts"; +import { installMockGateway, type MockGatewayRequest } from "../../test-helpers/control-ui-e2e.ts"; const suite = createControlUiE2eSuite({ name: "Control UI chat background-tasks rail mocked Gateway E2E", @@ -15,6 +15,19 @@ const artifactDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/chat- const baseTime = Date.now(); const chatSessionKey = "agent:main:main"; +function requestSessionKey(request: MockGatewayRequest): string | undefined { + const { params } = request; + if ( + typeof params !== "object" || + params === null || + !("sessionKey" in params) || + typeof params.sessionKey !== "string" + ) { + return undefined; + } + return params.sessionKey; +} + const runningSubagent = { id: "task-subagent", taskId: "task-subagent", @@ -113,14 +126,46 @@ suite.define(() => { thinkingLevel: null, }, }, + { + match: { sessionKey: finishedCli.sessionKey }, + response: { + messages: [ + { + content: [ + { type: "text", text: "CLI transcript stayed in the task rail." }, + ], + role: "assistant", + timestamp: Date.now(), + }, + ], + sessionId: "cli-task-transcript", + thinkingLevel: null, + }, + }, ], }, "tasks.list": { tasks: [runningSubagent, queuedCron, finishedCli] }, "tasks.get": { - task: { - ...runningSubagent, - prompt: "Trace model routing across provider and session boundaries.", - }, + cases: [ + { + match: { taskId: runningSubagent.id }, + response: { + task: { + ...runningSubagent, + prompt: "Trace model routing across provider and session boundaries.", + }, + }, + }, + { + match: { taskId: finishedCli.id }, + response: { + task: { + ...finishedCli, + prompt: "Generate a searchable media index.", + }, + }, + }, + ], }, "tasks.cancel": { found: true, @@ -218,15 +263,62 @@ suite.define(() => { expect(page.url()).toBe(chatUrl); expect( (await gateway.getRequests("chat.history")).some( - (request) => - (request.params as { sessionKey?: string }).sessionKey === - runningSubagent.childSessionKey, + (request) => requestSessionKey(request) === runningSubagent.childSessionKey, ), ).toBe(false); await page.screenshot({ path: path.join(artifactDir, "04-back-to-list.png"), fullPage: true, }); + + const mainTranscript = page.locator(".chat-main .chat-thread"); + const mainTranscriptBefore = await mainTranscript.textContent(); + await rail + .locator('[data-task-id="task-cli"]') + .getByRole("button", { name: "Show details for Generate media index" }) + .click(); + await rail.getByText("Generate a searchable media index.").waitFor(); + await page.screenshot({ + path: path.join(artifactDir, "05-cli-task-detail.png"), + fullPage: true, + }); + + await rail.getByRole("button", { name: "View transcript" }).click(); + await expect + .poll(async () => + (await gateway.getRequests("chat.history")).some( + (request) => requestSessionKey(request) === finishedCli.sessionKey, + ), + ) + .toBe(true); + const transcriptRequest = (await gateway.getRequests("chat.history")).find( + (request) => requestSessionKey(request) === finishedCli.sessionKey, + ); + expect(transcriptRequest?.params).toEqual({ + sessionKey: finishedCli.sessionKey, + limit: 100, + }); + await rail.getByText("CLI transcript stayed in the task rail.").waitFor(); + expect(await rail.locator(".chat-thread").textContent()).toContain( + "CLI transcript stayed in the task rail.", + ); + expect(page.url()).toBe(chatUrl); + expect(await mainTranscript.textContent()).toBe(mainTranscriptBefore); + expect(await rail.getByRole("button", { name: "Back to task details" }).count()).toBe(1); + await page.screenshot({ + path: path.join(artifactDir, "06-cli-task-transcript.png"), + fullPage: true, + }); + + await rail.getByRole("button", { name: "Back to task details" }).click(); + await rail.locator('[data-task-detail="task-cli"]').waitFor({ state: "visible" }); + await rail.getByText("Generate a searchable media index.").waitFor(); + expect(page.url()).toBe(chatUrl); + expect(await mainTranscript.textContent()).toBe(mainTranscriptBefore); + await page.screenshot({ + path: path.join(artifactDir, "07-cli-task-detail-restored.png"), + fullPage: true, + }); }, ); }); @@ -279,7 +371,7 @@ suite.define(() => { expect(Math.abs(previewCenter - linkCenter)).toBeLessThanOrEqual(2); expect(previewBox.y + previewBox.height).toBeLessThanOrEqual(linkBox.y); await page.screenshot({ - path: path.join(artifactDir, "05-running-task-popover-centered.png"), + path: path.join(artifactDir, "08-running-task-popover-centered.png"), fullPage: true, }); @@ -289,7 +381,7 @@ suite.define(() => { expect(await row.textContent()).toContain("CLI command"); expect(await row.textContent()).toContain("Command running"); await page.screenshot({ - path: path.join(artifactDir, "06-one-background-exec.png"), + path: path.join(artifactDir, "09-one-background-exec.png"), fullPage: true, }); }, diff --git a/ui/src/pages/chat/chat-history.ts b/ui/src/pages/chat/chat-history.ts index e7a825987c9b..4962ad3d78d5 100644 --- a/ui/src/pages/chat/chat-history.ts +++ b/ui/src/pages/chat/chat-history.ts @@ -90,7 +90,7 @@ import { handleAgentEvent, normalizePlanSnapshot, type PlanStatus } from "./tool const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/; const SYNTHETIC_TRANSCRIPT_REPAIR_RESULT = "[openclaw] missing tool result in session history; inserted synthetic error result for transcript repair."; -const CHAT_HISTORY_REQUEST_LIMIT = 100; +export const CHAT_HISTORY_REQUEST_LIMIT = 100; const STARTUP_CHAT_HISTORY_RETRY_TIMEOUT_MS = 60_000; const SESSION_MESSAGE_RELEASE_RETRY_MS = 250; const MAX_SESSION_MESSAGE_RELEASE_ATTEMPTS = 3; @@ -232,6 +232,12 @@ function shouldHideHistoryMessage(message: unknown): boolean { ); } +export function visibleChatHistoryMessages(messages: unknown): unknown[] { + return Array.isArray(messages) + ? messages.filter((message) => !shouldHideHistoryMessage(message)) + : []; +} + export function materializeVisibleAssistantStreamMessages( messages: unknown[], state: ChatState, @@ -1433,7 +1439,7 @@ export async function loadChatHistory( opts.startup === true && startupAdvertised !== false ? "chat.startup" : "chat.history"; const client = state.client; const connectionEpoch = state.connectionEpoch; - const requestKey = `${connectionEpoch}\0${method}\0${sessionKey}\0${requestAgentId ?? ""}\0${CHAT_HISTORY_REQUEST_LIMIT}`; + const requestKey = `${connectionEpoch}\u0000${method}\u0000${sessionKey}\u0000${requestAgentId ?? ""}\u0000${CHAT_HISTORY_REQUEST_LIMIT}`; const requests = getChatHistoryPaneRequests(state); const inFlight = requests.inFlightHistory; // Live events replace the rendered array while their snapshot is pending; @@ -1503,9 +1509,7 @@ export async function loadOlderChatHistoryPage( } return { ...result, - messages: (Array.isArray(result.messages) ? result.messages : []).filter( - (message) => !shouldHideHistoryMessage(message), - ), + messages: visibleChatHistoryMessages(result.messages), }; } @@ -1573,7 +1577,7 @@ async function loadChatHistoryUncached( state.chatLoading = true; setChatError(state, null); try { - const requestKey = `${connectionEpoch}\0${method}\0${sessionKey}\0${requestAgentId ?? ""}\0${CHAT_HISTORY_REQUEST_LIMIT}`; + const requestKey = `${connectionEpoch}\u0000${method}\u0000${sessionKey}\u0000${requestAgentId ?? ""}\u0000${CHAT_HISTORY_REQUEST_LIMIT}`; const res = await requestSharedChatHistory( client, requestKey, @@ -1606,7 +1610,7 @@ async function loadChatHistoryUncached( const nextPagination = resolveChatHistoryPagination(res); const nextSessionId = resolveChatHistorySessionId(res); applyChatAgentsList(state, res.agentsList, client); - const visibleMessages = messages.filter((message) => !shouldHideHistoryMessage(message)); + const visibleMessages = visibleChatHistoryMessages(messages); const previousTerminalMessages = reconcileAuthoritativeTerminalHistory({ host: state, previousMessages, diff --git a/ui/src/pages/chat/chat-pane-base.ts b/ui/src/pages/chat/chat-pane-base.ts index 367a0dcdc0d0..57aa28b43d89 100644 --- a/ui/src/pages/chat/chat-pane-base.ts +++ b/ui/src/pages/chat/chat-pane-base.ts @@ -109,6 +109,7 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { this.requestUpdate(), ); protected readonly transcript = new ChatTranscriptController(this); + protected readonly backgroundTaskTranscript = new ChatTranscriptController(this); protected readonly questionPromptState = createQuestionPromptState(() => { this.questionPrompts = listQuestionPrompts(this.questionPromptState); this.requestUpdate(); diff --git a/ui/src/pages/chat/chat-pane-header.ts b/ui/src/pages/chat/chat-pane-header.ts index 07c6443f5288..bccaca29af17 100644 --- a/ui/src/pages/chat/chat-pane-header.ts +++ b/ui/src/pages/chat/chat-pane-header.ts @@ -25,10 +25,8 @@ import { headerPlatformByClient } from "./chat-pane-shared.ts"; import { readChatSessionActionAccess } from "./chat-session-action-access.ts"; import { patchChatSessionLabel } from "./chat-state-route.ts"; import { renderCatalogTerminalButton } from "./components/catalog-terminal-button.ts"; -import { - renderBackgroundTasksToggle, - type BackgroundTasksProps, -} from "./components/chat-background-tasks.ts"; +import { renderBackgroundTasksToggle } from "./components/chat-background-tasks-render.ts"; +import type { BackgroundTasksProps } from "./components/chat-background-tasks.types.ts"; import { isChatRunWorking } from "./components/chat-composer.ts"; import { type ChatPaneHeaderAction, diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index f79c0ee01e26..0ed92e320f7f 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -213,9 +213,6 @@ export class ChatPane extends ChatPaneHeader { narrowLayout: chatLayoutWidth < WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH + (railSideDocked ? WORKSPACE_RAIL_MAX_WIDTH : 0), - onOpenSession: (sessionKey) => { - this.onPaneSessionChange?.(this.paneId, sessionKey); - }, }); const tasksSideDocked = !backgroundTasks.collapsed && !backgroundTasks.narrowLayout; // Only side-docked rails narrow the conversation region. @@ -253,6 +250,7 @@ export class ChatPane extends ChatPaneHeader { }); const props: ChatProps = { transcript: this.transcript, + backgroundTaskTranscript: this.backgroundTaskTranscript, paneId: this.paneId, sessionKey: state.sessionKey, announceTranscript: this.active, diff --git a/ui/src/pages/chat/chat-pane-session-access.test.ts b/ui/src/pages/chat/chat-pane-session-access.test.ts index 4fca63aeb513..8bc85e651428 100644 --- a/ui/src/pages/chat/chat-pane-session-access.test.ts +++ b/ui/src/pages/chat/chat-pane-session-access.test.ts @@ -179,7 +179,7 @@ describe("chat pane session access", () => { render( pane.renderPaneHeader( createSessionWorkspaceProps(state), - createBackgroundTasksProps(state, { onOpenSession: () => {} }), + createBackgroundTasksProps(state), session, false, undefined, @@ -213,7 +213,7 @@ describe("chat pane session access", () => { render( pane.renderPaneHeader( createSessionWorkspaceProps(state), - createBackgroundTasksProps(state, { onOpenSession: () => {} }), + createBackgroundTasksProps(state), session, false, undefined, diff --git a/ui/src/pages/chat/chat-pane.test.ts b/ui/src/pages/chat/chat-pane.test.ts index 97858a08dd53..4d1deb5ff94f 100644 --- a/ui/src/pages/chat/chat-pane.test.ts +++ b/ui/src/pages/chat/chat-pane.test.ts @@ -708,7 +708,7 @@ describe("chat pane catalog session lifecycle", () => { render( pane.renderPaneHeader( createSessionWorkspaceProps(state), - createBackgroundTasksProps(state, { onOpenSession: () => {} }), + createBackgroundTasksProps(state), undefined, true, undefined, diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index bae7a13c56bd..fdd0096d46c4 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -776,9 +776,9 @@ function createBackgroundTasks( loading: false, error: null, tasks: [], + view: { kind: "list" }, cancellingTaskIds: new Set(), finishedCollapsed: false, - selectedTaskId: null, taskDetails: new Map(), taskDetailErrors: new Map(), taskDetailLoadingIds: new Set(), @@ -787,8 +787,8 @@ function createBackgroundTasks( onRefresh: () => undefined, onCancel: () => undefined, onSelectTask: () => undefined, - onBackToList: () => undefined, - onOpenSession: () => undefined, + onBack: () => undefined, + onOpenTranscript: () => undefined, ...overrides, }; } diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index fafc05992910..d11f931f88e4 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -38,10 +38,8 @@ import type { ChatRunStartupStatus } from "./chat-run-startup.ts"; import type { ChatSessionCompanionThread } from "./chat-session-companion.ts"; import { renderChatViewNotices } from "./chat-view-notices.ts"; import { createChatAttachmentDropHandlers } from "./components/chat-attachments.ts"; -import { - renderBackgroundTasksRail, - type BackgroundTasksProps, -} from "./components/chat-background-tasks.ts"; +import { renderBackgroundTasksRail } from "./components/chat-background-tasks-render.ts"; +import type { BackgroundTasksProps } from "./components/chat-background-tasks.types.ts"; import type { CapabilityMenuProps, ChatComposerDisabledBanner, @@ -85,6 +83,7 @@ type ChatReplyTarget = { export type ChatProps = { transcript: ChatTranscriptController; + backgroundTaskTranscript?: ChatTranscriptController; paneId: string; sessionKey: string; announceTranscript?: boolean; @@ -390,6 +389,56 @@ export function renderChat(props: ChatProps) { }, props.transcript, ); + const backgroundTaskView = props.backgroundTasks?.view; + const backgroundTaskTranscript = props.backgroundTaskTranscript; + const backgroundTaskThread = + backgroundTaskTranscript && + backgroundTaskView?.kind === "transcript" && + backgroundTaskView.load.status === "loaded" && + backgroundTaskView.load.messages.length > 0 + ? renderChatThread( + { + paneId: `${props.paneId}:background-task-transcript`, + sessionKey: backgroundTaskView.sessionKey, + announceTranscript: false, + loading: false, + messages: backgroundTaskView.load.messages, + toolMessages: [], + streamSegments: [], + stream: null, + streamStartedAt: null, + runId: null, + queue: [], + showThinking: props.showThinking, + showToolCalls: props.showToolCalls, + persistCommentary: props.persistCommentary, + readOnly: true, + sessions: props.sessions, + sessionHost: props.sessionHost, + gatewayUrl: props.gatewayUrl, + assistantName: props.assistantName, + assistantAvatar: props.assistantAvatar, + assistantAvatarUrl: props.assistantAvatarUrl, + userId: props.userId, + userName: props.userName, + userAvatar: props.userAvatar, + basePath: props.basePath, + fullMessageAgentId: props.fullMessageAgentId, + loadFullAssistantMessage: props.loadFullAssistantMessage, + localMediaPreviewRoots: props.localMediaPreviewRoots, + assistantAttachmentAuthToken: props.assistantAttachmentAuthToken, + resolveArtifactDownload: props.resolveArtifactDownload, + canvasPluginSurfaceUrl: props.canvasPluginSurfaceUrl, + embedSandboxMode: props.embedSandboxMode, + allowExternalEmbedUrls: props.allowExternalEmbedUrls, + autoExpandToolCalls: props.autoExpandToolCalls, + onRequestUpdate: requestUpdate, + onDraftChange: () => undefined, + onSend: () => undefined, + }, + backgroundTaskTranscript, + ) + : nothing; const chatColumnFooter = renderChatComposer({ paneId: props.paneId, @@ -547,7 +596,7 @@ export function renderChat(props: ChatProps) { : ""} ${tasksDockBottom ? "chat-workbench--tasks-dock-bottom" : ""}" > ${renderSessionWorkspaceRail(props.sessionWorkspace)} - ${renderBackgroundTasksRail(props.backgroundTasks)} + ${renderBackgroundTasksRail(props.backgroundTasks, backgroundTaskThread)} ${props.sessionWorkspace?.dockDragging ? html` - ${renderTaskMeta(task, props, facts)} + ${renderTaskMeta(task, props, facts, "list")} ${detail ? html`
${detail}
` : nothing} `; @@ -185,7 +186,7 @@ export function renderTaskDetail(task: TaskSummary, props: BackgroundTasksProps) ${newest.status === "running" ? html`` : nothing} - ${renderTaskMeta(newest, props, facts)} + ${renderTaskMeta(newest, props, facts, "detail")} ${facts.active && props.canCancel ? html` diff --git a/ui/src/pages/chat/components/chat-background-tasks-concurrency.test.ts b/ui/src/pages/chat/components/chat-background-tasks-concurrency.test.ts index 078a61be5721..cbaf01eca92c 100644 --- a/ui/src/pages/chat/components/chat-background-tasks-concurrency.test.ts +++ b/ui/src/pages/chat/components/chat-background-tasks-concurrency.test.ts @@ -7,8 +7,6 @@ import { type BackgroundTasksHost, } from "./chat-background-tasks.ts"; -const openSession = { onOpenSession: () => {} }; - function flushAsync() { return new Promise((resolve) => { setTimeout(resolve, 0); @@ -70,11 +68,11 @@ async function refreshingHost(tasks: TaskSummary[], initial = false) { hello: null, requestUpdate: vi.fn(), }; - createBackgroundTasksProps(host, openSession); + createBackgroundTasksProps(host); if (!initial) { await flushAsync(); deferRefresh = true; - createBackgroundTasksProps(host, openSession).onRefresh(); + createBackgroundTasksProps(host).onRefresh(); } return { host, @@ -124,7 +122,7 @@ describe("background tasks concurrent snapshots", () => { ])("replays $name during the initial load without a stale second load", async (test) => { const refresh = await refreshingHost(test.stale, true); expect(refresh.request).toHaveBeenCalledTimes(2); - expect(createBackgroundTasksProps(refresh.host, openSession).tasks).toBeNull(); + expect(createBackgroundTasksProps(refresh.host).tasks).toBeNull(); handleBackgroundTasksEvent(refresh.host, test.event); handleBackgroundTasksEvent(refresh.host, { @@ -142,10 +140,7 @@ describe("background tasks concurrent snapshots", () => { expect(refresh.request).toHaveBeenCalledTimes(2); expect( - createBackgroundTasksProps(refresh.host, openSession).tasks?.map((task) => [ - task.id, - task.status, - ]), + createBackgroundTasksProps(refresh.host).tasks?.map((task) => [task.id, task.status]), ).toEqual(test.expected); }); @@ -166,7 +161,7 @@ describe("background tasks concurrent snapshots", () => { refresh.resolveRecent({ tasks: stale }); await flushAsync(); - const props = createBackgroundTasksProps(refresh.host, openSession); + const props = createBackgroundTasksProps(refresh.host); expect(props.error).toBe("Initial task snapshot unavailable"); expect(props.tasks?.map((task) => [task.id, task.status])).toEqual([ ["task-snapshot-failed", "completed"], @@ -193,7 +188,7 @@ describe("background tasks concurrent snapshots", () => { refresh.resolveRecent({ tasks }); await flushAsync(); - const props = createBackgroundTasksProps(refresh.host, openSession); + const props = createBackgroundTasksProps(refresh.host); expect(props.tasks).toHaveLength(10); expect(new Set(props.tasks?.map((task) => task.id)).size).toBe(10); expect(props.tasks?.every((task) => task.status === "completed")).toBe(true); @@ -207,19 +202,15 @@ describe("background tasks concurrent snapshots", () => { const tasks = [makeTask({ id: "task-cancelled" })]; const refresh = await refreshingHost(tasks); - createBackgroundTasksProps(refresh.host, openSession).onCancel("task-cancelled"); + createBackgroundTasksProps(refresh.host).onCancel("task-cancelled"); await flushAsync(); - expect(createBackgroundTasksProps(refresh.host, openSession).tasks?.[0]?.status).toBe( - "cancelled", - ); + expect(createBackgroundTasksProps(refresh.host).tasks?.[0]?.status).toBe("cancelled"); refresh.resolveActive({ tasks }); refresh.resolveRecent({ tasks }); await flushAsync(); - expect(createBackgroundTasksProps(refresh.host, openSession).tasks?.[0]?.status).toBe( - "cancelled", - ); + expect(createBackgroundTasksProps(refresh.host).tasks?.[0]?.status).toBe("cancelled"); }); it("discards an in-flight snapshot after a same-client connection-epoch change", async () => { @@ -229,19 +220,19 @@ describe("background tasks concurrent snapshots", () => { refresh.setFallbackTasks(replacement); refresh.host.connectionEpoch = 2; - createBackgroundTasksProps(refresh.host, openSession); + createBackgroundTasksProps(refresh.host); await flushAsync(); - expect( - createBackgroundTasksProps(refresh.host, openSession).tasks?.map((task) => task.id), - ).toEqual(["task-new-account"]); + expect(createBackgroundTasksProps(refresh.host).tasks?.map((task) => task.id)).toEqual([ + "task-new-account", + ]); refresh.resolveActive({ tasks: stale }); refresh.resolveRecent({ tasks: stale }); await flushAsync(); - expect( - createBackgroundTasksProps(refresh.host, openSession).tasks?.map((task) => task.id), - ).toEqual(["task-new-account"]); + expect(createBackgroundTasksProps(refresh.host).tasks?.map((task) => task.id)).toEqual([ + "task-new-account", + ]); expect(refresh.request).toHaveBeenCalledTimes(4); }); }); diff --git a/ui/src/pages/chat/components/chat-background-tasks-render.ts b/ui/src/pages/chat/components/chat-background-tasks-render.ts new file mode 100644 index 000000000000..708ccce4d3cb --- /dev/null +++ b/ui/src/pages/chat/components/chat-background-tasks-render.ts @@ -0,0 +1,218 @@ +import { html, nothing, type TemplateResult } from "lit"; +import { repeat } from "lit/directives/repeat.js"; +import { icons } from "../../../components/icons.ts"; +import "../../../components/tooltip.ts"; +import { t } from "../../../i18n/index.ts"; +import { isActiveTask, partitionTasks, taskTitle } from "../../../lib/tasks/data.ts"; +import type { TaskSummary } from "../../../lib/tasks/task-summary.ts"; +import { renderTaskDetail, renderTaskRow } from "./chat-background-task-row.ts"; +import type { BackgroundTasksProps } from "./chat-background-tasks.types.ts"; + +/** Active-count badge shown on the collapsed-rail toggles; 0 until the task + * list has loaded for the pane's session. */ +function backgroundTasksActiveCount(props: BackgroundTasksProps | undefined): number { + return props?.tasks?.filter(isActiveTask).length ?? 0; +} + +export function renderBackgroundTasksToggle( + backgroundTasks: BackgroundTasksProps | undefined, +): TemplateResult | typeof nothing { + if (!backgroundTasks) { + return nothing; + } + const expanded = !backgroundTasks.collapsed; + const label = expanded ? t("chat.backgroundTasks.collapse") : t("chat.backgroundTasks.show"); + const activeCount = backgroundTasksActiveCount(backgroundTasks); + return html` + + + + `; +} + +function renderTaskRows( + tasks: readonly TaskSummary[], + props: BackgroundTasksProps, +): TemplateResult { + return html` +
+ ${repeat( + tasks, + (task) => task.id, + (task) => renderTaskRow(task, props), + )} +
+ `; +} + +export function renderBackgroundTasksRail( + backgroundTasks: BackgroundTasksProps | undefined, + transcript: TemplateResult | typeof nothing = nothing, +): TemplateResult | typeof nothing { + // Collapsed rails render nothing at all — no icon strip. Reopening happens + // through renderBackgroundTasksToggle. + if (!backgroundTasks || backgroundTasks.collapsed) { + return nothing; + } + const view = backgroundTasks.view; + const viewedTask = + view.kind === "list" + ? undefined + : backgroundTasks.tasks?.find((task) => task.id === view.taskId); + const selectedTask = view.kind === "detail" ? viewedTask : undefined; + const { active, recent } = partitionTasks(backgroundTasks.tasks ?? []); + const loaded = backgroundTasks.tasks !== null; + const empty = loaded && active.length === 0 && recent.length === 0; + const collapseButton = html` + + + + `; + return html` + + `; +} diff --git a/ui/src/pages/chat/components/chat-background-tasks-status.ts b/ui/src/pages/chat/components/chat-background-tasks-status.ts index ec10f1c090b9..7fed512c55f5 100644 --- a/ui/src/pages/chat/components/chat-background-tasks-status.ts +++ b/ui/src/pages/chat/components/chat-background-tasks-status.ts @@ -12,7 +12,8 @@ import { taskTitle, } from "../../../lib/tasks/data.ts"; import type { TaskSummary } from "../../../lib/tasks/task-summary.ts"; -import { STATUS_TONES, type BackgroundTasksProps } from "./chat-background-tasks.ts"; +import { STATUS_TONES } from "./chat-background-tasks-shared.ts"; +import type { BackgroundTasksProps } from "./chat-background-tasks.types.ts"; type BackgroundTasksStatus = { count: number; startedMs: number | null }; diff --git a/ui/src/pages/chat/components/chat-background-tasks.test.ts b/ui/src/pages/chat/components/chat-background-tasks.test.ts index 8d26b706edb0..95a816319ef0 100644 --- a/ui/src/pages/chat/components/chat-background-tasks.test.ts +++ b/ui/src/pages/chat/components/chat-background-tasks.test.ts @@ -2,14 +2,15 @@ import { html, render } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../../../api/gateway.ts"; import type { TaskSummary } from "../../../lib/tasks/task-summary.ts"; +import { CHAT_HISTORY_REQUEST_LIMIT } from "../chat-history.ts"; +import { renderBackgroundTasksRail } from "./chat-background-tasks-render.ts"; import { renderBackgroundTasksStatusRow } from "./chat-background-tasks-status.ts"; import { createBackgroundTasksProps, handleBackgroundTasksEvent, - renderBackgroundTasksRail, type BackgroundTasksHost, - type BackgroundTasksProps, } from "./chat-background-tasks.ts"; +import type { BackgroundTasksProps } from "./chat-background-tasks.types.ts"; function flushAsync() { return new Promise((resolve) => { @@ -66,8 +67,6 @@ function createHost(options?: { return { host, request, requestUpdate }; } -const openSession = { onOpenSession: () => {} }; - function makeProps(overrides: Partial = {}): BackgroundTasksProps { return { sessionKey: "agent:main:current", @@ -79,9 +78,9 @@ function makeProps(overrides: Partial = {}): BackgroundTas loading: false, error: null, tasks: null, + view: { kind: "list" }, cancellingTaskIds: new Set(), finishedCollapsed: false, - selectedTaskId: null, taskDetails: new Map(), taskDetailErrors: new Map(), taskDetailLoadingIds: new Set(), @@ -90,8 +89,8 @@ function makeProps(overrides: Partial = {}): BackgroundTas onRefresh: () => {}, onCancel: () => {}, onSelectTask: () => {}, - onBackToList: () => {}, - onOpenSession: () => {}, + onBack: () => {}, + onOpenTranscript: () => {}, ...overrides, }; } @@ -134,10 +133,10 @@ describe("background tasks rail state", () => { }, }); - expect(createBackgroundTasksProps(host, openSession).collapsed).toBe(true); + expect(createBackgroundTasksProps(host).collapsed).toBe(true); await flushAsync(); - const props = createBackgroundTasksProps(host, openSession); + const props = createBackgroundTasksProps(host); expect(props.collapsed).toBe(true); expect(props.finishedCollapsed).toBe(true); expect(request).toHaveBeenCalledTimes(2); @@ -165,12 +164,12 @@ describe("background tasks rail state", () => { }, }); - createBackgroundTasksProps(host, openSession); + createBackgroundTasksProps(host); await flushAsync(); expect(request.mock.calls[0]?.[1]).toMatchObject({ status: ["queued", "running"] }); expect(request.mock.calls[1]?.[1]).not.toHaveProperty("status"); - expect(createBackgroundTasksProps(host, openSession).tasks).toEqual([recent]); + expect(createBackgroundTasksProps(host).tasks).toEqual([recent]); }); it("loads the snapshot when a task event arrives before any load", async () => { @@ -178,7 +177,7 @@ describe("background tasks rail state", () => { connected: false, request: () => Promise.resolve({ tasks: [makeTask({ id: "task-1" })] }), }); - createBackgroundTasksProps(host, openSession); + createBackgroundTasksProps(host); expect(request).not.toHaveBeenCalled(); host.connected = true; @@ -189,18 +188,18 @@ describe("background tasks rail state", () => { await flushAsync(); expect(request).toHaveBeenCalledTimes(2); - const props = createBackgroundTasksProps(host, openSession); + const props = createBackgroundTasksProps(host); expect(props.tasks?.map((task) => task.id)).toEqual(["task-1"]); }); it("keeps expansion across session switches and reloads the new scope", async () => { const { host, request } = createHost(); - createBackgroundTasksProps(host, openSession).onToggleCollapsed(); - createBackgroundTasksProps(host, openSession); + createBackgroundTasksProps(host).onToggleCollapsed(); + createBackgroundTasksProps(host); await flushAsync(); host.sessionKey = "agent:main:another-thread"; - const props = createBackgroundTasksProps(host, openSession); + const props = createBackgroundTasksProps(host); expect(props.collapsed).toBe(false); expect(props.sessionKey).toBe("agent:main:another-thread"); expect(props.tasks).toBeNull(); @@ -220,13 +219,13 @@ describe("background tasks rail state", () => { }); const auth = { role: "operator" as const, scopes: ["operator.write"] }; host.hello = { type: "hello-ok", protocol: 4, auth }; - createBackgroundTasksProps(host, openSession).onToggleCollapsed(); + createBackgroundTasksProps(host).onToggleCollapsed(); await flushAsync(); - createBackgroundTasksProps(host, openSession).onCancel("task-1"); + createBackgroundTasksProps(host).onCancel("task-1"); await flushAsync(); - const props = createBackgroundTasksProps(host, openSession); + const props = createBackgroundTasksProps(host); expect(props.error).toBe("already finished"); expect(props.cancellingTaskIds.has("task-1")).toBe(false); }); @@ -243,21 +242,119 @@ describe("background tasks rail state", () => { ? Promise.resolve({ task: { ...running, prompt: "Audit the background task UI" } }) : Promise.resolve({ tasks: [running] }), }); - createBackgroundTasksProps(host, openSession); + createBackgroundTasksProps(host); await flushAsync(); - createBackgroundTasksProps(host, openSession).onSelectTask(running); + createBackgroundTasksProps(host).onSelectTask(running); await flushAsync(); expect(request).toHaveBeenCalledWith("tasks.get", { taskId: "task-1" }); - const props = createBackgroundTasksProps(host, openSession); - expect(props.selectedTaskId).toBe("task-1"); + const props = createBackgroundTasksProps(host); + expect(props.view).toEqual({ kind: "detail", taskId: "task-1" }); expect(props.taskDetails.get("task-1")?.prompt).toBe("Audit the background task UI"); - props.onBackToList(); - expect(createBackgroundTasksProps(host, openSession).selectedTaskId).toBeNull(); + props.onBack(); + expect(createBackgroundTasksProps(host).view).toEqual({ kind: "list" }); }); + it("loads a task transcript in the rail and returns to its originating detail", async () => { + const task = makeTask({ + id: "task-1", + runtime: "cli", + sessionKey: "agent:main:cli:fallback", + childSessionKey: "agent:main:cli:child", + }); + const { host, request } = createHost({ + request: (method) => { + if (method === "chat.history") { + return Promise.resolve({ + messages: [ + { role: "assistant", content: [{ type: "text", text: "CLI result" }] }, + { role: "assistant", content: [{ type: "text", text: "NO_REPLY" }] }, + ], + }); + } + return Promise.resolve({ tasks: [task] }); + }, + }); + createBackgroundTasksProps(host); + await flushAsync(); + + const props = createBackgroundTasksProps(host); + props.onSelectTask(task); + props.onOpenTranscript(task, "detail"); + expect(createBackgroundTasksProps(host).view).toMatchObject({ + kind: "transcript", + taskId: "task-1", + sessionKey: "agent:main:cli:child", + returnTo: "detail", + load: { status: "loading" }, + }); + await flushAsync(); + + expect(request).toHaveBeenCalledWith("chat.history", { + sessionKey: "agent:main:cli:child", + limit: CHAT_HISTORY_REQUEST_LIMIT, + }); + const loaded = createBackgroundTasksProps(host).view; + expect(loaded.kind).toBe("transcript"); + if (loaded.kind !== "transcript") { + throw new Error("expected transcript view"); + } + expect(loaded.load).toMatchObject({ status: "loaded" }); + expect(loaded.load.status === "loaded" ? loaded.load.messages : []).toHaveLength(1); + + createBackgroundTasksProps(host).onBack(); + expect(createBackgroundTasksProps(host).view).toEqual({ kind: "detail", taskId: "task-1" }); + }); + + it.each(["detail", "transcript"] as const)( + "returns a stale %s view to the list when refresh omits its task", + async (view) => { + const task = makeTask({ + id: "task-1", + runtime: "cli", + sessionKey: "agent:main:cli:late", + }); + let listCall = 0; + let resolveView: ((value: unknown) => void) | undefined; + const viewResponse = new Promise((resolve) => { + resolveView = resolve; + }); + const { host } = createHost({ + request: (method) => { + if (method === "tasks.get" || method === "chat.history") { + return viewResponse; + } + listCall += 1; + return Promise.resolve({ tasks: listCall <= 2 ? [task] : [] }); + }, + }); + createBackgroundTasksProps(host); + await flushAsync(); + + const props = createBackgroundTasksProps(host); + if (view === "detail") { + props.onSelectTask(task); + } else { + props.onOpenTranscript(task, "list"); + } + createBackgroundTasksProps(host).onRefresh(); + await flushAsync(); + expect(createBackgroundTasksProps(host).tasks).toEqual([]); + expect(createBackgroundTasksProps(host).view).toEqual({ kind: "list" }); + + resolveView?.( + view === "detail" + ? { task: { ...task, prompt: "Late task detail" } } + : { messages: [{ role: "assistant", content: "Late transcript" }] }, + ); + await flushAsync(); + + expect(createBackgroundTasksProps(host).view).toEqual({ kind: "list" }); + }, + ); + it("moves focus into task details and restores it to the selected row", async () => { const running = makeTask({ id: "task-1", progressSummary: "Reading files" }); const completed = makeTask({ @@ -273,21 +370,18 @@ describe("background tasks rail state", () => { ? Promise.resolve({ task: completed }) : Promise.resolve({ tasks: [running] }), }); - createBackgroundTasksProps(host, openSession); + createBackgroundTasksProps(host); await flushAsync(); const container = document.createElement("div"); document.body.append(container); const renderRail = () => { - render( - html`${renderBackgroundTasksRail(createBackgroundTasksProps(host, openSession))}`, - container, - ); + render(html`${renderBackgroundTasksRail(createBackgroundTasksProps(host))}`, container); }; host.requestUpdate = renderRail; // finishedCollapsed defaults to true; back-navigation from a finished // detail must expand the section so the returned-to row stays visible. - const initialProps = createBackgroundTasksProps(host, openSession); + const initialProps = createBackgroundTasksProps(host); initialProps.onToggleCollapsed(); renderRail(); @@ -303,7 +397,7 @@ describe("background tasks rail state", () => { back?.click(); await flushAnimationFrame(); - expect(createBackgroundTasksProps(host, openSession).finishedCollapsed).toBe(false); + expect(createBackgroundTasksProps(host).finishedCollapsed).toBe(false); expect( container.querySelector('[data-tasks-section="finished"] [data-task-id="task-1"]'), ).not.toBeNull(); @@ -327,13 +421,13 @@ describe("background tasks rail state", () => { ? Promise.resolve({ task: completed }) : Promise.resolve({ tasks: [running] }), }); - createBackgroundTasksProps(host, openSession); + createBackgroundTasksProps(host); await flushAsync(); - createBackgroundTasksProps(host, openSession).onSelectTask(running); + createBackgroundTasksProps(host).onSelectTask(running); await flushAsync(); - const props = createBackgroundTasksProps(host, openSession); + const props = createBackgroundTasksProps(host); expect(props.tasks?.map((task) => [task.id, task.status])).toEqual([["task-1", "completed"]]); expect(props.taskDetails.get("task-1")?.terminalSummary).toBe("Finished in lookup"); }); @@ -368,17 +462,17 @@ describe("background tasks rail state", () => { return listCall === 3 ? active : recent; }, }); - createBackgroundTasksProps(host, openSession); + createBackgroundTasksProps(host); await flushAsync(); - createBackgroundTasksProps(host, openSession).onRefresh(); - createBackgroundTasksProps(host, openSession).onSelectTask(running); + createBackgroundTasksProps(host).onRefresh(); + createBackgroundTasksProps(host).onSelectTask(running); await flushAsync(); resolveActive?.({ tasks: [running] }); resolveRecent?.({ tasks: [running] }); await flushAsync(); - const props = createBackgroundTasksProps(host, openSession); + const props = createBackgroundTasksProps(host); expect(props.tasks?.map((task) => [task.id, task.status])).toEqual([["task-1", "completed"]]); expect(props.taskDetails.get("task-1")).toMatchObject({ status: "completed", @@ -397,17 +491,17 @@ describe("background tasks rail state", () => { request: (method) => method === "tasks.get" ? detail : Promise.resolve({ tasks: [running] }), }); - createBackgroundTasksProps(host, openSession); + createBackgroundTasksProps(host); await flushAsync(); - createBackgroundTasksProps(host, openSession).onSelectTask(running); + createBackgroundTasksProps(host).onSelectTask(running); handleBackgroundTasksEvent(host, { action: "deleted", taskId: "task-1" }); resolveDetail?.({ task: { ...running, prompt: "Deleted task prompt" } }); await flushAsync(); - const props = createBackgroundTasksProps(host, openSession); + const props = createBackgroundTasksProps(host); expect(props.tasks).toEqual([]); - expect(props.selectedTaskId).toBeNull(); + expect(props.view).toEqual({ kind: "list" }); expect(props.taskDetails.has("task-1")).toBe(false); }); }); @@ -417,7 +511,7 @@ describe("background tasks rail events", () => { const { host, request } = createHost({ request: () => Promise.resolve({ tasks }), }); - createBackgroundTasksProps(host, openSession).onToggleCollapsed(); + createBackgroundTasksProps(host).onToggleCollapsed(); await flushAsync(); return { host, request }; } @@ -429,11 +523,11 @@ describe("background tasks rail events", () => { action: "upserted", task: makeTask({ id: "task-2", status: "completed", updatedAt: 9_000 }), }); - let props = createBackgroundTasksProps(host, openSession); + let props = createBackgroundTasksProps(host); expect(props.tasks?.map((task) => task.id)).toEqual(["task-2", "task-1"]); handleBackgroundTasksEvent(host, { action: "deleted", taskId: "task-1" }); - props = createBackgroundTasksProps(host, openSession); + props = createBackgroundTasksProps(host); expect(props.tasks?.map((task) => task.id)).toEqual(["task-2"]); }); @@ -454,7 +548,7 @@ describe("background tasks rail events", () => { handleBackgroundTasksEvent(host, { action: "upserted", task: correction }); - expect(createBackgroundTasksProps(host, openSession).tasks).toEqual([correction]); + expect(createBackgroundTasksProps(host).tasks).toEqual([correction]); }); it("does not roll back running tool activity from an equally current event", async () => { @@ -474,7 +568,7 @@ describe("background tasks rail events", () => { handleBackgroundTasksEvent(host, { action: "upserted", task: stale }); - expect(createBackgroundTasksProps(host, openSession).tasks).toEqual([progress]); + expect(createBackgroundTasksProps(host).tasks).toEqual([progress]); }); it("preserves an opened prompt when a terminal event corrects its output", async () => { @@ -497,14 +591,14 @@ describe("background tasks rail events", () => { ? Promise.resolve({ task: { ...completed, prompt } }) : Promise.resolve({ tasks: [completed] }), }); - createBackgroundTasksProps(host, openSession); + createBackgroundTasksProps(host); await flushAsync(); - createBackgroundTasksProps(host, openSession).onSelectTask(completed); + createBackgroundTasksProps(host).onSelectTask(completed); await flushAsync(); handleBackgroundTasksEvent(host, { action: "upserted", task: correction }); - const props = createBackgroundTasksProps(host, openSession); + const props = createBackgroundTasksProps(host); expect(props.tasks?.[0]?.terminalSummary).toBe("Authoritative terminal details"); expect(props.taskDetails.get("task-1")).toMatchObject({ prompt, @@ -520,7 +614,7 @@ describe("background tasks rail events", () => { task: makeTask({ id: "task-2", sessionKey: "agent:main:another-thread" }), }); - const props = createBackgroundTasksProps(host, openSession); + const props = createBackgroundTasksProps(host); expect(props.tasks?.map((task) => task.id)).toEqual(["task-1"]); }); @@ -536,7 +630,7 @@ describe("background tasks rail events", () => { }, }); - const props = createBackgroundTasksProps(host, openSession); + const props = createBackgroundTasksProps(host); expect(props.tasks?.map((task) => task.id)).toEqual(["task-owner", "task-1"]); }); @@ -565,9 +659,9 @@ describe("background tasks rail events", () => { ? Promise.resolve({ task: completed }) : Promise.resolve({ tasks: [running] }), }); - createBackgroundTasksProps(host, openSession); + createBackgroundTasksProps(host); await flushAsync(); - createBackgroundTasksProps(host, openSession).onSelectTask(running); + createBackgroundTasksProps(host).onSelectTask(running); await flushAsync(); handleBackgroundTasksEvent(host, { @@ -575,7 +669,7 @@ describe("background tasks rail events", () => { task: makeTask({ id: "task-1", status: "running", updatedAt: 2_000 }), }); - const props = createBackgroundTasksProps(host, openSession); + const props = createBackgroundTasksProps(host); expect(props.tasks?.[0]?.status).toBe("completed"); expect(props.taskDetails.get("task-1")).toMatchObject({ status: "completed", @@ -586,9 +680,42 @@ describe("background tasks rail events", () => { }); describe("background tasks rail rendering", () => { + it("renders explicit transcript loading, empty, and error states", () => { + const task = makeTask({ id: "task-1", runtime: "cli" }); + const baseView = { + kind: "transcript" as const, + taskId: "task-1", + sessionKey: "agent:main:cli:task", + returnTo: "detail" as const, + }; + const loading = renderTaskRail({ + tasks: [task], + view: { ...baseView, load: { status: "loading" } }, + }); + expect(loading.textContent).toContain("Loading task transcript…"); + expect(loading.querySelector(".chat-tasks-rail__back")?.getAttribute("aria-label")).toBe( + "Back to task details", + ); + + const empty = renderTaskRail({ + tasks: [task], + view: { ...baseView, load: { status: "loaded", messages: [] } }, + }); + expect(empty.textContent).toContain("No transcript messages yet."); + + const failed = renderTaskRail({ + tasks: [task], + view: { + ...baseView, + load: { status: "error" }, + }, + }); + expect(failed.textContent).toContain("Could not load task transcript."); + }); + it("keeps subagents in the rail and preserves linked sessions for other runtimes", () => { const onCancel = vi.fn(); - const onOpenSession = vi.fn(); + const onOpenTranscript = vi.fn(); const onSelectTask = vi.fn(); const container = renderTaskRail({ canCancel: true, @@ -608,7 +735,7 @@ describe("background tasks rail rendering", () => { ], onCancel, onSelectTask, - onOpenSession, + onOpenTranscript, }); const rows = container.querySelectorAll(".chat-tasks-rail__task"); @@ -629,7 +756,10 @@ describe("background tasks rail rendering", () => { ); expect(transcript).not.toBeNull(); transcript?.click(); - expect(onOpenSession).toHaveBeenCalledWith("agent:main:cli:finished"); + expect(onOpenTranscript).toHaveBeenCalledWith( + expect.objectContaining({ id: "task-2" }), + "list", + ); expect(onSelectTask).not.toHaveBeenCalled(); }); @@ -660,22 +790,26 @@ describe("background tasks rail rendering", () => { }); it("opens a compact task detail view with prompt, output, and back navigation", () => { - const onBackToList = vi.fn(); + const onBack = vi.fn(); + const onOpenTranscript = vi.fn(); const task = makeTask({ id: "task-1", + runtime: "cli", + sessionKey: "agent:main:cli:audit", status: "completed", terminalSummary: "Audit complete", }); const container = renderTaskRail({ tasks: [task], - selectedTaskId: "task-1", + view: { kind: "detail", taskId: "task-1" }, taskDetails: new Map([ [ "task-1", { ...task, terminalSummary: "Stale running progress", prompt: "Review running tasks" }, ], ]), - onBackToList, + onBack, + onOpenTranscript, }); const detail = container.querySelector('[data-task-detail="task-1"]'); @@ -683,11 +817,20 @@ describe("background tasks rail rendering", () => { expect(detail?.textContent).toContain("Audit complete"); expect(detail?.textContent).not.toContain("Stale running progress"); expect(container.querySelector(".chat-tasks-rail__task")).toBeNull(); + const transcript = container.querySelector( + ".chat-tasks-rail__task-transcript", + ); + expect(transcript).not.toBeNull(); + transcript?.click(); + expect(onOpenTranscript).toHaveBeenCalledWith( + expect.objectContaining({ id: "task-1" }), + "detail", + ); const back = container.querySelector(".chat-tasks-rail__back"); expect(back?.getAttribute("aria-label")).toBe("Back to background tasks"); back?.click(); - expect(onBackToList).toHaveBeenCalledTimes(1); + expect(onBack).toHaveBeenCalledTimes(1); }); it("uses a newer lookup snapshot for output", () => { @@ -706,7 +849,7 @@ describe("background tasks rail rendering", () => { }); const container = renderTaskRail({ tasks: [listTask], - selectedTaskId: "task-1", + view: { kind: "detail", taskId: "task-1" }, taskDetails: new Map([["task-1", lookupTask]]), }); diff --git a/ui/src/pages/chat/components/chat-background-tasks.ts b/ui/src/pages/chat/components/chat-background-tasks.ts index 17e6cdf704e6..d82a154f05c2 100644 --- a/ui/src/pages/chat/components/chat-background-tasks.ts +++ b/ui/src/pages/chat/components/chat-background-tasks.ts @@ -1,9 +1,5 @@ -import { html, nothing, type TemplateResult } from "lit"; -import { repeat } from "lit/directives/repeat.js"; import type { GatewayBrowserClient, GatewayHelloOk } from "../../../api/gateway.ts"; import { hasOperatorWriteAccess } from "../../../app/operator-access.ts"; -import { icons } from "../../../components/icons.ts"; -import "../../../components/tooltip.ts"; import { t } from "../../../i18n/index.ts"; import type { SessionScopeHost } from "../../../lib/sessions/index.ts"; import { canonicalUiSessionKeyForPersistence } from "../../../lib/sessions/session-key.ts"; @@ -16,17 +12,19 @@ import { normalizeTasksCancelResult, normalizeTasksGetResult, normalizeTasksListResult, - partitionTasks, sortTasks, - taskTitle, } from "../../../lib/tasks/data.ts"; import type { TaskSummary } from "../../../lib/tasks/task-summary.ts"; -import { renderTaskDetail, renderTaskRow } from "./chat-background-task-row.ts"; +import { + CHAT_HISTORY_REQUEST_LIMIT, + type ChatHistoryResult, + visibleChatHistoryMessages, +} from "../chat-history.ts"; import { newestTaskSnapshot } from "./chat-background-tasks-shared.ts"; -import type { BackgroundTasksProps } from "./chat-background-tasks.types.ts"; - -export { STATUS_TONES } from "./chat-background-tasks-shared.ts"; -export type { BackgroundTasksProps } from "./chat-background-tasks.types.ts"; +import type { + BackgroundTasksProps, + BackgroundTasksRailView, +} from "./chat-background-tasks.types.ts"; type BackgroundTaskLoadEvent = NonNullable>; @@ -57,7 +55,7 @@ type BackgroundTasksState = { // per pane: two panes on the same agent would otherwise cross-anchor. statusRowId: string; tasks: TaskSummary[] | null; - selectedTaskId: string | null; + view: BackgroundTasksRailView; taskDetails: Map; taskDetailErrors: Map; taskDetailLoadingIds: Set; @@ -117,7 +115,7 @@ function getBackgroundTasksState(host: BackgroundTasksHost): BackgroundTasksStat sessionKey, statusRowId: `chat-tasks-status-${nextStatusRowId}`, tasks: null, - selectedTaskId: null, + view: { kind: "list" }, taskDetails: new Map(), taskDetailErrors: new Map(), taskDetailLoadingIds: new Set(), @@ -184,6 +182,12 @@ function loadBackgroundTasks( current.tasks = sortTasks( merged.map((task) => newestTaskSnapshot(task, current.taskDetails.get(task.id))), ); + const viewedTaskId = current.view.kind === "list" ? null : current.view.taskId; + // Detail and transcript navigation depend on the authoritative list; + // a bounded refresh may legitimately omit the previously viewed task. + if (viewedTaskId && !current.tasks.some((task) => task.id === viewedTaskId)) { + current.view = { kind: "list" }; + } current.loadedClient = client; } catch (error) { const current = getBackgroundTasksState(host); @@ -291,8 +295,8 @@ export function handleBackgroundTasksEvent(host: BackgroundTasksHost, payload: u return; } state.tasks = state.tasks.filter((task) => task.id !== event.taskId); - if (state.selectedTaskId === event.taskId) { - state.selectedTaskId = null; + if (state.view.kind !== "list" && state.view.taskId === event.taskId) { + state.view = { kind: "list" }; } state.taskDetails.delete(event.taskId); state.taskDetailErrors.delete(event.taskId); @@ -400,14 +404,14 @@ function selectBackgroundTaskDetail( state: BackgroundTasksState, task: TaskSummary, ) { - state.selectedTaskId = task.id; + state.view = { kind: "detail", taskId: task.id }; host.requestUpdate?.(); focusBackgroundTaskControl(state, "back"); void loadBackgroundTaskDetail(host, state, task); } function showBackgroundTaskList(host: BackgroundTasksHost, state: BackgroundTasksState) { - const taskId = state.selectedTaskId; + const taskId = state.view.kind === "detail" ? state.view.taskId : null; const listedTask = state.tasks?.find((task) => task.id === taskId); const detailedTask = taskId ? state.taskDetails.get(taskId) : undefined; const selectedTask = listedTask ? newestTaskSnapshot(listedTask, detailedTask) : detailedTask; @@ -420,13 +424,84 @@ function showBackgroundTaskList(host: BackgroundTasksHost, state: BackgroundTask state.finishedCollapsed = false; } } - state.selectedTaskId = null; + state.view = { kind: "list" }; host.requestUpdate?.(); if (taskId) { focusBackgroundTaskControl(state, { taskId }); } } +function openBackgroundTaskTranscript( + host: BackgroundTasksHost, + state: BackgroundTasksState, + task: TaskSummary, + returnTo: "list" | "detail", +) { + const sessionKey = normalizeOptionalString(task.childSessionKey ?? task.sessionKey); + const client = host.client; + const pendingView: BackgroundTasksRailView = { + kind: "transcript", + taskId: task.id, + sessionKey: sessionKey ?? "", + returnTo, + load: { status: "loading" }, + }; + state.view = pendingView; + host.requestUpdate?.(); + focusBackgroundTaskControl(state, "back"); + if (!client || !host.connected || !sessionKey) { + state.view = { + ...pendingView, + load: { status: "error" }, + }; + host.requestUpdate?.(); + return; + } + void (async () => { + let load: Extract["load"]; + try { + const result = await client.request("chat.history", { + sessionKey, + limit: CHAT_HISTORY_REQUEST_LIMIT, + }); + load = { status: "loaded", messages: visibleChatHistoryMessages(result.messages) }; + } catch { + load = { status: "error" }; + } + const current = getBackgroundTasksState(host); + if (current !== state || current.view !== pendingView) { + return; + } + current.view = { ...pendingView, load }; + host.requestUpdate?.(); + })(); +} + +function showPreviousBackgroundTaskView(host: BackgroundTasksHost, state: BackgroundTasksState) { + if (state.view.kind === "detail") { + showBackgroundTaskList(host, state); + return; + } + if (state.view.kind !== "transcript") { + return; + } + const { returnTo, taskId } = state.view; + if (returnTo === "detail" && state.tasks?.some((task) => task.id === taskId)) { + state.view = { kind: "detail", taskId }; + host.requestUpdate?.(); + window.requestAnimationFrame(() => { + document + .getElementById(`${state.statusRowId}-rail`) + ?.querySelector(".chat-tasks-rail__task-transcript") + ?.focus(); + }); + return; + } + state.view = { kind: "list" }; + host.requestUpdate?.(); + focusBackgroundTaskControl(state, { taskId }); +} + async function cancelBackgroundTask( host: BackgroundTasksHost, state: BackgroundTasksState, @@ -488,7 +563,7 @@ function toggleBackgroundTasks(host: BackgroundTasksHost) { export function createBackgroundTasksProps( host: BackgroundTasksHost, - opts: { narrowLayout?: boolean; onOpenSession: (sessionKey: string) => void }, + opts: { narrowLayout?: boolean } = {}, ): BackgroundTasksProps { const state = getBackgroundTasksState(host); if (!host.connected) { @@ -517,7 +592,7 @@ export function createBackgroundTasksProps( loading: state.loading, error: state.error, tasks: state.tasks, - selectedTaskId: state.selectedTaskId, + view: state.view, taskDetails: state.taskDetails, taskDetailErrors: state.taskDetailErrors, taskDetailLoadingIds: state.taskDetailLoadingIds, @@ -531,190 +606,7 @@ export function createBackgroundTasksProps( onRefresh: () => loadBackgroundTasks(host, state, true), onCancel: (taskId) => void cancelBackgroundTask(host, state, taskId), onSelectTask: (task) => selectBackgroundTaskDetail(host, state, task), - onBackToList: () => showBackgroundTaskList(host, state), - onOpenSession: opts.onOpenSession, + onBack: () => showPreviousBackgroundTaskView(host, state), + onOpenTranscript: (task, returnTo) => openBackgroundTaskTranscript(host, state, task, returnTo), }; } - -/** Active-count badge shown on the collapsed-rail toggles; 0 until the task - * list has loaded for the pane's session. */ -function backgroundTasksActiveCount(props: BackgroundTasksProps | undefined): number { - return props?.tasks?.filter(isActiveTask).length ?? 0; -} - -export function renderBackgroundTasksToggle( - backgroundTasks: BackgroundTasksProps | undefined, -): TemplateResult | typeof nothing { - if (!backgroundTasks) { - return nothing; - } - const expanded = !backgroundTasks.collapsed; - const label = expanded ? t("chat.backgroundTasks.collapse") : t("chat.backgroundTasks.show"); - const activeCount = backgroundTasksActiveCount(backgroundTasks); - return html` - - - - `; -} - -function renderTaskRows( - tasks: readonly TaskSummary[], - props: BackgroundTasksProps, -): TemplateResult { - return html` -
- ${repeat( - tasks, - (task) => task.id, - (task) => renderTaskRow(task, props), - )} -
- `; -} - -export function renderBackgroundTasksRail( - backgroundTasks: BackgroundTasksProps | undefined, -): TemplateResult | typeof nothing { - // Collapsed rails render nothing at all — no icon strip. Reopening happens - // through renderBackgroundTasksToggle. - if (!backgroundTasks || backgroundTasks.collapsed) { - return nothing; - } - const selectedTask = backgroundTasks.tasks?.find( - (task) => task.id === backgroundTasks.selectedTaskId, - ); - const { active, recent } = partitionTasks(backgroundTasks.tasks ?? []); - const loaded = backgroundTasks.tasks !== null; - const empty = loaded && active.length === 0 && recent.length === 0; - const collapseButton = html` - - - - `; - return html` - - `; -} diff --git a/ui/src/pages/chat/components/chat-background-tasks.types.ts b/ui/src/pages/chat/components/chat-background-tasks.types.ts index d3de7972a16a..c58634e38585 100644 --- a/ui/src/pages/chat/components/chat-background-tasks.types.ts +++ b/ui/src/pages/chat/components/chat-background-tasks.types.ts @@ -1,5 +1,16 @@ import type { TaskSummary } from "../../../lib/tasks/task-summary.ts"; +export type BackgroundTasksRailView = + | { kind: "list" } + | { kind: "detail"; taskId: string } + | { + kind: "transcript"; + taskId: string; + sessionKey: string; + returnTo: "list" | "detail"; + load: { status: "loading" } | { status: "loaded"; messages: unknown[] } | { status: "error" }; + }; + export type BackgroundTasksProps = { sessionKey: string; statusRowId: string; @@ -13,7 +24,7 @@ export type BackgroundTasksProps = { error: string | null; /** null until the first load for this session finished. */ tasks: TaskSummary[] | null; - selectedTaskId: string | null; + view: BackgroundTasksRailView; taskDetails: ReadonlyMap; taskDetailErrors: ReadonlyMap; taskDetailLoadingIds: ReadonlySet; @@ -24,6 +35,6 @@ export type BackgroundTasksProps = { onRefresh: () => void; onCancel: (taskId: string) => void; onSelectTask: (task: TaskSummary) => void; - onBackToList: () => void; - onOpenSession: (sessionKey: string) => void; + onBack: () => void; + onOpenTranscript: (task: TaskSummary, returnTo: "list" | "detail") => void; }; diff --git a/ui/src/pages/chat/components/chat-thread.ts b/ui/src/pages/chat/components/chat-thread.ts index f73777020ddb..3de65cd13513 100644 --- a/ui/src/pages/chat/components/chat-thread.ts +++ b/ui/src/pages/chat/components/chat-thread.ts @@ -80,7 +80,7 @@ import { getOrCreateSessionCacheValue } from "../session-cache.ts"; import type { PlanStatus } from "../tool-stream.ts"; import { getToolTitlesVersion } from "../tool-titles.ts"; import { renderBackgroundTasksStatusRow } from "./chat-background-tasks-status.ts"; -import type { BackgroundTasksProps } from "./chat-background-tasks.ts"; +import type { BackgroundTasksProps } from "./chat-background-tasks.types.ts"; import { renderChatDivider, renderChatNotice } from "./chat-divider.ts"; import type { ArtifactDownloadResolver } from "./chat-message-media.ts"; import { @@ -133,6 +133,8 @@ type ChatThreadProps = { showThinking: boolean; showToolCalls: boolean; persistCommentary?: boolean; + /** Suppresses transcript mutations while preserving read-only presentation controls. */ + readOnly?: boolean; /** True while the session has an abortable live run (marks running tool rows). */ runActive?: boolean; /** True while the agent is visibly working (isChatRunWorking); shows the working spark. */ @@ -1158,7 +1160,7 @@ function handleChatContextMenu(event: MouseEvent, props: ChatThreadProps) { const copyButton = actionOwner?.querySelector(".chat-copy-btn"); const canReply = Boolean(text && props.onSetReply); const canRewind = isUserMessage && typeof props.onRewindMessage === "function"; - const canHide = groupKeys.length > 0; + const canHide = !props.readOnly && groupKeys.length > 0; const canCopy = Boolean(copyButton); const canFork = isUserMessage && typeof props.onForkMessage === "function"; if (!canReply && !canRewind && !canHide && !canCopy && !canFork) { @@ -1656,7 +1658,7 @@ function renderChatThreadContents( allowExternalEmbedUrls: props.allowExternalEmbedUrls ?? false, contextWindow: threadContextWindow, onReply: props.onSetReply, - onDelete, + onDelete: props.readOnly ? undefined : onDelete, onRewind: rewindEntryId && props.onRewindMessage ? () => { @@ -1884,6 +1886,7 @@ function renderChatThreadContents( props.planStatus, props.questionPrompts, Boolean(props.autoExpandToolCalls), + Boolean(props.readOnly), props.assistantName, assistantIdentity.avatar, props.userId, diff --git a/ui/src/styles/chat/sidebar.css b/ui/src/styles/chat/sidebar.css index 4a3ad281901b..dd893de3dac9 100644 --- a/ui/src/styles/chat/sidebar.css +++ b/ui/src/styles/chat/sidebar.css @@ -906,6 +906,23 @@ openclaw-chat-sidebar-region, overflow: auto; } +.chat-tasks-rail__transcript { + display: flex; + flex: 1 1 0; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.chat-tasks-rail__transcript > .chat-thread { + padding: 12px 0 6px; + border-radius: 0; +} + +.chat-tasks-rail__transcript .chat-thread-inner { + width: calc(100% - 16px); +} + /* List mode splits scrolling per section: running work owns the rail and the finished history pins to the bottom, so a long history can never clip the active tasks (each section scrolls on its own instead of the container). */