From ab910f88adb639bb046c39c485cbff35bf7560c3 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 25 May 2026 00:59:59 -0500 Subject: [PATCH] fix: make compaction reinjection opt-in Summary: - Make post-compaction AGENTS.md reinjection explicit opt-in for configured sections. - Carry the run workspace into compaction-safeguard AGENTS.md reads. - Improve collapsed Control UI tool rows while preserving raw expanded tool details. Verification: - CI green on PR head 96101664f07a3a43f06311bf987b63fbdbe25f08. - pnpm exec oxfmt --check --threads=1 - OPENCLAW_OXLINT_SKIP_PREPARE=1 node scripts/run-oxlint.mjs - node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.core.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/core-test.tsbuildinfo - git diff --check origin/main...HEAD && git diff --check - node scripts/run-vitest.mjs src/agents/pi-hooks/compaction-safeguard.test.ts src/agents/pi-embedded-runner/extensions.test.ts -t "workspace" - node scripts/run-vitest.mjs src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts -t "reads opted-in post-compaction context" - node scripts/run-vitest.mjs test/scripts/test-projects.test.ts -t "allows explicit split Vitest config targets" - node scripts/run-vitest.mjs ui/src/ui/chat/tool-cards.test.ts ui/src/ui/chat/tool-cards.node.test.ts ui/src/ui/chat/grouped-render.test.ts ui/src/styles/chat/tool-cards.test.ts - AUTOREVIEW_AUTO_TESTS=0 .agents/skills/autoreview/scripts/autoreview --mode branch Fixes #45488. Fixes #45649. Supersedes #67090. --- CHANGELOG.md | 1 + docs/gateway/config-agents.md | 4 +- .../session-management-compaction.md | 4 + docs/reference/token-use.md | 2 +- src/agents/pi-embedded-runner/compact.ts | 1 + .../pi-embedded-runner/extensions.test.ts | 22 ++++- src/agents/pi-embedded-runner/extensions.ts | 3 + src/agents/pi-embedded-runner/run/attempt.ts | 1 + .../pi-hooks/compaction-safeguard-runtime.ts | 2 + .../pi-hooks/compaction-safeguard.test.ts | 81 ++++++++++++++++- src/agents/pi-hooks/compaction-safeguard.ts | 56 +++++++----- .../agent-runner.misc.runreplyagent.test.ts | 12 ++- .../reply/post-compaction-context.test.ts | 80 ++++++++++++----- .../reply/post-compaction-context.ts | 23 ++--- src/config/schema.help.quality.test.ts | 2 + src/config/schema.help.ts | 2 +- src/config/types.agent-defaults.ts | 4 +- test/scripts/test-projects.test.ts | 15 ++++ ui/src/styles/chat/tool-cards.css | 45 +++++----- ui/src/styles/chat/tool-cards.test.ts | 23 +++++ ui/src/ui/chat/grouped-render.test.ts | 42 ++++++++- ui/src/ui/chat/grouped-render.ts | 20 +++-- ui/src/ui/chat/tool-cards.test.ts | 86 ++++++++++++++++++- ui/src/ui/chat/tool-cards.ts | 42 ++++++++- 24 files changed, 469 insertions(+), 104 deletions(-) create mode 100644 ui/src/styles/chat/tool-cards.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 202fe64260a5..22799ad3dae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,7 @@ Docs: https://docs.openclaw.ai - Live tests: fail Gateway live model sweeps when selected coverage is lost to timeouts or stale high-signal filters instead of reporting false missing-profile coverage, and pin Docker OpenAI gateway coverage to the current `gpt-5.5` lane. - Tests: fail Docker resource-ceiling checks when stats samples or configured limits are invalid instead of silently reporting zero peaks. - Agents: fail closed when provider-less session models match multiple provider-prefixed runtime policies so CLI runtime routing no longer depends on config order. (#85970) Thanks @potterdigital. +- Control UI/agents: keep collapsed tool rows readable without early ellipses, preserve raw expanded tool details, and make post-compaction AGENTS.md reinjection opt-in to avoid duplicated project context. Fixes #45649 and #45488. Thanks @BunsDev. ## 2026.5.24 diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 3d7321a97a78..14575d783435 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -652,7 +652,7 @@ Periodic heartbeat runs. identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom qualityGuard: { enabled: true, maxRetries: 1 }, midTurnPrecheck: { enabled: false }, // optional Pi tool-loop pressure check - postCompactionSections: ["Session Startup", "Red Lines"], // [] disables reinjection + postCompactionSections: ["Session Startup", "Red Lines"], // opt in to AGENTS.md section reinjection model: "openrouter/anthropic/claude-sonnet-4-6", // optional compaction-only model override truncateAfterCompaction: true, // rotate to a smaller successor JSONL after compaction maxActiveTranscriptBytes: "20mb", // optional preflight local compaction trigger @@ -678,7 +678,7 @@ Periodic heartbeat runs. - `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`. - `qualityGuard`: retry-on-malformed-output checks for safeguard summaries. Enabled by default in safeguard mode; set `enabled: false` to skip the audit. - `midTurnPrecheck`: optional Pi tool-loop pressure check. When `enabled: true`, OpenClaw checks context pressure after tool results are appended and before the next model call. If the context no longer fits, it aborts the current attempt before submitting the prompt and reuses the existing precheck recovery path to truncate tool results or compact and retry. Works with both `default` and `safeguard` compaction modes. Default: disabled. -- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Defaults to `["Session Startup", "Red Lines"]`; set `[]` to disable reinjection. When unset or explicitly set to that default pair, older `Every Session`/`Safety` headings are also accepted as a legacy fallback. +- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Reinjection is disabled when unset or set to `[]`. Explicitly setting `["Session Startup", "Red Lines"]` enables that pair and preserves the legacy `Every Session`/`Safety` fallback. Enable this only when the extra context is worth the risk of duplicating project guidance already captured in the compaction summary. - `model`: optional `provider/model-id` override for compaction summarization only. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model. - `maxActiveTranscriptBytes`: optional byte threshold (`number` or strings like `"20mb"`) that triggers normal local compaction before a run when the active JSONL grows past the threshold. Requires `truncateAfterCompaction` so successful compaction can rotate to a smaller successor transcript. Disabled when unset or `0`. - `notifyUser`: when `true`, sends brief notices to the user when compaction starts and when it completes (for example, "Compacting context..." and "Compaction complete"). Disabled by default to keep compaction silent. diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index 4408f898b39a..e2b4baf2c5a8 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -248,6 +248,10 @@ After compaction, future turns see: - The compaction summary - Messages after `firstKeptEntryId` +AGENTS.md section reinjection after compaction is opt-in via +`agents.defaults.compaction.postCompactionSections`; when unset or `[]`, +OpenClaw does not append AGENTS.md excerpts on top of the compaction summary. + Compaction is **persistent** (unlike session pruning). See [/concepts/session-pruning](/concepts/session-pruning). ## Compaction chunk boundaries and tool pairing diff --git a/docs/reference/token-use.md b/docs/reference/token-use.md index 8a67cff257fe..d6cac94a05c1 100644 --- a/docs/reference/token-use.md +++ b/docs/reference/token-use.md @@ -19,7 +19,7 @@ OpenClaw assembles its own system prompt on every run. It includes: with optional per-agent override at `agents.list[].skillsLimits.maxSkillsPromptChars`. - Self-update instructions -- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` when present). Lowercase root `memory.md` is not injected; it is legacy repair input for `openclaw doctor --fix` when paired with `MEMORY.md`. Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 12000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 60000). `memory/*.md` daily files are not part of the normal bootstrap prompt; they remain on-demand via memory tools on ordinary turns, but reset/startup model runs can prepend a one-shot startup-context block with recent daily memory for that first turn. Bare chat `/new` and `/reset` commands are acknowledged without invoking the model. The startup prelude is controlled by `agents.defaults.startupContext`. +- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` when present). Lowercase root `memory.md` is not injected; it is legacy repair input for `openclaw doctor --fix` when paired with `MEMORY.md`. Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 12000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 60000). `memory/*.md` daily files are not part of the normal bootstrap prompt; they remain on-demand via memory tools on ordinary turns, but reset/startup model runs can prepend a one-shot startup-context block with recent daily memory for that first turn. Bare chat `/new` and `/reset` commands are acknowledged without invoking the model. The startup prelude is controlled by `agents.defaults.startupContext`. Post-compaction AGENTS.md excerpts are separate and require explicit `agents.defaults.compaction.postCompactionSections` opt-in. - Time (UTC + user timezone) - Reply tags + heartbeat behavior - Runtime metadata (host/OS/model/thinking) diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 914db6a8d2ff..cf6654621d35 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -1035,6 +1035,7 @@ async function compactEmbeddedPiSessionDirectOnce( const extensionFactories = buildEmbeddedExtensionFactories({ cfg: params.config, sessionManager, + workspaceDir: effectiveWorkspace, provider, modelId, model, diff --git a/src/agents/pi-embedded-runner/extensions.test.ts b/src/agents/pi-embedded-runner/extensions.test.ts index 9b1fc90f9162..2fccb2d7d087 100644 --- a/src/agents/pi-embedded-runner/extensions.test.ts +++ b/src/agents/pi-embedded-runner/extensions.test.ts @@ -16,7 +16,7 @@ vi.mock("../../plugins/provider-hook-runtime.js", () => ({ resolveProviderRuntimePlugin: () => undefined, })); -function buildSafeguardFactories(cfg: OpenClawConfig) { +function buildSafeguardFactories(cfg: OpenClawConfig, workspaceDir?: string) { const sessionManager = {} as SessionManager; const model = { id: "claude-sonnet-4-20250514", @@ -26,6 +26,7 @@ function buildSafeguardFactories(cfg: OpenClawConfig) { const factories = buildEmbeddedExtensionFactories({ cfg, sessionManager, + workspaceDir, provider: "anthropic", modelId: "claude-sonnet-4-20250514", model, @@ -101,6 +102,25 @@ describe("buildEmbeddedExtensionFactories", () => { }); }); + it("wires the run workspace into safeguard runtime", () => { + const { sessionManager } = buildSafeguardFactories( + { + agents: { + defaults: { + compaction: { + mode: "safeguard", + }, + }, + }, + } as OpenClawConfig, + "/tmp/openclaw-workspace", + ); + + expect(getCompactionSafeguardRuntime(sessionManager)?.workspaceDir).toBe( + "/tmp/openclaw-workspace", + ); + }); + it("enables cache-ttl pruning for custom anthropic-messages providers", () => { const factories = buildEmbeddedExtensionFactories({ cfg: { diff --git a/src/agents/pi-embedded-runner/extensions.ts b/src/agents/pi-embedded-runner/extensions.ts index 9522112d3e27..d403ebf8dc04 100644 --- a/src/agents/pi-embedded-runner/extensions.ts +++ b/src/agents/pi-embedded-runner/extensions.ts @@ -141,6 +141,7 @@ function buildContextPruningFactory(params: { export function buildEmbeddedExtensionFactories(params: { cfg: OpenClawConfig | undefined; sessionManager: SessionManager; + workspaceDir?: string; provider: string; modelId: string; model: ProviderRuntimeModel | undefined; @@ -167,6 +168,8 @@ export function buildEmbeddedExtensionFactories(params: { qualityGuardMaxRetries: qualityGuardCfg?.maxRetries, model: params.model, recentTurnsPreserve: compactionCfg?.recentTurnsPreserve, + workspaceDir: params.workspaceDir, + postCompactionSections: compactionCfg?.postCompactionSections, provider: compactionCfg?.provider, }); factories.push(compactionSafeguardExtension); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index a3bb3a88d03e..df7d9e47a968 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -2226,6 +2226,7 @@ export async function runEmbeddedAttempt( const extensionFactories = buildEmbeddedExtensionFactories({ cfg: params.config, sessionManager, + workspaceDir: effectiveWorkspace, provider: params.provider, modelId: params.modelId, model: params.model, diff --git a/src/agents/pi-hooks/compaction-safeguard-runtime.ts b/src/agents/pi-hooks/compaction-safeguard-runtime.ts index 545c05ec0b03..307bf2ca5baf 100644 --- a/src/agents/pi-hooks/compaction-safeguard-runtime.ts +++ b/src/agents/pi-hooks/compaction-safeguard-runtime.ts @@ -15,6 +15,8 @@ export type CompactionSafeguardRuntimeValue = { */ model?: Model; recentTurnsPreserve?: number; + workspaceDir?: string; + postCompactionSections?: string[]; qualityGuardEnabled?: boolean; qualityGuardMaxRetries?: number; /** diff --git a/src/agents/pi-hooks/compaction-safeguard.test.ts b/src/agents/pi-hooks/compaction-safeguard.test.ts index 1cd8deb3f62c..6fcda14f73ca 100644 --- a/src/agents/pi-hooks/compaction-safeguard.test.ts +++ b/src/agents/pi-hooks/compaction-safeguard.test.ts @@ -2370,7 +2370,9 @@ async function expectWorkspaceSummaryEmptyForAgentsAlias( const outside = path.join(root, "outside-secret.txt"); fs.writeFileSync(outside, "secret"); createAlias(outside, path.join(root, "AGENTS.md")); - await expect(readWorkspaceContextForSummary()).resolves.toBe(""); + await expect(readWorkspaceContextForSummary(["Session Startup", "Red Lines"])).resolves.toBe( + "", + ); } finally { cwdSpy.mockRestore(); fs.rmSync(root, { recursive: true, force: true }); @@ -2378,6 +2380,83 @@ async function expectWorkspaceSummaryEmptyForAgentsAlias( } describe("readWorkspaceContextForSummary", () => { + async function withWorkspaceSummary( + content: string, + sectionNames: string[] | undefined, + ): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-summary-")); + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(root); + try { + fs.writeFileSync(path.join(root, "AGENTS.md"), content); + return await readWorkspaceContextForSummary(sectionNames); + } finally { + cwdSpy.mockRestore(); + fs.rmSync(root, { recursive: true, force: true }); + } + } + + it("returns empty when post-compaction sections are not configured", async () => { + const result = await withWorkspaceSummary( + "## Session Startup\n\nRead AGENTS.md\n\n## Red Lines\n\nBe careful.\n", + undefined, + ); + + expect(result).toBe(""); + }); + + it("returns empty when post-compaction sections are explicitly disabled", async () => { + const result = await withWorkspaceSummary("## Session Startup\n\nRead AGENTS.md\n", []); + + expect(result).toBe(""); + }); + + it("injects workspace critical rules only for explicit section opt-in", async () => { + const result = await withWorkspaceSummary( + "## Session Startup\n\nRead AGENTS.md\n\n## Other\n\nIgnore me.\n", + ["Session Startup", "Red Lines"], + ); + + expect(result).toContain(""); + expect(result).toContain("## Session Startup"); + expect(result).toContain("Read AGENTS.md"); + expect(result).not.toContain("Ignore me"); + }); + + it("reads workspace context from the configured workspace instead of process cwd", async () => { + const processRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-cwd-")); + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-workspace-")); + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(processRoot); + try { + fs.writeFileSync( + path.join(processRoot, "AGENTS.md"), + "## Session Startup\n\nWrong cwd rules.\n", + ); + fs.writeFileSync( + path.join(workspaceRoot, "AGENTS.md"), + "## Session Startup\n\nUse the run workspace rules.\n", + ); + + const result = await readWorkspaceContextForSummary(["Session Startup"], workspaceRoot); + + expect(result).toContain("Use the run workspace rules."); + expect(result).not.toContain("Wrong cwd rules."); + } finally { + cwdSpy.mockRestore(); + fs.rmSync(processRoot, { recursive: true, force: true }); + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); + + it("preserves legacy fallback only for the explicit default section pair", async () => { + const result = await withWorkspaceSummary( + "## Every Session\n\nDo startup things.\n\n## Safety\n\nBe safe.\n", + ["Red Lines", "Session Startup"], + ); + + expect(result).toContain("Do startup things"); + expect(result).toContain("Be safe"); + }); + it.runIf(process.platform !== "win32")( "returns empty when AGENTS.md is a symlink escape", async () => { diff --git a/src/agents/pi-hooks/compaction-safeguard.ts b/src/agents/pi-hooks/compaction-safeguard.ts index 64416a93452f..7e76e55277d1 100644 --- a/src/agents/pi-hooks/compaction-safeguard.ts +++ b/src/agents/pi-hooks/compaction-safeguard.ts @@ -128,9 +128,9 @@ function coerceTimestamp(value: unknown): number { return 0; } -function sessionBranchEntryToMessage(entry: SessionBranchEntry): AgentMessage | undefined { +function sessionBranchEntryToMessage(entry: SessionBranchEntry): unknown { if (entry.type === "message" && entry.message && typeof entry.message === "object") { - return entry.message as AgentMessage; + return entry.message; } if (entry.type === "custom_message") { return { @@ -140,7 +140,7 @@ function sessionBranchEntryToMessage(entry: SessionBranchEntry): AgentMessage | display: entry.display !== false, details: entry.details, timestamp: coerceTimestamp(entry.timestamp), - } as AgentMessage; + }; } if (entry.type === "branch_summary") { return { @@ -148,7 +148,7 @@ function sessionBranchEntryToMessage(entry: SessionBranchEntry): AgentMessage | summary: typeof entry.summary === "string" ? entry.summary : "", fromId: typeof entry.fromId === "string" ? entry.fromId : "root", timestamp: coerceTimestamp(entry.timestamp), - } as AgentMessage; + }; } return undefined; } @@ -706,13 +706,10 @@ function splitPreservedRecentTurns(params: { continue; } const message = params.messages[i]; - const role = (message as { role?: unknown }).role; - if (role !== "assistant") { + if (message.role !== "assistant") { continue; } - const toolCalls = extractToolCallsFromAssistant( - message as Extract, - ); + const toolCalls = extractToolCallsFromAssistant(message); for (const toolCall of toolCalls) { preservedToolCallIds.add(toolCall.id); } @@ -728,12 +725,10 @@ function splitPreservedRecentTurns(params: { if (preservedStartIndex >= 0) { for (let i = preservedStartIndex; i < params.messages.length; i += 1) { const message = params.messages[i]; - if ((message as { role?: unknown }).role !== "toolResult") { + if (message.role !== "toolResult") { continue; } - const toolResultId = extractToolResultId( - message as Extract, - ); + const toolResultId = extractToolResultId(message); if (toolResultId && preservedToolCallIds.has(toolResultId)) { preservedIndexSet.add(i); } @@ -824,13 +819,19 @@ function extractLatestUserAsk(messages: AgentMessage[]): string | null { /** * Read and format critical workspace context for compaction summary. - * Extracts "Session Startup" and "Red Lines" from AGENTS.md. - * Falls back to legacy names "Every Session" and "Safety". + * Uses explicitly configured AGENTS.md section names only. + * The default "Session Startup" / "Red Lines" pair preserves the legacy + * "Every Session" / "Safety" fallback. * Limited to 2000 chars to avoid bloating the summary. */ -async function readWorkspaceContextForSummary(): Promise { +async function readWorkspaceContextForSummary( + sectionNames?: string[], + workspaceDir = process.cwd(), +): Promise { const MAX_SUMMARY_CONTEXT_CHARS = 2000; - const workspaceDir = process.cwd(); + if (!Array.isArray(sectionNames) || sectionNames.length === 0) { + return ""; + } const agentsPath = path.join(workspaceDir, "AGENTS.md"); try { @@ -850,10 +851,13 @@ async function readWorkspaceContextForSummary(): Promise { fs.closeSync(opened.fd); } })(); - // Accept legacy section names ("Every Session", "Safety") as fallback - // for backward compatibility with older AGENTS.md templates. - let sections = extractSections(content, ["Session Startup", "Red Lines"]); - if (sections.length === 0) { + let sections = extractSections(content, sectionNames); + if ( + sections.length === 0 && + sectionNames.length === 2 && + sectionNames.some((name) => name.trim().toLowerCase() === "session startup") && + sectionNames.some((name) => name.trim().toLowerCase() === "red lines") + ) { sections = extractSections(content, ["Every Session", "Safety"]); } @@ -981,7 +985,10 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { if (providerResult !== undefined) { // Provider succeeded — assemble suffix metadata and return. // No quality guard: the provider is trusted. - const workspaceContext = await readWorkspaceContextForSummary(); + const workspaceContext = await readWorkspaceContextForSummary( + runtime?.postCompactionSections, + runtime?.workspaceDir, + ); const suffix = assembleSuffix({ splitTurnSection, preservedTurnsSection, @@ -1268,7 +1275,10 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { // Cap the main history body first, then append split-turn context, preserved // turns, diagnostics, and workspace rules so they survive truncation. - const workspaceContext = await readWorkspaceContextForSummary(); + const workspaceContext = await readWorkspaceContextForSummary( + runtime?.postCompactionSections, + runtime?.workspaceDir, + ); const suffix = assembleSuffix({ splitTurnSection: lastSplitTurnSection, preservedTurnsSection, 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 5e69fe9b84bd..6a8f45223c1f 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -8,6 +8,7 @@ import { isEmbeddedPiRunActive, } from "../../agents/pi-embedded-runner/runs.js"; import { clearRuntimeConfigSnapshot } from "../../config/config.js"; +import type { OpenClawConfig } from "../../config/config.js"; import * as sessionTypesModule from "../../config/sessions.js"; import type { SessionEntry } from "../../config/sessions.js"; import { loadSessionStore, saveSessionStore } from "../../config/sessions.js"; @@ -292,6 +293,7 @@ describe("runReplyAgent auto-compaction token update", () => { async function runBaseReplyWithAgentMeta(params: { agentMeta: Record; collectDiagnostics?: boolean; + config?: OpenClawConfig; tmpPrefix: string; workspaceDir?: string; }) { @@ -322,6 +324,7 @@ describe("runReplyAgent auto-compaction token update", () => { const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({ storePath, sessionEntry, + config: params.config, workspaceDir: params.workspaceDir, }); @@ -507,7 +510,7 @@ describe("runReplyAgent auto-compaction token update", () => { ); }); - it("reads post-compaction context from the queued workspace instead of process cwd", async () => { + it("reads opted-in post-compaction context from the queued workspace instead of process cwd", async () => { const workspaceDir = await fs.mkdtemp( path.join(os.tmpdir(), "openclaw-post-compaction-workspace-"), ); @@ -529,6 +532,13 @@ describe("runReplyAgent auto-compaction token update", () => { const { sessionKey } = await runBaseReplyWithAgentMeta({ tmpPrefix: "openclaw-post-compaction-workspace-root-", workspaceDir, + config: { + agents: { + defaults: { + compaction: { postCompactionSections: ["Session Startup", "Red Lines"] }, + }, + }, + }, agentMeta: { compactionCount: 1, lastCallUsage: { input: 10_000, output: 500, total: 10_500 }, diff --git a/src/auto-reply/reply/post-compaction-context.test.ts b/src/auto-reply/reply/post-compaction-context.test.ts index 860814ebf459..3f5a50af3b85 100644 --- a/src/auto-reply/reply/post-compaction-context.test.ts +++ b/src/auto-reply/reply/post-compaction-context.test.ts @@ -1,14 +1,22 @@ import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { readPostCompactionContext } from "./post-compaction-context.js"; describe("readPostCompactionContext", () => { - const tmpDir = path.join("/tmp", "test-post-compaction-" + Date.now()); + let tmpDir = ""; + const defaultPostCompactionCfg = { + agents: { + defaults: { + compaction: { postCompactionSections: ["Session Startup", "Red Lines"] }, + }, + }, + } satisfies OpenClawConfig; beforeEach(() => { - fs.mkdirSync(tmpDir, { recursive: true }); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "test-post-compaction-")); }); afterEach(() => { @@ -36,6 +44,30 @@ describe("readPostCompactionContext", () => { } } + async function readDefaultPostCompactionContext(options?: { + cfg?: OpenClawConfig; + agentId?: string; + nowMs?: number; + }) { + const cfg = { + ...defaultPostCompactionCfg, + ...options?.cfg, + agents: { + ...defaultPostCompactionCfg.agents, + ...options?.cfg?.agents, + defaults: { + ...defaultPostCompactionCfg.agents.defaults, + ...options?.cfg?.agents?.defaults, + compaction: { + ...defaultPostCompactionCfg.agents.defaults.compaction, + ...options?.cfg?.agents?.defaults?.compaction, + }, + }, + }, + } as OpenClawConfig; + return readPostCompactionContext(tmpDir, { ...options, cfg }); + } + it("returns null when no AGENTS.md exists", async () => { const result = await readPostCompactionContext(tmpDir); expect(result).toBeNull(); @@ -43,7 +75,7 @@ describe("readPostCompactionContext", () => { it("returns null when AGENTS.md has no relevant sections", async () => { fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), "# My Agent\n\nSome content.\n"); - const result = await readPostCompactionContext(tmpDir); + const result = await readDefaultPostCompactionContext(); expect(result).toBeNull(); }); @@ -61,7 +93,7 @@ Read these files: Not relevant. `; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); - const result = await readPostCompactionContext(tmpDir); + const result = await readDefaultPostCompactionContext(); expect(result).toContain("Session Startup"); expect(result).toContain("WORKFLOW_AUTO.md"); expect(result).toContain("Post-compaction context refresh"); @@ -81,7 +113,7 @@ Never do Y. Stuff. `; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); - const result = await readPostCompactionContext(tmpDir); + const result = await readDefaultPostCompactionContext(); expect(result).toContain("Red Lines"); expect(result).toContain("Never do X"); }); @@ -102,7 +134,7 @@ Never break things. Ignore this. `; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); - const result = await readPostCompactionContext(tmpDir); + const result = await readDefaultPostCompactionContext(); expect(result).toContain("Session Startup"); expect(result).toContain("Red Lines"); expect(result).not.toContain("Other"); @@ -111,7 +143,7 @@ Ignore this. it("truncates when content exceeds limit", async () => { const longContent = "## Session Startup\n\n" + "A".repeat(4000) + "\n\n## Other\n\nStuff."; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), longContent); - const result = await readPostCompactionContext(tmpDir); + const result = await readDefaultPostCompactionContext(); expect(result).toContain("[truncated]"); expect(result?.length).toBeLessThan(2600); }); @@ -138,7 +170,7 @@ Ignore this. }, } as OpenClawConfig; - const result = await readPostCompactionContext(tmpDir, { cfg, agentId: "writer" }); + const result = await readDefaultPostCompactionContext({ cfg, agentId: "writer" }); expect(result).toContain("[truncated]"); expect(result?.length).toBeLessThan(1_200); }); @@ -153,7 +185,7 @@ Read WORKFLOW_AUTO.md ## Other `; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); - const result = await readPostCompactionContext(tmpDir); + const result = await readDefaultPostCompactionContext(); expect(result).toContain("WORKFLOW_AUTO.md"); }); @@ -167,7 +199,7 @@ Read these files. ### Other `; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); - const result = await readPostCompactionContext(tmpDir); + const result = await readDefaultPostCompactionContext(); expect(result).toContain("Read these files"); }); @@ -186,7 +218,7 @@ Real red lines here. ## Other `; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); - const result = await readPostCompactionContext(tmpDir); + const result = await readDefaultPostCompactionContext(); expect(result).toContain("Real red lines here"); expect(result).not.toContain("inside a code block"); }); @@ -203,7 +235,7 @@ Never do Y. ## Other Section `; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); - const result = await readPostCompactionContext(tmpDir); + const result = await readDefaultPostCompactionContext(); expect(result).toContain("Rule 1"); expect(result).toContain("Rule 2"); expect(result).not.toContain("Other Section"); @@ -216,7 +248,7 @@ Never do Y. fs.writeFileSync(outside, "secret"); fs.symlinkSync(outside, path.join(tmpDir, "AGENTS.md")); - const result = await readPostCompactionContext(tmpDir); + const result = await readDefaultPostCompactionContext(); expect(result).toBeNull(); }, ); @@ -228,7 +260,7 @@ Never do Y. fs.writeFileSync(outside, "secret"); fs.linkSync(outside, path.join(tmpDir, "AGENTS.md")); - const result = await readPostCompactionContext(tmpDir); + const result = await readDefaultPostCompactionContext(); expect(result).toBeNull(); }, ); @@ -248,7 +280,7 @@ Never modify memory/YYYY-MM-DD.md destructively. } as OpenClawConfig; // 2026-03-03 14:00 UTC = 2026-03-03 09:00 EST const nowMs = Date.UTC(2026, 2, 3, 14, 0, 0); - const result = await readPostCompactionContext(tmpDir, { cfg, nowMs }); + const result = await readDefaultPostCompactionContext({ cfg, nowMs }); expect(result).toContain("memory/2026-03-03.md"); expect(result).not.toContain("memory/YYYY-MM-DD.md"); expect(result).toContain("Current time: Tuesday, March 3rd, 2026 - 9:00 AM (America/New_York)"); @@ -262,7 +294,7 @@ Read WORKFLOW.md on startup. `; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); const nowMs = Date.UTC(2026, 2, 3, 14, 0, 0); - const result = await readPostCompactionContext(tmpDir, { nowMs }); + const result = await readDefaultPostCompactionContext({ nowMs }); expect(result).toContain("Current time:"); }); @@ -270,10 +302,17 @@ Read WORKFLOW.md on startup. // postCompactionSections config // ------------------------------------------------------------------------- describe("agents.defaults.compaction.postCompactionSections", () => { - it("uses default sections (Session Startup + Red Lines) when config is not set", async () => { + it("returns null when postCompactionSections is not configured", async () => { const content = `## Session Startup\n\nDo startup.\n\n## Red Lines\n\nDo not break.\n\n## Other\n\nIgnore.\n`; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); const result = await readPostCompactionContext(tmpDir); + expect(result).toBeNull(); + }); + + it("uses default sections when explicitly configured", async () => { + const content = `## Session Startup\n\nDo startup.\n\n## Red Lines\n\nDo not break.\n\n## Other\n\nIgnore.\n`; + fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); + const result = await readDefaultPostCompactionContext(); expect(result).toContain("Session Startup"); expect(result).toContain("Red Lines"); expect(result).not.toContain("Other"); @@ -324,7 +363,6 @@ Read WORKFLOW.md on startup. }, } as OpenClawConfig; const result = await readPostCompactionContext(tmpDir, { cfg }); - // Empty array = opt-out: no post-compaction context injection expect(result).toBeNull(); }); @@ -365,14 +403,14 @@ Read WORKFLOW.md on startup. it("uses default 'Session Startup' prose when default sections are active", async () => { const content = `## Session Startup\n\nDo startup.\n`; fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); - const result = await readPostCompactionContext(tmpDir); + const result = await readDefaultPostCompactionContext(); expect(result).toContain("Run your Session Startup sequence"); }); it("falls back to legacy sections when defaults are explicitly configured", async () => { // Older AGENTS.md templates use "Every Session" / "Safety" instead of - // "Session Startup" / "Red Lines". Explicitly setting the defaults should - // still trigger the legacy fallback — same behavior as leaving the field unset. + // "Session Startup" / "Red Lines". Explicitly setting the defaults still + // triggers the legacy fallback. await expectLegacySectionFallback(["Session Startup", "Red Lines"]); }); diff --git a/src/auto-reply/reply/post-compaction-context.ts b/src/auto-reply/reply/post-compaction-context.ts index f29594b981d6..636c51baa1f0 100644 --- a/src/auto-reply/reply/post-compaction-context.ts +++ b/src/auto-reply/reply/post-compaction-context.ts @@ -94,28 +94,21 @@ export async function readPostCompactionContext( } })(); - // Extract configured sections from AGENTS.md (default: Session Startup + Red Lines). - // An explicit empty array disables post-compaction context injection entirely. const configuredSections = cfg?.agents?.defaults?.compaction?.postCompactionSections; - const sectionNames = Array.isArray(configuredSections) - ? configuredSections - : DEFAULT_POST_COMPACTION_SECTIONS; - - if (sectionNames.length === 0) { + if (!Array.isArray(configuredSections) || configuredSections.length === 0) { return null; } + const sectionNames = configuredSections; const foundSectionNames: string[] = []; let sections = extractSections(content, sectionNames, foundSectionNames); - // Fall back to legacy section names ("Every Session" / "Safety") when using - // defaults and the current headings aren't found — preserves compatibility - // with older AGENTS.md templates. The fallback also applies when the user - // explicitly configures the default pair, so that pinning the documented - // defaults never silently changes behavior vs. leaving the field unset. - const isDefaultSections = - !Array.isArray(configuredSections) || - matchesSectionSet(configuredSections, DEFAULT_POST_COMPACTION_SECTIONS); + // Legacy "Every Session" / "Safety" fallback is preserved only for users + // who explicitly opt in to the documented default section pair. + const isDefaultSections = matchesSectionSet( + configuredSections, + DEFAULT_POST_COMPACTION_SECTIONS, + ); if (sections.length === 0 && isDefaultSections) { sections = extractSections(content, LEGACY_POST_COMPACTION_SECTIONS, foundSectionNames); } diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 52d716690b21..ed6a79d1fb76 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -870,9 +870,11 @@ describe("config help copy quality", () => { expect(/mid-turn|tool loop|default:\s*false/i.test(midTurnPrecheck)).toBe(true); const postCompactionSections = FIELD_HELP["agents.defaults.compaction.postCompactionSections"]; + expect(/opt-in|Leave unset/i.test(postCompactionSections)).toBe(true); expect(/Session Startup|Red Lines/i.test(postCompactionSections)).toBe(true); expect(/Every Session|Safety/i.test(postCompactionSections)).toBe(true); expect(/\[\]|disable/i.test(postCompactionSections)).toBe(true); + expect(/duplicate project context/i.test(postCompactionSections)).toBe(true); const compactionModel = FIELD_HELP["agents.defaults.compaction.model"]; expect(/provider\/model|different model|primary agent model/i.test(compactionModel)).toBe(true); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 320a9ef5c1d3..8523b850bf6d 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1481,7 +1481,7 @@ export const FIELD_HELP: Record = { "agents.defaults.compaction.postIndexSync": 'Controls post-compaction session memory reindex mode: "off", "async", or "await" (default: "async"). Use "await" for strongest freshness, "async" for lower compaction latency, and "off" only when session-memory sync is handled elsewhere.', "agents.defaults.compaction.postCompactionSections": - 'AGENTS.md H2/H3 section names re-injected after compaction so the agent reruns critical startup guidance. Leave unset to use "Session Startup"/"Red Lines" with legacy fallback to "Every Session"/"Safety"; set to [] to disable reinjection entirely.', + 'Opt-in AGENTS.md H2/H3 section names re-injected after compaction so the agent reruns critical startup guidance. Leave unset or set [] to disable reinjection. Explicitly set ["Session Startup", "Red Lines"] to enable the legacy default pair with fallback to older "Every Session"/"Safety" headings. Enabling this can duplicate project context already present in the compaction summary.', "agents.defaults.compaction.timeoutSeconds": "Maximum time in seconds allowed for a single compaction operation before it is aborted (default: 900). Increase this for very large sessions that need more time to summarize, or decrease it to fail faster on unresponsive models.", "agents.defaults.compaction.model": diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 2239a62ae54b..9dadc9e4a780 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -512,8 +512,8 @@ export type AgentCompactionConfig = { memoryFlush?: AgentCompactionMemoryFlushConfig; /** * H2/H3 section names from AGENTS.md to inject after compaction. - * Defaults to ["Session Startup", "Red Lines"] when unset. - * Set to [] to disable post-compaction context injection entirely. + * Disabled when unset or []. + * Explicit ["Session Startup", "Red Lines"] preserves legacy fallback headings. */ postCompactionSections?: string[]; /** Optional model override for compaction summarization (e.g. "openrouter/anthropic/claude-sonnet-4-6"). diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 37106260195f..561db203e395 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -10,6 +10,7 @@ import { buildFullSuiteVitestRunPlans, buildVitestArgs, buildVitestRunPlans, + findUnmatchedExplicitTestTargets, listFullExtensionVitestProjectConfigs, orderFullSuiteSpecsForParallelRun, shouldAcquireLocalHeavyCheckLock, @@ -272,6 +273,20 @@ describe("scripts/test-projects changed-target routing", () => { ]); }); + it("allows explicit split Vitest config targets without treating them as unmatched tests", () => { + expect( + findUnmatchedExplicitTestTargets( + [ + "test/vitest/vitest.agents-core.config.ts", + "test/vitest/vitest.agents-pi-embedded.config.ts", + "test/vitest/vitest.agents-support.config.ts", + "test/vitest/vitest.agents-tools.config.ts", + ], + process.cwd(), + ), + ).toEqual([]); + }); + it("routes contract roots to separate contract shards", () => { const plans = buildVitestRunPlans([ "src/channels/plugins/contracts/channel-catalog.contract.test.ts", diff --git a/ui/src/styles/chat/tool-cards.css b/ui/src/styles/chat/tool-cards.css index 4fe6981e22ef..2e360f131c8f 100644 --- a/ui/src/styles/chat/tool-cards.css +++ b/ui/src/styles/chat/tool-cards.css @@ -548,14 +548,16 @@ .chat-tool-msg-summary { display: flex; - align-items: center; + align-items: flex-start; + flex-wrap: wrap; gap: 8px; min-width: 0; box-sizing: border-box; padding: 8px 11px; cursor: pointer; - font-size: 12px; - color: var(--muted); + font-size: var(--control-ui-text-sm); + line-height: 1.4; + color: var(--text); user-select: none; list-style: none; border: 1px solid color-mix(in srgb, var(--border) 75%, transparent); @@ -590,6 +592,7 @@ .chat-tool-msg-summary::before { content: "▸"; font-size: 10px; + margin-top: 3px; flex-shrink: 0; transition: transform 150ms ease; } @@ -627,6 +630,7 @@ justify-content: center; width: 14px; height: 14px; + margin-top: 2px; color: var(--accent); opacity: 0.75; flex-shrink: 0; @@ -645,33 +649,26 @@ .chat-tool-msg-summary__label { font-weight: 600; color: var(--text); - flex: 1 1 50%; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.chat-tool-msg-summary__names { - font-family: var(--mono); - font-size: 11px; - opacity: 0.85; flex: 0 1 auto; - max-width: 42%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; min-width: 0; + max-width: 100%; + overflow: visible; + overflow-wrap: anywhere; + text-overflow: clip; + white-space: normal; } +.chat-tool-msg-summary__names, .chat-tool-msg-summary__preview { font-family: var(--mono); - font-size: 11px; - opacity: 0.85; - flex: 1 1 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + font-size: var(--control-ui-text-xs); + color: color-mix(in srgb, var(--text) 76%, var(--muted) 24%); + flex: 1 1 18rem; + max-width: 100%; + overflow: visible; + overflow-wrap: anywhere; + text-overflow: clip; + white-space: normal; min-width: 0; } diff --git a/ui/src/styles/chat/tool-cards.test.ts b/ui/src/styles/chat/tool-cards.test.ts new file mode 100644 index 000000000000..0d419a92432b --- /dev/null +++ b/ui/src/styles/chat/tool-cards.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { readStyleSheet } from "../../../../test/helpers/ui-style-fixtures.js"; + +function readToolCardsCss(): string { + return readStyleSheet("ui/src/styles/chat/tool-cards.css"); +} + +describe("chat tool card styles", () => { + it("keeps collapsed tool summaries readable without premature ellipsis", () => { + const css = readToolCardsCss(); + + expect(css).toContain(".chat-tool-msg-summary {"); + expect(css).toContain("flex-wrap: wrap;"); + expect(css).toContain("font-size: var(--control-ui-text-sm);"); + expect(css).toContain("color: var(--text);"); + expect(css).toMatch(/\.chat-tool-msg-summary__names\s*,/); + expect(css).toContain(".chat-tool-msg-summary__preview"); + expect(css).toContain("overflow-wrap: anywhere;"); + expect(css).toContain("text-overflow: clip;"); + expect(css).toContain("white-space: normal;"); + expect(css).not.toContain("max-width: 42%;"); + }); +}); diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts index a541d860469b..83b3afdd2428 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/ui/chat/grouped-render.test.ts @@ -97,10 +97,14 @@ vi.mock("./chat-avatar.ts", () => ({ vi.mock("../tool-display.ts", () => ({ formatToolDetail: () => undefined, - resolveToolDisplay: ({ name }: { name: string }) => ({ + resolveToolDisplay: ({ name, args }: { name: string; args?: unknown }) => ({ name, label: name, icon: "zap", + detail: + args && typeof args === "object" && "detail" in args + ? String((args as { detail: unknown }).detail) + : undefined, }), })); @@ -943,6 +947,42 @@ describe("grouped chat rendering", () => { ); }); + it("cleans collapsed tool connector copy while preserving expanded raw input", () => { + const container = document.createElement("div"); + const message = { + id: "assistant-string-tool", + role: "assistant", + toolCallId: "call-string-tool", + content: [ + { + type: "toolcall", + id: "call-string-tool", + name: "presentation_create", + arguments: "with Example Deck", + }, + ], + timestamp: Date.now(), + }; + renderAssistantMessage(container, message, { + isToolMessageExpanded: () => false, + }); + + expect(container.querySelector(".chat-tool-msg-summary__label")?.textContent?.trim()).toBe( + "Example Deck", + ); + expect(container.querySelector(".chat-tool-msg-summary")?.textContent).not.toContain( + "with Example Deck", + ); + + renderAssistantMessage(container, message, { + isToolMessageExpanded: () => true, + }); + + expect(container.querySelector(".chat-tool-card__block code")?.textContent).toBe( + "with Example Deck", + ); + }); + it("renders expanded tool output rows and their json content", () => { const container = document.createElement("div"); renderMessageGroups( diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 7c50d75d982e..75b49e9d02b9 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -25,10 +25,13 @@ import { isToolResultMessage, normalizeMessage } from "./message-normalizer.ts"; import { normalizeRoleForGrouping } from "./role-normalizer.ts"; import { extractToolCards, + formatCollapsedToolPreviewText, + formatCollapsedToolSummaryText, renderExpandedToolCardContent, renderRawOutputToggle, renderToolCard, renderToolPreview, + resolveCollapsedToolDetail, } from "./tool-cards.ts"; type AssistantAttachmentAvailability = @@ -1537,21 +1540,28 @@ function renderGroupedMessage( detailMode: "explain", }) : null; - const toolSummaryLabel = singleToolDisplay?.detail + const singleToolDisplayDetail = + singleToolCard && singleToolDisplay + ? resolveCollapsedToolDetail(singleToolCard, singleToolDisplay.detail) + : undefined; + const toolSummaryLabelRaw = singleToolDisplayDetail ? singleToolCard?.outputText?.trim() ? "output" : undefined : toolNames.length <= 3 ? toolNames.join(", ") : `${toolNames.slice(0, 2).join(", ")} +${toolNames.length - 2} more`; + const toolSummaryLabel = formatCollapsedToolSummaryText(toolSummaryLabelRaw); const toolPreview = - markdown && !toolSummaryLabel ? markdown.trim().replace(/\s+/g, " ").slice(0, 120) : ""; - const toolMessageLabel = - singleToolDisplay?.detail && !markdown && !hasImages - ? singleToolDisplay.detail + markdown && !toolSummaryLabel ? (formatCollapsedToolPreviewText(markdown) ?? "") : ""; + const toolMessageLabelRaw = + singleToolDisplayDetail && !markdown && !hasImages + ? singleToolDisplayDetail : singleToolDisplay && !markdown && !hasImages ? singleToolDisplay.label : "Tool output"; + const toolMessageLabel = + formatCollapsedToolSummaryText(toolMessageLabelRaw) ?? toolMessageLabelRaw; const toolMessageIcon = singleToolDisplay ? icons[singleToolDisplay.icon] : icons.zap; const duplicateCount = Math.max(1, Math.floor(opts.duplicateCount ?? 1)); diff --git a/ui/src/ui/chat/tool-cards.test.ts b/ui/src/ui/chat/tool-cards.test.ts index c8fdb297f9e8..f0f29a3fd3a9 100644 --- a/ui/src/ui/chat/tool-cards.test.ts +++ b/ui/src/ui/chat/tool-cards.test.ts @@ -2,7 +2,11 @@ import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; -import { renderToolCard } from "./tool-cards.ts"; +import { + formatCollapsedToolPreviewText, + formatCollapsedToolSummaryText, + renderToolCard, +} from "./tool-cards.ts"; vi.mock("../icons.ts", () => ({ icons: {}, @@ -10,13 +14,17 @@ vi.mock("../icons.ts", () => ({ vi.mock("../tool-display.ts", () => ({ formatToolDetail: () => undefined, - resolveToolDisplay: ({ name }: { name: string }) => ({ + resolveToolDisplay: ({ name, args }: { name: string; args?: unknown }) => ({ name, label: name .split(/[._-]/g) .map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part)) .join(" "), icon: "zap", + detail: + args && typeof args === "object" && "detail" in args + ? String((args as { detail: unknown }).detail) + : undefined, }), })); @@ -111,6 +119,80 @@ describe("tool-cards", () => { expect(container.querySelector(".chat-tool-msg-body")).toBeNull(); }); + it("cleans connector copy from collapsed summaries without changing raw details", () => { + const container = document.createElement("div"); + render( + renderToolCard( + { + id: "msg:5b:call-5b", + name: "presentation_create", + args: "with Example Deck", + inputText: "with Example Deck", + }, + { expanded: false, onToggleExpanded: vi.fn() }, + ), + container, + ); + + const summaryButton = container.querySelector("button.chat-tool-msg-summary"); + expect(summaryButton?.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe( + "Example Deck", + ); + + render( + renderToolCard( + { + id: "msg:5b:call-5b", + name: "presentation_create", + args: "with Example Deck", + inputText: "with Example Deck", + }, + { expanded: true, onToggleExpanded: vi.fn() }, + ), + container, + ); + + expect(container.querySelector(".chat-tool-card__block code")?.textContent).toBe( + "with Example Deck", + ); + }); + + it("normalizes collapsed summary text for display only", () => { + expect(formatCollapsedToolSummaryText(" with Example Deck ")).toBe("Example Deck"); + expect(formatCollapsedToolSummaryText("Example Deck")).toBe("Example Deck"); + expect(formatCollapsedToolSummaryText(" ")).toBeUndefined(); + }); + + it("keeps collapsed markdown previews bounded after display cleanup", () => { + const preview = formatCollapsedToolPreviewText(`with ${"A".repeat(200)}`); + + expect(preview).toHaveLength(120); + expect(preview?.startsWith("A")).toBe(true); + expect(preview).not.toContain("with "); + }); + + it("bounds raw string argument fallbacks in collapsed summaries", () => { + const container = document.createElement("div"); + const rawInput = `with ${"A".repeat(200)}`; + render( + renderToolCard( + { + id: "msg:5c:call-5c", + name: "presentation_create", + args: rawInput, + inputText: rawInput, + }, + { expanded: false, onToggleExpanded: vi.fn() }, + ), + container, + ); + + const labelText = container.querySelector(".chat-tool-msg-summary__label")?.textContent?.trim(); + expect(labelText).toHaveLength(120); + expect(labelText?.startsWith("A")).toBe(true); + expect(labelText).not.toContain("with "); + }); + it("keeps raw details for legacy canvas tool output without rendering tool-row previews", () => { const container = document.createElement("div"); render( diff --git a/ui/src/ui/chat/tool-cards.ts b/ui/src/ui/chat/tool-cards.ts index db7e04141953..ec884b01b5d8 100644 --- a/ui/src/ui/chat/tool-cards.ts +++ b/ui/src/ui/chat/tool-cards.ts @@ -136,6 +136,23 @@ ${text} \`\`\``; } +export function formatCollapsedToolSummaryText(value: string | undefined): string | undefined { + const normalized = value?.trim().replace(/\s+/g, " "); + if (!normalized) { + return undefined; + } + const withoutConnector = normalized.replace(/^with\s+/i, "").trim(); + return withoutConnector || normalized; +} + +export function formatCollapsedToolPreviewText(value: string | undefined): string | undefined { + const normalized = formatCollapsedToolSummaryText(value); + if (!normalized) { + return undefined; + } + return normalized.slice(0, 120); +} + function findLatestCard(cards: ToolCard[], id: string, name: string): ToolCard | undefined { for (let i = cards.length - 1; i >= 0; i--) { const card = cards[i]; @@ -398,6 +415,8 @@ function renderCollapsedToolSummary(params: { onToggleExpanded: () => void; }) { const { label, icon, name, expanded, onToggleExpanded } = params; + const displayLabel = formatCollapsedToolSummaryText(label) ?? label; + const displayName = formatCollapsedToolSummaryText(name); return html` `; } +export function resolveCollapsedToolDetail(card: ToolCard, displayDetail: string | undefined) { + const directDetail = displayDetail?.trim(); + if (directDetail) { + return displayDetail; + } + if (typeof card.args !== "string") { + return undefined; + } + const inputText = card.inputText?.trim() ? card.inputText : card.args; + return formatCollapsedToolPreviewText(inputText); +} + export function renderToolCard( card: ToolCard, opts: { @@ -425,8 +458,9 @@ export function renderToolCard( ) { const hasOutput = Boolean(card.outputText?.trim()); const display = resolveToolDisplay({ name: card.name, args: card.args, detailMode: "explain" }); - const previewLabel = display.detail ?? display.label; - const previewName = display.detail && hasOutput ? "output" : undefined; + const collapsedDetail = resolveCollapsedToolDetail(card, display.detail); + const previewLabel = collapsedDetail ?? display.label; + const previewName = collapsedDetail && hasOutput ? "output" : undefined; return html`