Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions assistant/src/__tests__/conversation-clean-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* Tests for the `/clean` slash command primitives.
*
* `/clean` is wired up so that:
* 1. Runtime injection prefixes are stripped from user-message text blocks
* (same allowlist `/compact` uses).
* 2. Assistant turns, tool_use blocks, and tool_result blocks are preserved
* verbatim — `/clean` never alters history shape.
* 3. The user-facing result message reports tokens reclaimed and the
* number of messages preserved.
*
* The slash-routing layer is covered by `conversation-slash-commands.test.ts`;
* the graph-memory eviction hook is covered by the v2 routing tests. This
* file focuses on the formatter and the strip behavior that defines the
* "no history loss" contract.
*/
import { describe, expect, test } from "bun:test";

import { formatCleanResult } from "../daemon/conversation-process.js";
import { stripInjectionsForCompaction } from "../daemon/conversation-runtime-assembly.js";
import type { Message } from "../providers/types.js";

describe("formatCleanResult", () => {
test("formats token reclamation and preserved-message count", () => {
const out = formatCleanResult({
previousEstimatedInputTokens: 100_000,
estimatedInputTokens: 95_000,
maxInputTokens: 200_000,
preservedMessages: 250,
});
expect(out).toContain("Context Cleaned");
expect(out).toContain("100,000 → 95,000 (5,000 reclaimed)");
expect(out).toContain("95,000 / 200,000 tokens");
expect(out).toContain("250 preserved");
});

test("renders zero reclaimed when nothing was stripped", () => {
const out = formatCleanResult({
previousEstimatedInputTokens: 12_345,
estimatedInputTokens: 12_345,
maxInputTokens: 200_000,
preservedMessages: 10,
});
expect(out).toContain("12,345 → 12,345 (0 reclaimed)");
expect(out).toContain("10 preserved");
});
});

describe("stripInjectionsForCompaction preserves history shape", () => {
test("strips known injection prefixes from user text blocks", () => {
const messages: Message[] = [
{
role: "user",
content: [
{
type: "text",
text: "<NOW.md Always keep this up to date>\nfoo\n</NOW.md>",
},
{ type: "text", text: "Hello, please help with X." },
],
},
];
const out = stripInjectionsForCompaction(messages);
expect(out).toHaveLength(1);
expect(out[0].content).toHaveLength(1);
expect(out[0].content[0]).toEqual({
type: "text",
text: "Hello, please help with X.",
});
});

test("preserves assistant turns and tool_use/tool_result blocks verbatim", () => {
const messages: Message[] = [
{
role: "user",
content: [
{
type: "text",
text: "<knowledge_base>\nstale kb\n</knowledge_base>",
},
{ type: "text", text: "Run the calculator." },
],
},
{
role: "assistant",
content: [
{ type: "text", text: "Using the calculator." },
{
type: "tool_use",
id: "tool-1",
name: "calculator",
input: { expr: "1 + 2" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-1",
content: "3",
},
],
},
{
role: "assistant",
content: [{ type: "text", text: "The answer is 3." }],
},
];

const out = stripInjectionsForCompaction(messages);

expect(out).toHaveLength(4);
expect(out[0].content).toEqual([
{ type: "text", text: "Run the calculator." },
]);
expect(out[1]).toEqual(messages[1]);
expect(out[2]).toEqual(messages[2]);
expect(out[3]).toEqual(messages[3]);
});

test("leaves <turn_context>, <workspace>, and <memory __injected> alone", () => {
const messages: Message[] = [
{
role: "user",
content: [
{ type: "text", text: "<turn_context>\nnow\n</turn_context>" },
{ type: "text", text: "<workspace>\nfiles\n</workspace>" },
{ type: "text", text: "<memory __injected>\nrecent\n</memory>" },
],
},
];
const out = stripInjectionsForCompaction(messages);
expect(out[0].content).toEqual(messages[0].content);
});
});
44 changes: 36 additions & 8 deletions assistant/src/__tests__/conversation-slash-commands.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { writeFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";

import {
invalidateConfigCache,
loadRawConfig,
} from "../config/loader.js";
import { invalidateConfigCache, loadRawConfig } from "../config/loader.js";
import {
classifySlash,
resolveSlash,
Expand Down Expand Up @@ -42,6 +39,7 @@ describe("resolveSlash /commands interface-aware help", () => {
expect(lines).toEqual([
"/commands — List all available commands",
"/compact — Force context compaction immediately",
"/clean — Strip injected runtime context and reset memory injection state (no summarization)",
"/context — Show conversation context usage",
"/model — List or switch inference profile",
"/models — List all available models",
Expand All @@ -58,6 +56,7 @@ describe("resolveSlash /commands interface-aware help", () => {
expect(lines).toEqual([
"/commands — List all available commands",
"/compact — Force context compaction immediately",
"/clean — Strip injected runtime context and reset memory injection state (no summarization)",
"/context — Show conversation context usage",
"/model — List or switch inference profile",
"/models — List all available models",
Expand All @@ -74,6 +73,7 @@ describe("resolveSlash /commands interface-aware help", () => {
expect(lines).toEqual([
"/commands — List all available commands",
"/compact — Force context compaction immediately",
"/clean — Strip injected runtime context and reset memory injection state (no summarization)",
"/context — Show conversation context usage",
"/model — List or switch inference profile",
"/models — List all available models",
Expand All @@ -87,6 +87,7 @@ describe("resolveSlash /commands interface-aware help", () => {
expect(lines).toEqual([
"/commands — List all available commands",
"/compact — Force context compaction immediately",
"/clean — Strip injected runtime context and reset memory injection state (no summarization)",
"/context — Show conversation context usage",
"/model — List or switch inference profile",
"/models — List all available models",
Expand All @@ -99,6 +100,7 @@ describe("resolveSlash /commands interface-aware help", () => {
expect(lines).toEqual([
"/commands — List all available commands",
"/compact — Force context compaction immediately",
"/clean — Strip injected runtime context and reset memory injection state (no summarization)",
"/model — List or switch inference profile",
"/models — List all available models",
]);
Expand Down Expand Up @@ -187,13 +189,38 @@ describe("resolveSlash /compact target override", () => {
});
});

describe("resolveSlash /clean", () => {
test("plain /clean resolves to kind=clean", async () => {
const result = await resolveSlash("/clean");
expect(result).toEqual({ kind: "clean" });
});

test("/clean tolerates surrounding whitespace", async () => {
const result = await resolveSlash(" /clean ");
expect(result).toEqual({ kind: "clean" });
});

test("/clean is case-insensitive", async () => {
const result = await resolveSlash("/CLEAN");
expect(result).toEqual({ kind: "clean" });
});

test("/clean rejects arguments with usage hint", async () => {
const result = await resolveSlash("/clean now");
expect(result.kind).toBe("unknown");
if (result.kind !== "unknown") throw new Error("expected unknown");
expect(result.message).toContain("/clean");
expect(result.message).toContain("does not take arguments");
});
});

describe("classifySlash is a pure classifier matching resolveSlash kinds", () => {
// Lookahead in `buildPassthroughBatch` must not run `resolveSlash`'s side
// effects. The pure classifier is synchronous, takes no side-effecting
// dependencies, and must agree with resolveSlash's `kind`.
const cases: Array<{
input: string;
kind: "passthrough" | "compact" | "unknown";
kind: "passthrough" | "compact" | "clean" | "unknown";
}> = [
{ input: "/models", kind: "unknown" },
{ input: "/context", kind: "unknown" },
Expand All @@ -204,6 +231,9 @@ describe("classifySlash is a pure classifier matching resolveSlash kinds", () =>
{ input: "/compact 30k", kind: "compact" },
{ input: "/compact 1.5M", kind: "compact" },
{ input: "/compact bogus", kind: "unknown" },
{ input: "/clean", kind: "clean" },
{ input: " /clean ", kind: "clean" },
{ input: "/clean foo", kind: "unknown" },
{ input: "/model", kind: "unknown" },
{ input: "/model foo", kind: "unknown" },
{ input: "/opus", kind: "unknown" },
Expand Down Expand Up @@ -318,9 +348,7 @@ describe("resolveSlash /model — inference profile switcher", () => {
const result = await resolveSlash("/model balanced");
expect(result.kind).toBe("unknown");
if (result.kind !== "unknown") throw new Error("expected unknown kind");
expect(result.message).toBe(
"Already using profile `balanced` (Balanced).",
);
expect(result.message).toBe("Already using profile `balanced` (Balanced).");
});

test("`/model` with no profiles defined points at Settings", async () => {
Expand Down
20 changes: 20 additions & 0 deletions assistant/src/context/window-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,26 @@ export class ContextWindowManager {
return getConfig().compaction;
}

get maxInputTokens(): number {
return this.config.maxInputTokens;
}

/**
* Estimate the prompt-token cost of `messages` using the same path as the
* auto-compaction pre-check. Clears the system-prompt cache so the next
* turn re-resolves it (the system prompt is lazy and may have changed).
*/
estimateInputTokens(messages: Message[]): number {
try {
return estimatePromptTokens(messages, this.systemPrompt, {
providerName: this.estimationProviderName,
toolTokenBudget: this.toolTokenBudget,
});
} finally {
this.clearSystemPromptCache();
}
}

/**
* Cheap pre-check — estimate the current token count and compare against
* `compaction.autoThreshold`. Callers pass the estimate back through
Expand Down
Loading
Loading