diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index 019f08302..653194039 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -99,6 +99,21 @@ function todoTimeline(items: { text: string; completed: boolean }[]): AgentStrea }; } +function compactionTimeline( + status: "loading" | "completed", + trigger?: "auto" | "manual", +): AgentStreamEventPayload { + return { + type: "timeline", + provider: "pi", + item: { + type: "compaction", + status, + ...(trigger ? { trigger } : {}), + }, + }; +} + function findToolByCallId(state: StreamItem[], callId: string): AgentToolCallItem | undefined { return state.find( (item): item is AgentToolCallItem => @@ -693,6 +708,27 @@ describe("stream reducer canonical tool calls", () => { assert.strictEqual(todos.items[1]?.completed, true); }); + it("preserves compaction trigger when completed update replaces loading marker", () => { + const state = hydrateStreamState([ + { + event: compactionTimeline("loading", "auto"), + timestamp: new Date("2025-01-01T10:50:00Z"), + }, + { + event: compactionTimeline("completed"), + timestamp: new Date("2025-01-01T10:50:01Z"), + }, + ]); + + const compactions = state.filter( + (item): item is Extract => item.kind === "compaction", + ); + + assert.strictEqual(compactions.length, 1); + assert.strictEqual(compactions[0].status, "completed"); + assert.strictEqual(compactions[0].trigger, "auto"); + }); + it("renders Claude TodoWrite as todo_list and suppresses tool call badge", () => { const state = hydrateStreamState([ { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 44bfa2e35..c67e0d0ed 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -726,8 +726,8 @@ function reduceTimelineCompaction( const updated: CompactionItem = { ...existing, status: "completed", - trigger: item.trigger, - preTokens: item.preTokens, + trigger: item.trigger ?? existing.trigger, + preTokens: item.preTokens ?? existing.preTokens, }; return [...state.slice(0, loadingIdx), updated, ...state.slice(loadingIdx + 1)]; } diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 5b4eb5838..6866df671 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -2,7 +2,7 @@ import { closeSync, existsSync, fstatSync, openSync, readSync } from "node:fs"; import pino from "pino"; import { describe, expect, test } from "vitest"; -import type { AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js"; +import type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js"; import { PiRpcAgentClient, PiRpcAgentSession, transformPiModels } from "./agent.js"; import { FakePi } from "./test-utils/fake-pi.js"; @@ -693,12 +693,202 @@ describe("PiRpcAgentClient", () => { ]; await expect(session.listCommands()).resolves.toEqual([ + { + name: "compact", + description: "Manually compact the session context", + argumentHint: "[instructions]", + }, + { + name: "autocompact", + description: "Toggle automatic context compaction", + argumentHint: "[on|off|toggle]", + }, { name: "review", description: "Review changes", argumentHint: "" }, { name: "fix-tests", description: "Fix tests", argumentHint: "" }, { name: "skill:docs", description: "Read docs", argumentHint: "" }, ]); }); + test("lists Pi compact even when RPC get_commands omits built-in slash commands", async () => { + const { pi, session } = await createSession(); + pi.latestSession().commands = [ + { name: "review", description: "Review changes", source: "extension" }, + ]; + + await expect(session.listCommands()).resolves.toContainEqual({ + name: "compact", + description: "Manually compact the session context", + argumentHint: "[instructions]", + }); + await expect(session.listCommands()).resolves.toContainEqual({ + name: "autocompact", + description: "Toggle automatic context compaction", + argumentHint: "[on|off|toggle]", + }); + }); + + test("preserves known argument hints when RPC get_commands returns built-in slash commands", async () => { + const { pi, session } = await createSession(); + pi.latestSession().commands = [ + { name: "compact", description: "Compact from RPC", source: "extension" }, + { name: "autocompact", description: "Auto compact from RPC", source: "extension" }, + ]; + + await expect(session.listCommands()).resolves.toEqual([ + { + name: "compact", + description: "Compact from RPC", + argumentHint: "[instructions]", + }, + { + name: "autocompact", + description: "Auto compact from RPC", + argumentHint: "[on|off|toggle]", + }, + ]); + }); + + test("executes Pi compact through RPC instead of prompt text", async () => { + const { pi, session } = await createSession(); + const fakeSession = pi.latestSession(); + const handler = (session as AgentSession).tryHandleOutOfBand?.("/compact focus on tests"); + const events: AgentStreamEvent[] = []; + + expect(handler).not.toBeNull(); + await handler?.run({ emit: (event) => events.push(event) }); + + expect(fakeSession.compactRequests).toEqual([{ customInstructions: "focus on tests" }]); + expect(fakeSession.prompts).toEqual([]); + expect(events).toEqual([ + { + type: "timeline", + provider: "pi", + item: { type: "compaction", status: "loading", trigger: "manual" }, + }, + { + type: "timeline", + provider: "pi", + item: { type: "compaction", status: "completed", trigger: "manual" }, + }, + ]); + }); + + test("closes Pi compact loading marker when RPC rejects after compaction starts", async () => { + const { pi, session } = await createSession(); + const fakeSession = pi.latestSession(); + fakeSession.emitCompactEnd = false; + fakeSession.compactError = new Error("summarizer failed"); + const handler = (session as AgentSession).tryHandleOutOfBand?.("/compact"); + const events: AgentStreamEvent[] = []; + + expect(handler).not.toBeNull(); + await handler?.run({ emit: (event) => events.push(event) }); + + expect(events).toEqual([ + { + type: "timeline", + provider: "pi", + item: { type: "compaction", status: "loading", trigger: "manual" }, + }, + { + type: "timeline", + provider: "pi", + item: { type: "compaction", status: "completed", trigger: "manual" }, + }, + { + type: "timeline", + provider: "pi", + item: { + type: "assistant_message", + text: "[Error] Failed to compact context: summarizer failed", + }, + }, + ]); + }); + + test("executes Pi autocompact through RPC instead of prompt text", async () => { + const { pi, session } = await createSession(); + const fakeSession = pi.latestSession(); + const handler = (session as AgentSession).tryHandleOutOfBand?.("/autocompact off"); + const events: AgentStreamEvent[] = []; + + expect(handler).not.toBeNull(); + await handler?.run({ emit: (event) => events.push(event) }); + + expect(fakeSession.setAutoCompactionRequests).toEqual([false]); + expect(fakeSession.prompts).toEqual([]); + expect(events).toEqual([ + { + type: "timeline", + provider: "pi", + item: { type: "assistant_message", text: "Auto-compaction disabled." }, + }, + ]); + }); + + test("rejects unknown Pi autocompact mode instead of toggling", async () => { + const { pi, session } = await createSession(); + const fakeSession = pi.latestSession(); + const handler = (session as AgentSession).tryHandleOutOfBand?.("/autocompact banana"); + const events: AgentStreamEvent[] = []; + + expect(handler).not.toBeNull(); + await handler?.run({ emit: (event) => events.push(event) }); + + expect(fakeSession.setAutoCompactionRequests).toEqual([]); + expect(events).toEqual([ + { + type: "timeline", + provider: "pi", + item: { + type: "assistant_message", + text: "[Error] Usage: /autocompact [on|off|toggle]", + }, + }, + ]); + }); + + test("toggles Pi autocompact through current RPC state", async () => { + const { pi, session } = await createSession(); + const fakeSession = pi.latestSession(); + fakeSession.state.autoCompactionEnabled = false; + const handler = (session as AgentSession).tryHandleOutOfBand?.("/autocompact"); + const events: AgentStreamEvent[] = []; + + expect(handler).not.toBeNull(); + await handler?.run({ emit: (event) => events.push(event) }); + + expect(fakeSession.setAutoCompactionRequests).toEqual([true]); + expect(events).toContainEqual({ + type: "timeline", + provider: "pi", + item: { type: "assistant_message", text: "Auto-compaction enabled." }, + }); + }); + + test("rejects Pi autocompact toggle when current RPC state is unavailable", async () => { + const { pi, session } = await createSession(); + const fakeSession = pi.latestSession(); + delete fakeSession.state.autoCompactionEnabled; + const handler = (session as AgentSession).tryHandleOutOfBand?.("/autocompact"); + const events: AgentStreamEvent[] = []; + + expect(handler).not.toBeNull(); + await handler?.run({ emit: (event) => events.push(event) }); + + expect(fakeSession.setAutoCompactionRequests).toEqual([]); + expect(events).toEqual([ + { + type: "timeline", + provider: "pi", + item: { + type: "assistant_message", + text: "[Error] Auto-compaction state is unavailable. Use /autocompact on or /autocompact off.", + }, + }, + ]); + }); + test("rewinds conversation through the Pi tree navigation bridge", async () => { const { pi, session, events } = await createSession(); pi.latestSession().capturedUserEntries = [ diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index b76ab5c7b..e07176a33 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -86,6 +86,19 @@ const QUESTION_COMMENT_HEADER = "Comment"; const PI_ASK_USER_FREEFORM_SENTINEL = "✏️ Type custom response..."; const COMBINED_ASK_USER_METADATA = "ask_user_select_optional_comment"; +const PI_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [ + { + name: "compact", + description: "Manually compact the session context", + argumentHint: "[instructions]", + }, + { + name: "autocompact", + description: "Toggle automatic context compaction", + argumentHint: "[on|off|toggle]", + }, +]; + const PI_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, supportsSessionPersistence: true, @@ -205,6 +218,13 @@ interface ExtensionUiMappingOptions { allowFreeform?: boolean; } +interface PiSlashCommandInvocation { + commandName: string; + args?: string; +} + +type AutoCompactMode = boolean | "toggle" | "unknown"; + function normalizePiModelLabel(label: string): string { return label.trim().replace(/[_\s]+/g, " "); } @@ -247,6 +267,20 @@ function normalizePiThinkingOption(value: string | null | undefined): PiThinking return isPiThinkingLevel(value) ? value : null; } +function parseAutoCompactMode(value: string | undefined): AutoCompactMode { + const mode = (value ?? "toggle").trim().toLowerCase(); + if (mode === "on" || mode === "true" || mode === "enable" || mode === "enabled") { + return true; + } + if (mode === "off" || mode === "false" || mode === "disable" || mode === "disabled") { + return false; + } + if (mode === "toggle") { + return "toggle"; + } + return "unknown"; +} + function mapThinkingOption(option: (typeof PI_THINKING_OPTIONS)[number]) { const mappedOption = { id: option.id, @@ -922,6 +956,9 @@ export class PiRpcAgentSession implements AgentSession { private readonly seenUserEntryIds = new Set(); private readonly pendingUserMessages: PendingPiUserMessage[] = []; private readonly pendingExtensionResults = new Map(); + private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null; + private outOfBandCompactionStarted = false; + private outOfBandCompactionCompleted = false; private state: PiSessionState; private closed = false; @@ -1127,11 +1164,46 @@ export class PiRpcAgentSession implements AgentSession { async listCommands(): Promise { const commands = await this.runtimeSession.getCommands(); - return commands.map((command) => ({ - name: command.name, - description: command.description ?? command.source, - argumentHint: "", - })); + const mappedCommands = new Map( + PI_HANDLED_BUILTIN_SLASH_COMMANDS.map((command) => [command.name, { ...command }]), + ); + for (const command of commands) { + const knownCommand = mappedCommands.get(command.name); + mappedCommands.set(command.name, { + name: command.name, + description: command.description ?? command.source, + argumentHint: knownCommand?.argumentHint ?? "", + }); + } + return [...mappedCommands.values()]; + } + + tryHandleOutOfBand( + prompt: AgentPromptInput, + ): { run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise } | null { + if (typeof prompt !== "string") { + return null; + } + const parsed = this.parseSlashCommandInput(prompt); + if (!parsed) { + return null; + } + const commandName = parsed.commandName.toLowerCase(); + if (commandName === "compact") { + return { + run: async ({ emit }) => { + await this.executeCompactCommand(parsed.args, emit); + }, + }; + } + if (commandName === "autocompact") { + return { + run: async ({ emit }) => { + await this.executeAutoCompactCommand(parsed.args, emit); + }, + }; + } + return null; } async setModel(modelId: string | null): Promise { @@ -1172,6 +1244,128 @@ export class PiRpcAgentSession implements AgentSession { return this.activeTurnId ?? undefined; } + private parseSlashCommandInput(text: string): PiSlashCommandInvocation | null { + const trimmed = text.trim(); + if (!trimmed.startsWith("/") || trimmed.length <= 1) { + return null; + } + const withoutPrefix = trimmed.slice(1); + const firstWhitespaceIdx = withoutPrefix.search(/\s/); + const commandName = + firstWhitespaceIdx === -1 ? withoutPrefix : withoutPrefix.slice(0, firstWhitespaceIdx); + if (!commandName || commandName.includes("/")) { + return null; + } + const rawArgs = + firstWhitespaceIdx === -1 ? "" : withoutPrefix.slice(firstWhitespaceIdx + 1).trim(); + return rawArgs.length > 0 ? { commandName, args: rawArgs } : { commandName }; + } + + private async executeCompactCommand( + customInstructions: string | undefined, + emit: (event: AgentStreamEvent) => void, + ): Promise { + if (this.outOfBandCompactionEmit) { + throw new Error("A Pi compact command is already running"); + } + this.outOfBandCompactionEmit = emit; + this.outOfBandCompactionStarted = false; + this.outOfBandCompactionCompleted = false; + try { + await this.runtimeSession.compact(customInstructions); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ( + this.outOfBandCompactionEmit === emit && + this.outOfBandCompactionStarted && + !this.outOfBandCompactionCompleted + ) { + this.emitCompactionTimeline({ + turnId: undefined, + item: { + type: "compaction", + status: "completed", + trigger: "manual", + }, + }); + } + emit({ + type: "timeline", + provider: PI_PROVIDER, + item: { + type: "assistant_message", + text: `[Error] Failed to compact context: ${message}`, + }, + }); + } finally { + if (this.outOfBandCompactionEmit === emit && !this.outOfBandCompactionStarted) { + this.outOfBandCompactionEmit = null; + this.outOfBandCompactionStarted = false; + this.outOfBandCompactionCompleted = false; + } + } + } + + private async executeAutoCompactCommand( + mode: string | undefined, + emit: (event: AgentStreamEvent) => void, + ): Promise { + let enabled = parseAutoCompactMode(mode); + if (enabled === "unknown") { + emit({ + type: "timeline", + provider: PI_PROVIDER, + item: { + type: "assistant_message", + text: "[Error] Usage: /autocompact [on|off|toggle]", + }, + }); + return; + } + if (enabled === "toggle") { + const state = await this.runtimeSession.getState(); + if (typeof state.autoCompactionEnabled !== "boolean") { + emit({ + type: "timeline", + provider: PI_PROVIDER, + item: { + type: "assistant_message", + text: "[Error] Auto-compaction state is unavailable. Use /autocompact on or /autocompact off.", + }, + }); + return; + } + enabled = !state.autoCompactionEnabled; + } + + try { + await this.runtimeSession.setAutoCompaction(enabled); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + emit({ + type: "timeline", + provider: PI_PROVIDER, + item: { + type: "assistant_message", + text: `[Error] Failed to set auto-compaction: ${message}`, + }, + }); + return; + } + this.state = { + ...this.state, + autoCompactionEnabled: enabled, + }; + emit({ + type: "timeline", + provider: PI_PROVIDER, + item: { + type: "assistant_message", + text: `Auto-compaction ${enabled ? "enabled" : "disabled"}.`, + }, + }); + } + private async requestEntryCapture(reason: string): Promise { const requestId = randomUUID(); const resultPromise = this.waitForExtensionResult(requestId); @@ -1433,9 +1627,7 @@ export class PiRpcAgentSession implements AgentSession { return; } case "compaction_start": - this.emit({ - type: "timeline", - provider: PI_PROVIDER, + this.emitCompactionTimeline({ turnId, item: { type: "compaction", @@ -1445,13 +1637,12 @@ export class PiRpcAgentSession implements AgentSession { }); return; case "compaction_end": - this.emit({ - type: "timeline", - provider: PI_PROVIDER, + this.emitCompactionTimeline({ turnId, item: { type: "compaction", status: "completed", + trigger: event.reason === "manual" ? "manual" : "auto", }, }); return; @@ -1463,6 +1654,37 @@ export class PiRpcAgentSession implements AgentSession { } } + private emitCompactionTimeline(input: { + turnId: string | undefined; + item: Extract["item"]; + }): void { + const emitOutOfBand = this.outOfBandCompactionEmit; + if (emitOutOfBand && input.item.type === "compaction") { + if (input.item.status === "loading") { + this.outOfBandCompactionStarted = true; + } + if (input.item.status === "completed") { + this.outOfBandCompactionCompleted = true; + } + } + const event: AgentStreamEvent = { + type: "timeline", + provider: PI_PROVIDER, + ...(emitOutOfBand ? {} : { turnId: input.turnId }), + item: input.item, + }; + if (emitOutOfBand) { + emitOutOfBand(event); + if (input.item.type === "compaction" && input.item.status === "completed") { + this.outOfBandCompactionEmit = null; + this.outOfBandCompactionStarted = false; + this.outOfBandCompactionCompleted = false; + } + return; + } + this.emit(event); + } + private handleMessageUpdate( event: Extract, turnId: string | undefined, diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.ts index 84f8cbaf9..07af803a0 100644 --- a/packages/server/src/server/agent/providers/pi/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.ts @@ -129,6 +129,17 @@ class PiCliRuntimeSession implements PiRuntimeSession { await this.request({ type: "prompt", message, ...(images?.length ? { images } : {}) }); } + async compact(customInstructions?: string): Promise { + await this.request({ + type: "compact", + ...(customInstructions ? { customInstructions } : {}), + }); + } + + async setAutoCompaction(enabled: boolean): Promise { + await this.request({ type: "set_auto_compaction", enabled }); + } + async abort(): Promise { await this.request({ type: "abort" }); } diff --git a/packages/server/src/server/agent/providers/pi/rpc-types.ts b/packages/server/src/server/agent/providers/pi/rpc-types.ts index 90f9aa4e1..b25a61659 100644 --- a/packages/server/src/server/agent/providers/pi/rpc-types.ts +++ b/packages/server/src/server/agent/providers/pi/rpc-types.ts @@ -79,6 +79,7 @@ export interface PiSessionState { thinkingLevel: PiThinkingLevel; isStreaming: boolean; isCompacting: boolean; + autoCompactionEnabled?: boolean; sessionFile?: string; sessionId: string; sessionName?: string; @@ -111,6 +112,8 @@ export interface PiRpcSlashCommand { export type PiRpcCommand = | { id?: string; type: "prompt"; message: string; images?: PiImageContent[] } + | { id?: string; type: "compact"; customInstructions?: string } + | { id?: string; type: "set_auto_compaction"; enabled: boolean } | { id?: string; type: "abort" } | { id?: string; type: "get_state" } | { id?: string; type: "get_messages" } diff --git a/packages/server/src/server/agent/providers/pi/runtime.ts b/packages/server/src/server/agent/providers/pi/runtime.ts index e479c5894..0d813c12f 100644 --- a/packages/server/src/server/agent/providers/pi/runtime.ts +++ b/packages/server/src/server/agent/providers/pi/runtime.ts @@ -37,6 +37,8 @@ export interface PiRuntimeSession { message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>, ): Promise; + compact(customInstructions?: string): Promise; + setAutoCompaction(enabled: boolean): Promise; abort(): Promise; getState(): Promise; getMessages(): Promise; diff --git a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts index 08d92f201..53121c23a 100644 --- a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts +++ b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts @@ -51,6 +51,8 @@ export class FakePi implements PiRuntime { export class FakePiSession implements PiRuntimeSession { readonly prompts: Array<{ message: string; imageCount: number }> = []; + readonly compactRequests: Array<{ customInstructions?: string }> = []; + readonly setAutoCompactionRequests: boolean[] = []; readonly setModelRequests: Array<{ provider: string; modelId: string }> = []; readonly setThinkingLevelRequests: string[] = []; readonly treeNavigationRequests: string[] = []; @@ -69,6 +71,8 @@ export class FakePiSession implements PiRuntimeSession { cost: 0, }; commands: PiRpcSlashCommand[] = []; + compactError: Error | null = null; + emitCompactEnd = true; state: PiSessionState; private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>(); @@ -79,6 +83,7 @@ export class FakePiSession implements PiRuntimeSession { thinkingLevel: "medium", isStreaming: false, isCompacting: false, + autoCompactionEnabled: true, sessionFile: launch.session ?? "/tmp/pi-session", sessionId: "pi-session-1", messageCount: 0, @@ -102,6 +107,25 @@ export class FakePiSession implements PiRuntimeSession { this.handleEntryCaptureCommand(message); } + async compact(customInstructions?: string): Promise { + this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions }); + this.emit({ type: "compaction_start", reason: "manual" }); + if (this.emitCompactEnd) { + this.emit({ type: "compaction_end", reason: "manual" }); + } + if (this.compactError) { + throw this.compactError; + } + } + + async setAutoCompaction(enabled: boolean): Promise { + this.setAutoCompactionRequests.push(enabled); + this.state = { + ...this.state, + autoCompactionEnabled: enabled, + }; + } + async abort(): Promise { this.abortRequested = true; } diff --git a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts index 8b2a9cd40..8140a6517 100644 --- a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { randomUUID } from "node:crypto"; @@ -24,6 +24,7 @@ process.env.PASEO_SUPERVISED = "0"; const PI_TEST_TIMEOUT_MS = 240_000; const PI_REAL_TEST_MODEL = getRealProviderConfig("pi").model; +const PI_FREE_COMPACTION_TEST_MODEL = "openrouter/openai/gpt-oss-20b:free"; type ToolCallItem = Extract; @@ -31,6 +32,18 @@ function tmpCwd(prefix = "daemon-real-pi-"): string { return mkdtempSync(path.join(tmpdir(), prefix)); } +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function writePiCompactionSettings( + cwd: string, + compaction: { enabled: boolean; reserveTokens?: number; keepRecentTokens?: number }, +): void { + mkdirSync(path.join(cwd, ".pi"), { recursive: true }); + writeFileSync(path.join(cwd, ".pi/settings.json"), JSON.stringify({ compaction }, null, 2)); +} + function createPiClient(): AgentClient { return createRealProviderClient("pi", pino({ level: "silent" })); } @@ -68,6 +81,27 @@ async function fetchCanonicalTimeline( return timeline.entries.map((entry) => entry.item); } +async function waitForTimelineItem( + client: DaemonClient, + agentId: string, + predicate: (item: AgentTimelineItem) => boolean, + timeoutMs = PI_TEST_TIMEOUT_MS, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastItems: AgentTimelineItem[] = []; + while (Date.now() < deadline) { + lastItems = await fetchCanonicalTimeline(client, agentId); + const item = lastItems.find(predicate); + if (item) { + return item; + } + await sleep(250); + } + throw new Error( + `Timed out waiting for Pi timeline item. Last timeline: ${JSON.stringify(lastItems)}`, + ); +} + async function withConnectedPiDaemon( run: (context: { client: DaemonClient }) => Promise, ): Promise { @@ -101,6 +135,200 @@ beforeEach((context) => { } }); +test( + "real Pi daemon lists Paseo-handled compact slash commands", + async () => { + const cwd = tmpCwd("pi-compact-commands-"); + + try { + await withConnectedPiDaemon(async ({ client }) => { + const agent = await client.createAgent({ + cwd, + title: "pi-compact-commands", + provider: "pi", + model: PI_FREE_COMPACTION_TEST_MODEL, + }); + + const result = await client.listCommands(agent.id); + expect(result.commands).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "compact", + description: "Manually compact the session context", + }), + expect.objectContaining({ + name: "autocompact", + description: "Toggle automatic context compaction", + }), + ]), + ); + }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + PI_TEST_TIMEOUT_MS, +); + +test( + "real Pi daemon executes manual compact out-of-band instead of prompt text", + async () => { + const cwd = tmpCwd("pi-manual-compact-"); + + try { + writePiCompactionSettings(cwd, { + enabled: true, + keepRecentTokens: 1, + }); + + await withConnectedPiDaemon(async ({ client }) => { + const agent = await client.createAgent({ + cwd, + title: "pi-manual-compact", + provider: "pi", + model: PI_FREE_COMPACTION_TEST_MODEL, + }); + + await client.sendMessage(agent.id, "Reply exactly: compact-ready"); + const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); + expect(finish.status).toBe("idle"); + + await client.sendMessage(agent.id, "/compact summarize this e2e run"); + + await waitForTimelineItem( + client, + agent.id, + (item) => item.type === "compaction" && item.status === "completed", + ); + + const items = await fetchCanonicalTimeline(client, agent.id); + expect(items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "compaction", + status: "loading", + trigger: "manual", + }), + expect.objectContaining({ + type: "compaction", + status: "completed", + }), + ]), + ); + expect( + items.some((item) => item.type === "user_message" && item.text.includes("/compact")), + ).toBe(false); + expect( + items.some( + (item) => item.type === "assistant_message" && item.text.includes("Failed to compact"), + ), + ).toBe(false); + }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + PI_TEST_TIMEOUT_MS, +); + +test( + "real Pi daemon toggles auto-compaction out-of-band instead of prompt text", + async () => { + const cwd = tmpCwd("pi-autocompact-toggle-"); + + try { + await withConnectedPiDaemon(async ({ client }) => { + const agent = await client.createAgent({ + cwd, + title: "pi-autocompact-toggle", + provider: "pi", + model: PI_FREE_COMPACTION_TEST_MODEL, + }); + + await client.sendMessage(agent.id, "/autocompact off"); + await waitForTimelineItem( + client, + agent.id, + (item) => item.type === "assistant_message" && item.text === "Auto-compaction disabled.", + ); + + await client.sendMessage(agent.id, "/autocompact"); + await waitForTimelineItem( + client, + agent.id, + (item) => item.type === "assistant_message" && item.text === "Auto-compaction enabled.", + ); + + const items = await fetchCanonicalTimeline(client, agent.id); + expect( + items.some((item) => item.type === "user_message" && item.text.includes("/autocompact")), + ).toBe(false); + }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + PI_TEST_TIMEOUT_MS, +); + +test( + "real Pi daemon surfaces automatic threshold compaction events", + async () => { + const cwd = tmpCwd("pi-auto-compact-"); + + try { + writePiCompactionSettings(cwd, { + enabled: true, + reserveTokens: 126_000, + keepRecentTokens: 1, + }); + + await withConnectedPiDaemon(async ({ client }) => { + const agent = await client.createAgent({ + cwd, + title: "pi-auto-compact", + provider: "pi", + model: PI_FREE_COMPACTION_TEST_MODEL, + }); + + await client.sendMessage(agent.id, "Reply exactly: auto-compact-ready"); + const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); + expect(finish.status, JSON.stringify(finish)).toBe("idle"); + + await waitForTimelineItem( + client, + agent.id, + (item) => item.type === "compaction" && item.status === "completed", + ); + + const items = await fetchCanonicalTimeline(client, agent.id); + expect(items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "compaction", + status: "loading", + trigger: "auto", + }), + expect.objectContaining({ + type: "compaction", + status: "completed", + }), + ]), + ); + expect( + items.some( + (item) => + item.type === "assistant_message" && item.text.includes("Auto-compaction failed"), + ), + ).toBe(false); + }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + PI_TEST_TIMEOUT_MS, +); + test( "bash tool call records completed shell detail and output", async () => {