Fix Pi slash commands hanging after local completion (#2066)

* fix(pi): complete locally handled prompts

Slash commands the Pi CLI handles without starting a model turn previously
left the Paseo turn hanging until interrupt. Parse the optional
agentInvoked correlation from prompt acknowledgements, surface
command_output/notify output as timeline content, and complete the turn
locally when no agent turn will start. Older Pi binaries without the
correlation are detected via a getState compatibility probe.

* review: guard command output without an active turn, simplify delegates

- Drop late-arriving command_output when no turn is active instead of
  emitting an orphaned timeline item with turnId undefined
- Remove redundant 'text' in event check (type is already narrowed)
- Inline recordNoTurnNotification delegate

* refactor(pi): consolidate local prompt state

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Ethan Greenfeld
2026-07-14 20:29:42 +02:00
committed by GitHub
co-authored by Mohamed Boudra
parent 1f5283f5a3
commit cd6c608c9e
6 changed files with 317 additions and 20 deletions
@@ -12,6 +12,7 @@ import {
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import pino from "pino"; import pino from "pino";
import { setImmediate as waitForImmediate } from "node:timers/promises";
import { describe, expect, onTestFinished, test } from "vitest"; import { describe, expect, onTestFinished, test } from "vitest";
import type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js"; import type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js";
@@ -54,6 +55,9 @@ function readUtf8File(pathname: string): string {
closeSync(fd); closeSync(fd);
} }
} }
async function flushTurnScheduling(): Promise<void> {
await waitForImmediate();
}
async function createSession(pi = new FakePi()): Promise<{ async function createSession(pi = new FakePi()): Promise<{
pi: FakePi; pi: FakePi;
@@ -147,6 +151,13 @@ class SessionEvents {
}); });
} }
turnCompletedEvents() {
return this.events.filter(
(event): event is Extract<AgentStreamEvent, { type: "turn_completed" }> =>
event.type === "turn_completed",
);
}
nextTurnCompletion(): Promise<Extract<AgentStreamEvent, { type: "turn_completed" }>> { nextTurnCompletion(): Promise<Extract<AgentStreamEvent, { type: "turn_completed" }>> {
return this.nextEvent( return this.nextEvent(
(event): event is Extract<AgentStreamEvent, { type: "turn_completed" }> => (event): event is Extract<AgentStreamEvent, { type: "turn_completed" }> =>
@@ -885,6 +896,137 @@ describe("PiRpcAgentSession", () => {
error: "Pi exited", error: "Pi exited",
}); });
}); });
test("completes locally handled slash commands when agentInvoked is false", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
fakeSession.promptAck = { agentInvoked: false };
const { turnId: usageTurnId } = await session.startTurn("/usage");
fakeSession.emit({
type: "command_output",
text: "\u001b[38;2;138;138;138mUsage 12%\u001b[39m",
});
await flushTurnScheduling();
const usageCompletion = await events.nextTurnCompletion();
expect(usageCompletion).toMatchObject({ type: "turn_completed", turnId: usageTurnId });
expect(events.timelineAndCompletionEvents()).toEqual([
{ type: "timeline", item: { type: "user_message", text: "/usage" } },
{ type: "timeline", item: { type: "assistant_message", text: "Usage 12%" } },
{ type: "turn_completed" },
]);
const { turnId: helloTurnId } = await session.startTurn("hello");
fakeSession.finishTurn();
await flushTurnScheduling();
expect(events.turnCompletedEvents()).toHaveLength(2);
expect(events.turnCompletedEvents()[1]).toMatchObject({
type: "turn_completed",
turnId: helloTurnId,
});
});
test("does not synthesize completion when agentInvoked is true for slash prompts", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
fakeSession.promptAck = { agentInvoked: true };
const { turnId } = await session.startTurn("/usage");
await flushTurnScheduling();
expect(events.turnCompletedEvents()).toHaveLength(0);
fakeSession.emit({ type: "agent_start" });
fakeSession.finishTurn();
await flushTurnScheduling();
const completion = await events.nextTurnCompletion();
expect(completion).toMatchObject({ type: "turn_completed", turnId });
expect(events.turnCompletedEvents()).toHaveLength(1);
});
test("probes slash prompts without agentInvoked and surfaces buffered notify output", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
const { turnId } = await session.startTurn("/plan on");
fakeSession.emit({
type: "extension_ui_request",
id: "notify-plan",
method: "notify",
message: "Plan mode enabled",
});
await flushTurnScheduling();
const completion = await events.nextTurnCompletion();
expect(completion).toMatchObject({ type: "turn_completed", turnId });
expect(events.timelineAndCompletionEvents()).toEqual([
{ type: "timeline", item: { type: "user_message", text: "/plan on" } },
{ type: "timeline", item: { type: "assistant_message", text: "Plan mode enabled" } },
{ type: "turn_completed" },
]);
});
test("does not synthesize completion when lifecycle starts before the no-turn probe", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
const { turnId } = await session.startTurn("/custom-template-cmd");
fakeSession.emit({
type: "extension_ui_request",
id: "notify-buffered",
method: "notify",
message: "Should not appear after turn starts",
});
fakeSession.emit({ type: "agent_start" });
await flushTurnScheduling();
expect(events.turnCompletedEvents()).toHaveLength(0);
expect(events.timelineItems()).toEqual([]);
fakeSession.finishTurn();
await flushTurnScheduling();
const completion = await events.nextTurnCompletion();
expect(completion).toMatchObject({ type: "turn_completed", turnId });
expect(events.turnCompletedEvents()).toHaveLength(1);
});
test("fails slash turns when the no-turn getState barrier errors", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
fakeSession.getStateError = new Error("get_state timed out");
const { turnId } = await session.startTurn("/local-command on");
await flushTurnScheduling();
await expect(events.nextTurnFailure()).resolves.toMatchObject({
turnId,
error: "get_state timed out",
});
fakeSession.getStateError = null;
const { turnId: recoveryTurnId } = await session.startTurn("hello");
fakeSession.finishTurn();
await flushTurnScheduling();
await expect(events.nextTurnCompletion()).resolves.toMatchObject({
type: "turn_completed",
turnId: recoveryTurnId,
});
});
test("does not probe non-slash prompts when agentInvoked is missing", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
const { turnId } = await session.startTurn("hello");
await flushTurnScheduling();
expect(events.turnCompletedEvents()).toHaveLength(0);
fakeSession.finishTurn();
await flushTurnScheduling();
const completion = await events.nextTurnCompletion();
expect(completion).toMatchObject({ type: "turn_completed", turnId });
expect(events.turnCompletedEvents()).toHaveLength(1);
});
}); });
describe("PiRpcAgentClient", () => { describe("PiRpcAgentClient", () => {
@@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no
import { homedir, tmpdir } from "node:os"; import { homedir, tmpdir } from "node:os";
import { join, resolve as resolvePath } from "node:path"; import { join, resolve as resolvePath } from "node:path";
import type { Logger } from "pino"; import type { Logger } from "pino";
import stripAnsi from "strip-ansi";
import { z } from "zod"; import { z } from "zod";
import { import {
@@ -228,6 +229,12 @@ interface PendingPiUserMessage {
turnId: string | undefined; turnId: string | undefined;
} }
interface PendingLocalPrompt {
turnId: string;
text: string;
outputs: string[];
}
interface PendingExtensionResult { interface PendingExtensionResult {
resolve: (value: unknown) => void; resolve: (value: unknown) => void;
reject: (error: Error) => void; reject: (error: Error) => void;
@@ -1064,6 +1071,7 @@ export class PiRpcAgentSession implements AgentSession {
private activeAskUserDialog: ActiveAskUserDialog | null = null; private activeAskUserDialog: ActiveAskUserDialog | null = null;
private pendingCombinedAskUserResponse: PendingCombinedAskUserResponse | null = null; private pendingCombinedAskUserResponse: PendingCombinedAskUserResponse | null = null;
private activeTurnId: string | null = null; private activeTurnId: string | null = null;
private pendingLocalPrompt: PendingLocalPrompt | null = null;
private activeAssistantMessageId: string | null = null; private activeAssistantMessageId: string | null = null;
private lastKnownThinkingOptionId: string | null; private lastKnownThinkingOptionId: string | null;
currentLeafOverrideId: string | null | undefined; currentLeafOverrideId: string | null | undefined;
@@ -1124,29 +1132,43 @@ export class PiRpcAgentSession implements AgentSession {
const payload = convertPromptInput(prompt, { model: this.state.model }); const payload = convertPromptInput(prompt, { model: this.state.model });
const turnId = randomUUID(); const turnId = randomUUID();
this.activeTurnId = turnId; this.activeTurnId = turnId;
this.pendingLocalPrompt = { turnId, text: payload.text, outputs: [] };
this.activeAssistantMessageId = null; this.activeAssistantMessageId = null;
const shouldProbeForNoTurnPrompt = this.parseSlashCommandInput(payload.text) !== null;
void this.runtimeSession.prompt(payload.text, payload.images).catch((error) => { void (async () => {
if (this.activeTurnId !== turnId) { try {
return; const ack = await this.runtimeSession.prompt(payload.text, payload.images);
} if (ack.agentInvoked === false) {
this.activeTurnId = null; await this.completeNoTurnPrompt(turnId);
if (isPiRequestAbortError(error)) { return;
}
if (ack.agentInvoked === undefined && shouldProbeForNoTurnPrompt) {
await this.completePromptIfHandledWithoutTurn(turnId);
}
} catch (error) {
if (this.activeTurnId !== turnId) {
return;
}
this.activeTurnId = null;
this.pendingLocalPrompt = null;
if (isPiRequestAbortError(error)) {
this.emit({
type: "turn_canceled",
provider: PI_PROVIDER,
turnId,
reason: toDiagnosticErrorMessage(error),
});
return;
}
this.emit({ this.emit({
type: "turn_canceled", type: "turn_failed",
provider: PI_PROVIDER, provider: PI_PROVIDER,
turnId, turnId,
reason: toDiagnosticErrorMessage(error), error: toDiagnosticErrorMessage(error),
}); });
return;
} }
this.emit({ })();
type: "turn_failed",
provider: PI_PROVIDER,
turnId,
error: toDiagnosticErrorMessage(error),
});
});
return { turnId }; return { turnId };
} }
@@ -1242,6 +1264,7 @@ export class PiRpcAgentSession implements AgentSession {
await this.runtimeSession.abort(); await this.runtimeSession.abort();
if (turnId && this.activeTurnId === turnId) { if (turnId && this.activeTurnId === turnId) {
this.activeTurnId = null; this.activeTurnId = null;
this.pendingLocalPrompt = null;
this.emit({ type: "turn_canceled", provider: PI_PROVIDER, reason: "interrupted", turnId }); this.emit({ type: "turn_canceled", provider: PI_PROVIDER, reason: "interrupted", turnId });
} }
} }
@@ -1371,6 +1394,82 @@ export class PiRpcAgentSession implements AgentSession {
return this.activeTurnId ?? undefined; return this.activeTurnId ?? undefined;
} }
private async completeNoTurnPrompt(turnId: string): Promise<void> {
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
if (this.activeTurnId !== turnId || this.pendingLocalPrompt?.turnId !== turnId) {
return;
}
this.emitPendingLocalPrompt();
this.completeTurn(turnId, []);
}
private async completePromptIfHandledWithoutTurn(turnId: string): Promise<void> {
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
let runtimeState: PiSessionState;
try {
runtimeState = await this.runtimeSession.getState();
} catch (error) {
if (this.activeTurnId === turnId && this.pendingLocalPrompt?.turnId === turnId) {
throw error;
}
return;
}
if (
this.activeTurnId !== turnId ||
this.pendingLocalPrompt?.turnId !== turnId ||
runtimeState.isStreaming
) {
return;
}
this.state = runtimeState;
this.emitPendingLocalPrompt();
this.completeTurn(turnId, []);
}
private emitPendingLocalPrompt(): void {
const prompt = this.pendingLocalPrompt;
this.pendingLocalPrompt = null;
if (!prompt) {
return;
}
if (prompt.text) {
this.emit({
type: "timeline",
provider: PI_PROVIDER,
turnId: prompt.turnId,
item: {
type: "user_message",
text: prompt.text,
},
});
}
for (const output of prompt.outputs) {
this.emit({
type: "timeline",
provider: PI_PROVIDER,
turnId: prompt.turnId,
item: {
type: "assistant_message",
text: output,
},
});
}
}
private bufferLocalPromptOutput(message: string): void {
if (!this.pendingLocalPrompt) {
return;
}
this.pendingLocalPrompt.outputs.push(message);
}
private parseSlashCommandInput(text: string): PiSlashCommandInvocation | null { private parseSlashCommandInput(text: string): PiSlashCommandInvocation | null {
const trimmed = text.trim(); const trimmed = text.trim();
if (!trimmed.startsWith("/") || trimmed.length <= 1) { if (!trimmed.startsWith("/") || trimmed.length <= 1) {
@@ -1613,6 +1712,7 @@ export class PiRpcAgentSession implements AgentSession {
if (this.handleEntryCaptureMarker(message) || this.handleCommandResultMarker(message)) { if (this.handleEntryCaptureMarker(message) || this.handleCommandResultMarker(message)) {
return; return;
} }
this.bufferLocalPromptOutput(message);
} }
if (this.respondToCombinedAskUserFollowUp(event)) { if (this.respondToCombinedAskUserFollowUp(event)) {
@@ -1667,6 +1767,26 @@ export class PiRpcAgentSession implements AgentSession {
return false; return false;
} }
private handleCommandOutput(textValue: unknown): void {
if (!this.activeTurnId) {
return;
}
const text = stripAnsi(optionalString(textValue) ?? "").trim();
if (!text) {
return;
}
if (this.pendingLocalPrompt) {
this.bufferLocalPromptOutput(text);
return;
}
this.emit({
type: "timeline",
provider: PI_PROVIDER,
turnId: this.currentTurnIdForEvent(),
item: { type: "assistant_message", text },
});
}
private handleRuntimeEvent(event: PiRuntimeEvent): void { private handleRuntimeEvent(event: PiRuntimeEvent): void {
if (event.type === "extension_ui_request") { if (event.type === "extension_ui_request") {
this.handleExtensionUiRequest(event); this.handleExtensionUiRequest(event);
@@ -1676,6 +1796,10 @@ export class PiRpcAgentSession implements AgentSession {
this.handleProcessExit(event.error); this.handleProcessExit(event.error);
return; return;
} }
if (event.type === "command_output") {
this.handleCommandOutput(event.text);
return;
}
this.handleSessionEvent(event); this.handleSessionEvent(event);
} }
@@ -1686,6 +1810,7 @@ export class PiRpcAgentSession implements AgentSession {
} }
const turnId = this.activeTurnId; const turnId = this.activeTurnId;
this.activeTurnId = null; this.activeTurnId = null;
this.pendingLocalPrompt = null;
this.emit({ this.emit({
type: "turn_failed", type: "turn_failed",
provider: PI_PROVIDER, provider: PI_PROVIDER,
@@ -1699,6 +1824,7 @@ export class PiRpcAgentSession implements AgentSession {
switch (event.type) { switch (event.type) {
case "agent_start": case "agent_start":
this.pendingLocalPrompt = null;
this.emit({ this.emit({
type: "thread_started", type: "thread_started",
provider: PI_PROVIDER, provider: PI_PROVIDER,
@@ -1706,6 +1832,7 @@ export class PiRpcAgentSession implements AgentSession {
}); });
return; return;
case "turn_start": case "turn_start":
this.pendingLocalPrompt = null;
this.emit({ this.emit({
type: "turn_started", type: "turn_started",
provider: PI_PROVIDER, provider: PI_PROVIDER,
@@ -1922,6 +2049,7 @@ export class PiRpcAgentSession implements AgentSession {
private completeTurn(turnId: string | undefined, messages: PiAgentMessage[]): void { private completeTurn(turnId: string | undefined, messages: PiAgentMessage[]): void {
this.activeTurnId = null; this.activeTurnId = null;
this.pendingLocalPrompt = null;
this.activeAssistantMessageId = null; this.activeAssistantMessageId = null;
const errorMessage = latestPiErrorMessage(messages); const errorMessage = latestPiErrorMessage(messages);
if (typeof errorMessage === "string" && errorMessage.length > 0) { if (typeof errorMessage === "string" && errorMessage.length > 0) {
@@ -15,6 +15,7 @@ import type {
PiAgentMessage, PiAgentMessage,
PiCommandsRpcType, PiCommandsRpcType,
PiModel, PiModel,
PiPromptAck,
PiRpcCommand, PiRpcCommand,
PiRpcResponse, PiRpcResponse,
PiRpcSlashCommand, PiRpcSlashCommand,
@@ -136,8 +137,19 @@ class PiCliRuntimeSession implements PiRuntimeSession {
async prompt( async prompt(
message: string, message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>, images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<void> { ): Promise<PiPromptAck> {
await this.request({ type: "prompt", message, ...(images?.length ? { images } : {}) }); const data = await this.request({
type: "prompt",
message,
...(images?.length ? { images } : {}),
});
if (typeof data === "object" && data !== null && !Array.isArray(data)) {
const { agentInvoked } = data as Record<string, unknown>;
if (typeof agentInvoked === "boolean") {
return { agentInvoked };
}
}
return {};
} }
async compact(customInstructions?: string): Promise<void> { async compact(customInstructions?: string): Promise<void> {
@@ -5,6 +5,9 @@ export interface PiImageContent {
data: string; data: string;
mimeType: string; mimeType: string;
} }
export interface PiPromptAck {
agentInvoked?: boolean;
}
export interface PiTextContent { export interface PiTextContent {
type: "text"; type: "text";
@@ -181,6 +184,10 @@ export type PiRuntimeEvent =
method: string; method: string;
[key: string]: unknown; [key: string]: unknown;
} }
| {
type: "command_output";
text?: string;
}
| { | {
type: "process_exit"; type: "process_exit";
error: string; error: string;
@@ -1,6 +1,7 @@
import type { import type {
PiAgentMessage, PiAgentMessage,
PiModel, PiModel,
PiPromptAck,
PiRpcSlashCommand, PiRpcSlashCommand,
PiRuntimeEvent, PiRuntimeEvent,
PiSessionState, PiSessionState,
@@ -38,7 +39,7 @@ export interface PiRuntimeSession {
prompt( prompt(
message: string, message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>, images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<void>; ): Promise<PiPromptAck>;
compact(customInstructions?: string): Promise<void>; compact(customInstructions?: string): Promise<void>;
setAutoCompaction(enabled: boolean): Promise<void>; setAutoCompaction(enabled: boolean): Promise<void>;
abort(): Promise<void>; abort(): Promise<void>;
@@ -7,6 +7,7 @@ import type {
import type { import type {
PiAgentMessage, PiAgentMessage,
PiModel, PiModel,
PiPromptAck,
PiRpcSlashCommand, PiRpcSlashCommand,
PiRuntimeEvent, PiRuntimeEvent,
PiSessionState, PiSessionState,
@@ -73,6 +74,8 @@ export class FakePiSession implements PiRuntimeSession {
commands: PiRpcSlashCommand[] = []; commands: PiRpcSlashCommand[] = [];
compactError: Error | null = null; compactError: Error | null = null;
emitCompactEnd = true; emitCompactEnd = true;
getStateError: Error | null = null;
promptAck: PiPromptAck = {};
state: PiSessionState; state: PiSessionState;
private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>(); private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>();
@@ -104,7 +107,7 @@ export class FakePiSession implements PiRuntimeSession {
async prompt( async prompt(
message: string, message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>, images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<void> { ): Promise<PiPromptAck> {
this.prompts.push({ message, imageCount: images?.length ?? 0 }); this.prompts.push({ message, imageCount: images?.length ?? 0 });
const heldPrompt = this.nextHeldPrompt; const heldPrompt = this.nextHeldPrompt;
if (heldPrompt) { if (heldPrompt) {
@@ -120,6 +123,7 @@ export class FakePiSession implements PiRuntimeSession {
} }
this.handleTreeNavigationCommand(message); this.handleTreeNavigationCommand(message);
this.handleEntryCaptureCommand(message); this.handleEntryCaptureCommand(message);
return this.promptAck;
} }
holdNextPrompt(): void { holdNextPrompt(): void {
@@ -166,6 +170,9 @@ export class FakePiSession implements PiRuntimeSession {
} }
async getState(): Promise<PiSessionState> { async getState(): Promise<PiSessionState> {
if (this.getStateError) {
throw this.getStateError;
}
return this.state; return this.state;
} }