mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Keep Pi chats usable after canceling extension commands (#2019)
* Fix Pi sessions stuck after interrupt * Guard Pi retries from stale prompt failures
This commit is contained in:
@@ -138,6 +138,13 @@ class SessionEvents {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nextTurnCancellation(): Promise<Extract<AgentStreamEvent, { type: "turn_canceled" }>> {
|
||||||
|
return this.nextEvent(
|
||||||
|
(event): event is Extract<AgentStreamEvent, { type: "turn_canceled" }> =>
|
||||||
|
event.type === "turn_canceled",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
nextPermissionRequest(): Promise<Extract<AgentStreamEvent, { type: "permission_requested" }>> {
|
nextPermissionRequest(): Promise<Extract<AgentStreamEvent, { type: "permission_requested" }>> {
|
||||||
return this.nextEvent(
|
return this.nextEvent(
|
||||||
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
|
||||||
@@ -508,6 +515,38 @@ describe("PiRpcAgentSession", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("canceling a silent Pi extension command leaves the session usable", async () => {
|
||||||
|
const { pi, session, events } = await createSession();
|
||||||
|
const fakeSession = pi.latestSession();
|
||||||
|
|
||||||
|
fakeSession.holdNextPrompt();
|
||||||
|
const firstTurn = await session.startTurn("/silent-search");
|
||||||
|
fakeSession.emit({
|
||||||
|
type: "extension_ui_request",
|
||||||
|
id: "notify-1",
|
||||||
|
method: "notify",
|
||||||
|
message: "Search finished",
|
||||||
|
});
|
||||||
|
await session.interrupt();
|
||||||
|
const cancellation = await events.nextTurnCancellation();
|
||||||
|
await session.startTurn("next request");
|
||||||
|
await fakeSession.failHeldPrompt(new Error("Canceled prompt timed out"));
|
||||||
|
|
||||||
|
expect(cancellation).toEqual({
|
||||||
|
type: "turn_canceled",
|
||||||
|
provider: "pi",
|
||||||
|
reason: "interrupted",
|
||||||
|
turnId: firstTurn.turnId,
|
||||||
|
});
|
||||||
|
expect(fakeSession.prompts).toEqual([
|
||||||
|
{ message: "/silent-search", imageCount: 0 },
|
||||||
|
{ message: "next request", imageCount: 0 },
|
||||||
|
]);
|
||||||
|
await expect(session.startTurn("overlapping request")).rejects.toThrow(
|
||||||
|
"A Pi turn is already active",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("adds Pi assistant context to generic provider finish errors", async () => {
|
test("adds Pi assistant context to generic provider finish errors", async () => {
|
||||||
const { pi, session, events } = await createSession();
|
const { pi, session, events } = await createSession();
|
||||||
|
|
||||||
|
|||||||
@@ -1125,13 +1125,15 @@ export class PiRpcAgentSession implements AgentSession {
|
|||||||
this.activeTurnId = turnId;
|
this.activeTurnId = turnId;
|
||||||
|
|
||||||
void this.runtimeSession.prompt(payload.text, payload.images).catch((error) => {
|
void this.runtimeSession.prompt(payload.text, payload.images).catch((error) => {
|
||||||
const failedTurnId = this.activeTurnId ?? turnId;
|
if (this.activeTurnId !== turnId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.activeTurnId = null;
|
this.activeTurnId = null;
|
||||||
if (isPiRequestAbortError(error)) {
|
if (isPiRequestAbortError(error)) {
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "turn_canceled",
|
type: "turn_canceled",
|
||||||
provider: PI_PROVIDER,
|
provider: PI_PROVIDER,
|
||||||
turnId: failedTurnId,
|
turnId,
|
||||||
reason: toDiagnosticErrorMessage(error),
|
reason: toDiagnosticErrorMessage(error),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -1139,7 +1141,7 @@ export class PiRpcAgentSession implements AgentSession {
|
|||||||
this.emit({
|
this.emit({
|
||||||
type: "turn_failed",
|
type: "turn_failed",
|
||||||
provider: PI_PROVIDER,
|
provider: PI_PROVIDER,
|
||||||
turnId: failedTurnId,
|
turnId,
|
||||||
error: toDiagnosticErrorMessage(error),
|
error: toDiagnosticErrorMessage(error),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1234,7 +1236,12 @@ export class PiRpcAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async interrupt(): Promise<void> {
|
async interrupt(): Promise<void> {
|
||||||
|
const turnId = this.activeTurnId;
|
||||||
await this.runtimeSession.abort();
|
await this.runtimeSession.abort();
|
||||||
|
if (turnId && this.activeTurnId === turnId) {
|
||||||
|
this.activeTurnId = null;
|
||||||
|
this.emit({ type: "turn_canceled", provider: PI_PROVIDER, reason: "interrupted", turnId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async revertConversation(input: { messageId: string }): Promise<void> {
|
async revertConversation(input: { messageId: string }): Promise<void> {
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ export class FakePiSession implements PiRuntimeSession {
|
|||||||
state: PiSessionState;
|
state: PiSessionState;
|
||||||
|
|
||||||
private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>();
|
private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>();
|
||||||
|
private nextHeldPrompt: { promise: Promise<void>; reject: (error: Error) => void } | null = null;
|
||||||
|
private activeHeldPrompt: { promise: Promise<void>; reject: (error: Error) => void } | null =
|
||||||
|
null;
|
||||||
|
|
||||||
constructor(launch: PiRuntimeLaunch) {
|
constructor(launch: PiRuntimeLaunch) {
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -103,10 +106,42 @@ export class FakePiSession implements PiRuntimeSession {
|
|||||||
images?: Array<{ type: "image"; data: string; mimeType: string }>,
|
images?: Array<{ type: "image"; data: string; mimeType: string }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.prompts.push({ message, imageCount: images?.length ?? 0 });
|
this.prompts.push({ message, imageCount: images?.length ?? 0 });
|
||||||
|
const heldPrompt = this.nextHeldPrompt;
|
||||||
|
if (heldPrompt) {
|
||||||
|
this.nextHeldPrompt = null;
|
||||||
|
this.activeHeldPrompt = heldPrompt;
|
||||||
|
try {
|
||||||
|
await heldPrompt.promise;
|
||||||
|
} finally {
|
||||||
|
if (this.activeHeldPrompt === heldPrompt) {
|
||||||
|
this.activeHeldPrompt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
this.handleTreeNavigationCommand(message);
|
this.handleTreeNavigationCommand(message);
|
||||||
this.handleEntryCaptureCommand(message);
|
this.handleEntryCaptureCommand(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
holdNextPrompt(): void {
|
||||||
|
if (this.nextHeldPrompt || this.activeHeldPrompt) {
|
||||||
|
throw new Error("FakePi already has a held prompt");
|
||||||
|
}
|
||||||
|
let reject!: (error: Error) => void;
|
||||||
|
const promise = new Promise<void>((_resolve, rejectPromise) => {
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
this.nextHeldPrompt = { promise, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
async failHeldPrompt(error: Error): Promise<void> {
|
||||||
|
const heldPrompt = this.activeHeldPrompt ?? this.nextHeldPrompt;
|
||||||
|
if (!heldPrompt) {
|
||||||
|
throw new Error("FakePi has no held prompt");
|
||||||
|
}
|
||||||
|
heldPrompt.reject(error);
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
async compact(customInstructions?: string): Promise<void> {
|
async compact(customInstructions?: string): Promise<void> {
|
||||||
this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions });
|
this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions });
|
||||||
this.emit({ type: "compaction_start", reason: "manual" });
|
this.emit({ type: "compaction_start", reason: "manual" });
|
||||||
|
|||||||
Reference in New Issue
Block a user