Fix Pi compaction slash commands (#1338)
* fix: handle Pi compact slash command * fix: handle Pi compaction slash commands * fix: harden Pi compaction commands * fix: handle missing Pi autocompaction state --------- Co-authored-by: X <X@16x.org> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
X
Mohamed Boudra
parent
6aa73baaab
commit
59d48d235a
@@ -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<StreamItem, { kind: "compaction" }> => 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([
|
||||
{
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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<string>();
|
||||
private readonly pendingUserMessages: PendingPiUserMessage[] = [];
|
||||
private readonly pendingExtensionResults = new Map<string, PendingExtensionResult>();
|
||||
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<AgentSlashCommand[]> {
|
||||
const commands = await this.runtimeSession.getCommands();
|
||||
return commands.map((command) => ({
|
||||
name: command.name,
|
||||
description: command.description ?? command.source,
|
||||
argumentHint: "",
|
||||
}));
|
||||
const mappedCommands = new Map<string, AgentSlashCommand>(
|
||||
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<void> } | 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<void> {
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<AgentStreamEvent, { type: "timeline" }>["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<PiAgentSessionEvent, { type: "message_update" }>,
|
||||
turnId: string | undefined,
|
||||
|
||||
@@ -129,6 +129,17 @@ class PiCliRuntimeSession implements PiRuntimeSession {
|
||||
await this.request({ type: "prompt", message, ...(images?.length ? { images } : {}) });
|
||||
}
|
||||
|
||||
async compact(customInstructions?: string): Promise<void> {
|
||||
await this.request({
|
||||
type: "compact",
|
||||
...(customInstructions ? { customInstructions } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async setAutoCompaction(enabled: boolean): Promise<void> {
|
||||
await this.request({ type: "set_auto_compaction", enabled });
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
await this.request({ type: "abort" });
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface PiRuntimeSession {
|
||||
message: string,
|
||||
images?: Array<{ type: "image"; data: string; mimeType: string }>,
|
||||
): Promise<void>;
|
||||
compact(customInstructions?: string): Promise<void>;
|
||||
setAutoCompaction(enabled: boolean): Promise<void>;
|
||||
abort(): Promise<void>;
|
||||
getState(): Promise<PiSessionState>;
|
||||
getMessages(): Promise<PiAgentMessage[]>;
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
this.setAutoCompactionRequests.push(enabled);
|
||||
this.state = {
|
||||
...this.state,
|
||||
autoCompactionEnabled: enabled,
|
||||
};
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
this.abortRequested = true;
|
||||
}
|
||||
|
||||
@@ -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<AgentTimelineItem, { type: "tool_call" }>;
|
||||
|
||||
@@ -31,6 +32,18 @@ function tmpCwd(prefix = "daemon-real-pi-"): string {
|
||||
return mkdtempSync(path.join(tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
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<AgentTimelineItem> {
|
||||
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<void>,
|
||||
): Promise<void> {
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user