mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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 96101664f0.
- pnpm exec oxfmt --check --threads=1 <changed files>
- OPENCLAW_OXLINT_SKIP_PREPARE=1 node scripts/run-oxlint.mjs <changed ts/mjs files>
- 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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1035,6 +1035,7 @@ async function compactEmbeddedPiSessionDirectOnce(
|
||||
const extensionFactories = buildEmbeddedExtensionFactories({
|
||||
cfg: params.config,
|
||||
sessionManager,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
provider,
|
||||
modelId,
|
||||
model,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,6 +15,8 @@ export type CompactionSafeguardRuntimeValue = {
|
||||
*/
|
||||
model?: Model<Api>;
|
||||
recentTurnsPreserve?: number;
|
||||
workspaceDir?: string;
|
||||
postCompactionSections?: string[];
|
||||
qualityGuardEnabled?: boolean;
|
||||
qualityGuardMaxRetries?: number;
|
||||
/**
|
||||
|
||||
@@ -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<string> {
|
||||
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("<workspace-critical-rules>");
|
||||
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 () => {
|
||||
|
||||
@@ -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<AgentMessage, { role: "assistant" }>,
|
||||
);
|
||||
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<AgentMessage, { role: "toolResult" }>,
|
||||
);
|
||||
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<string> {
|
||||
async function readWorkspaceContextForSummary(
|
||||
sectionNames?: string[],
|
||||
workspaceDir = process.cwd(),
|
||||
): Promise<string> {
|
||||
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<string> {
|
||||
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,
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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 },
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1481,7 +1481,7 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"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":
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
23
ui/src/styles/chat/tool-cards.test.ts
Normal file
23
ui/src/styles/chat/tool-cards.test.ts
Normal file
@@ -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%;");
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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`
|
||||
<button
|
||||
class="chat-tool-msg-summary"
|
||||
@@ -406,12 +425,26 @@ function renderCollapsedToolSummary(params: {
|
||||
@click=${() => onToggleExpanded()}
|
||||
>
|
||||
<span class="chat-tool-msg-summary__icon">${icon}</span>
|
||||
<span class="chat-tool-msg-summary__label">${label}</span>
|
||||
${name ? html`<span class="chat-tool-msg-summary__names">${name}</span>` : nothing}
|
||||
<span class="chat-tool-msg-summary__label">${displayLabel}</span>
|
||||
${displayName
|
||||
? html`<span class="chat-tool-msg-summary__names">${displayName}</span>`
|
||||
: nothing}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user