Skip to content
Open
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
10 changes: 10 additions & 0 deletions apps/cli/src/ui/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4357,6 +4357,16 @@ export class InteractiveMode {
}

queueCompactionMessage(text: string, mode: "steer" | "followUp", images?: ImageContent[]): void {
if (this.session.isAutoClmCompacting) {
this.editor.addToHistory?.(text);
this.editor.setText("");
void this.session.prompt(text, { streamingBehavior: mode, images }).catch((error) => {
this.editor.setText(text);
this.showError(error instanceof Error ? error.message : String(error));
});
this.updatePendingMessagesDisplay();
return;
}
this.compactionQueuedMessages.push({ text, mode, images });
this.editor.addToHistory?.(text);
this.editor.setText("");
Expand Down
21 changes: 21 additions & 0 deletions apps/cli/src/ui/runtime/session-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,27 @@ export async function handleSessionEvent(ctx: RuntimeContext, event: AgentSessio
break;
}

case "auto_clm_start": {
if (ctx.settingsManager.getShowTerminalProgress()) ctx.ui.terminal.setProgress(true);
ctx.autoCompactionEscapeHandler = ctx.defaultEditor.onEscape;
ctx.defaultEditor.onEscape = () => ctx.session.abortCompaction();
ctx.showStatusIndicator(new CompactionStatusIndicator(ctx.ui, "threshold", ctx.presentation));
ctx.redraw.requestRender();
break;
}

case "auto_clm_end": {
if (ctx.settingsManager.getShowTerminalProgress()) ctx.ui.terminal.setProgress(false);
if (ctx.autoCompactionEscapeHandler) {
ctx.defaultEditor.onEscape = ctx.autoCompactionEscapeHandler;
ctx.autoCompactionEscapeHandler = undefined;
}
ctx.clearStatusIndicator("compaction");
void ctx.flushCompactionQueue({ willRetry: true });
ctx.redraw.requestRender();
break;
}

case "compaction_end": {
if (ctx.settingsManager.getShowTerminalProgress()) {
ctx.ui.terminal.setProgress(false);
Expand Down
40 changes: 40 additions & 0 deletions apps/cli/test/interactive-mode-compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,48 @@ import type { SessionEntry } from "../../../packages/coding-agent/src/core/sessi
import { InteractiveMode } from "../src/ui/interactive-mode.ts";
import { initTheme } from "../../../packages/coding-agent/src/theme/theme.ts";
import { stripAnsi } from "../../../packages/coding-agent/src/utils/ansi.ts";
import type { AgentSessionEvent } from "../../../packages/coding-agent/src/core/agent-session.ts";

describe("InteractiveMode compaction events", () => {
test("shows automatic CLM progress, supports escape cancellation, and restores the task editor", async () => {
const originalEscape = vi.fn();
const fakeThis = {
isInitialized: true,
footer: { invalidate: vi.fn() },
autoCompactionEscapeHandler: undefined as (() => void) | undefined,
defaultEditor: { onEscape: originalEscape },
settingsManager: { getShowTerminalProgress: () => true },
ui: { requestRender: vi.fn(), terminal: { setProgress: vi.fn() } },
redraw: { requestRender: vi.fn() },
presentation: "step" as const,
session: { abortCompaction: vi.fn() },
showStatusIndicator: vi.fn(), clearStatusIndicator: vi.fn(),
flushCompactionQueue: vi.fn().mockResolvedValue(undefined),
};
const handleEvent = Reflect.get(InteractiveMode.prototype, "handleEvent") as (this: typeof fakeThis, event: AgentSessionEvent) => Promise<void>;
initTheme("dark");
await handleEvent.call(fakeThis, { type: "auto_clm_start", reason: "soft-threshold" });
fakeThis.defaultEditor.onEscape();
expect(fakeThis.session.abortCompaction).toHaveBeenCalledTimes(1);
expect(fakeThis.showStatusIndicator).toHaveBeenCalledTimes(1);
await handleEvent.call(fakeThis, { type: "auto_clm_end", result: { attempted: true, accepted: false, fallback: false, reason: "interrupted", requests: 1 } });
expect(fakeThis.defaultEditor.onEscape).toBe(originalEscape);
expect(fakeThis.clearStatusIndicator).toHaveBeenCalledWith("compaction");
expect(fakeThis.flushCompactionQueue).toHaveBeenCalledWith({ willRetry: true });
expect(fakeThis.ui.terminal.setProgress.mock.calls).toEqual([[true], [false]]);
});
test("forwards terminal input to the active automatic CLM queue so maintenance is interrupted", async () => {
const fakeThis = {
compactionQueuedMessages: [],
session: { isAutoClmCompacting: true, prompt: vi.fn().mockResolvedValue(undefined) },
editor: { addToHistory: vi.fn(), setText: vi.fn() },
updatePendingMessagesDisplay: vi.fn(), showStatus: vi.fn(), showError: vi.fn(),
};
const queue = Reflect.get(InteractiveMode.prototype, "queueCompactionMessage") as (this: typeof fakeThis, text: string, mode: "steer" | "followUp") => void;
queue.call(fakeThis, "Preserve output order", "steer");
expect(fakeThis.session.prompt).toHaveBeenCalledWith("Preserve output order", { streamingBehavior: "steer", images: undefined });
expect(fakeThis.compactionQueuedMessages).toEqual([]);
});
test("uses the cache miss notice setting for compaction and branch summary costs", () => {
const usage: Usage = {
input: 10,
Expand Down
63 changes: 63 additions & 0 deletions apps/cli/test/runtime-context-overrides.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { spawn } from "node:child_process";
import { createServer } from "node:http";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it } from "vitest";

const roots: string[] = [];
afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); });

async function firstRequest(config: string, flags: string[]) {
const root = await mkdtemp(join(tmpdir(), "step-cli-context-overrides-")); roots.push(root);
const home = join(root, "home"); const cwd = join(root, "project"); const configRoot = join(root, "configuration"); const agentDir = join(configRoot, "agent");
await Promise.all([mkdir(home), mkdir(cwd), mkdir(agentDir, { recursive: true })]);
const requests: Array<{ messages: unknown[]; tools?: Array<{ function?: { name: string } }> }> = [];
const server = createServer((request, response) => {
let body = "";
request.on("data", (chunk) => { body += chunk; });
request.on("end", () => {
if (!body.trim()) {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ data: [{ id: "local-cli-test" }] }));
return;
}
requests.push(JSON.parse(body));
response.writeHead(200, { "content-type": "text/event-stream" });
const common = { id: "local-cli-test", object: "chat.completion.chunk", created: 1, model: "local-cli-test" };
response.end([
`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta: { role: "assistant", content: "done" }, finish_reason: null }] })}\n\n`,
`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 100, completion_tokens: 1, total_tokens: 101 } })}\n\n`,
"data: [DONE]\n\n",
].join(""));
});
});
await new Promise<void>((done) => server.listen(0, "127.0.0.1", done));
try {
const address = server.address();
if (!address || typeof address === "string") throw new Error("Local test server did not start");
const catalog = JSON.stringify({ providers: { openai: { baseUrl: `http://127.0.0.1:${address.port}/v1`, api: "openai-completions", models: [{ id: "local-cli-test", name: "Local CLI Test", contextWindow: 128000, maxTokens: 4096, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }] } } });
await Promise.all([writeFile(join(configRoot, "models.json"), catalog), writeFile(join(agentDir, "models.json"), catalog), writeFile(join(configRoot, "config.toml"), config)]);
const cli = resolve(import.meta.dirname, "../dist/main.js");
const args = [cli, "--provider", "openai", "--model", "local-cli-test", "--api-key", "local-test-key", "--mode", "json", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-context-files", "--no-approve", "--no-update-check", "--no-session", "-p", ...flags, "Reply done."];
const child = spawn(process.execPath, args, { cwd, env: { PATH: process.env.PATH, HOME: home, USERPROFILE: home, STEP_CODING_AGENT_DIR: agentDir, STEP_NO_LOCAL_LLM: "1", AWS_EC2_METADATA_DISABLED: "true" }, stdio: ["ignore", "pipe", "pipe"] });
let output = "";
child.stdout.on("data", (chunk) => { output += chunk; }); child.stderr.on("data", (chunk) => { output += chunk; });
const timeout = setTimeout(() => child.kill("SIGKILL"), 20000);
const code = await new Promise<number | null>((done, reject) => { child.once("error", reject); child.once("close", done); });
clearTimeout(timeout);
expect(code, output.slice(-12000)).toBe(0);
expect(requests.length, output.slice(-12000)).toBeGreaterThan(0);
expect(await readFile(join(configRoot, "config.toml"), "utf8")).toBe(config);
return requests[0];
} finally {
await new Promise<void>((done) => server.close(() => done()));
}
}

describe.skipIf(process.platform === "win32")("CLI overrides after resource loading", () => {
it("keeps explicit native compression after the resource loader reloads settings", async () => {
const request = await firstRequest('[compaction]\ncontextProjection = "clm-v1"\n', ["--context-projection", "off"]);
expect(JSON.stringify(request.messages)).not.toContain("## Working context");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// ============================================================================

/** Feature-flag values for `step.compaction.contextProjection`. */
export type ContextProjectionMode = "off" | "lightweight-v1";
export type ContextProjectionMode = "off" | "lightweight-v1" | "clm-v1";

/** Why a projection run did not rewrite anything. */
export type ProjectionSkippedReason =
Expand Down
14 changes: 13 additions & 1 deletion packages/coding-agent/docs/compaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,19 @@ For TypeScript definitions in your project, inspect `node_modules/@step-harness/

## Overview

Step has two summarization mechanisms:
[Model-managed working context (CLM)](context-management.md) is the default
compression mode. At the existing context threshold, Step first asks the model
to shorten eligible old observations in a validated working view. It keeps the
canonical history and protects user requirements and current tool groups. If no
useful edit is accepted, Step falls back to the native summary compaction
explained below. Provider context overflow goes directly to native recovery.

Use `--context-projection off` or `compaction.contextProjection = "off"` to select
native compaction explicitly. `/compact` still requests a native summary;
`/clm-compact` requests an explicit working-context edit. A settled CLM response
with no queued continuation leaves routine maintenance until the next request.

The native pipeline has two summarization mechanisms:

| Mechanism | Trigger | Purpose |
|-----------|---------|---------|
Expand Down
Loading
Loading