Keep Pi chats active through retry recovery (#3639)

* fix(server): preserve Pi turns through retries

Pi reports each provider attempt with agent_end, while agent_settled marks the final user-turn boundary. Keeping modern streams active until settlement prevents a recovered attempt from leaving the chat in an error state.

* fix(server): show Pi provider retries

Automatic retries keep the turn alive, but users still need to understand the delay. Surface Pi's retry-start signal through the existing non-terminal error activity row without changing terminal lifecycle handling.

* refactor(server): clarify Pi retry boundaries

Use the server's object-parameter convention for the retry boundary helpers so their event, turn, and retry semantics stay explicit as the lifecycle evolves.
This commit is contained in:
Mohamed Boudra
2026-08-21 12:16:19 +02:00
committed by GitHub
parent e1805ef542
commit c032df5b3d
4 changed files with 226 additions and 3 deletions
@@ -215,6 +215,20 @@ class SessionEvents {
return this.events.map((event) => event.type); return this.events.map((event) => event.type);
} }
turnLifecycleEvents() {
return this.events.flatMap((event) => {
if (
event.type === "turn_started" ||
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
) {
return [{ type: event.type, turnId: event.turnId }];
}
return [];
});
}
turnCompletedEvents() { turnCompletedEvents() {
return this.events.filter( return this.events.filter(
(event): event is Extract<AgentStreamEvent, { type: "turn_completed" }> => (event): event is Extract<AgentStreamEvent, { type: "turn_completed" }> =>
@@ -944,6 +958,132 @@ describe("PiRpcAgentSession", () => {
}); });
}); });
test("shows retry activity while keeping the original Pi turn active through recovery", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
const { turnId } = await session.startTurn("hello");
fakeSession.emit({ type: "turn_start" });
fakeSession.finishAgentRun({
message: {
role: "assistant",
provider: "test-provider",
model: "test-model",
stopReason: "error",
errorMessage: "Request timed out.",
content: [],
},
willRetry: true,
});
fakeSession.emit({
type: "auto_retry_start",
attempt: 1,
maxAttempts: 3,
delayMs: 2000,
errorMessage: "Request timed out.",
});
expect(events.timelineItems()).toContainEqual({
type: "error",
message: "Provider retry (attempt 1): Request timed out.",
});
expect(events.turnLifecycleEvents()).toEqual([{ type: "turn_started", turnId }]);
fakeSession.emit({ type: "turn_start" });
fakeSession.finishAgentRun({
message: {
role: "assistant",
provider: "test-provider",
model: "test-model",
stopReason: "stop",
content: [{ type: "text", text: "Recovered response" }],
},
willRetry: false,
});
fakeSession.emit({ type: "auto_retry_end", success: true, attempt: 1 });
fakeSession.settleTurn();
expect(events.turnLifecycleEvents()).toEqual([
{ type: "turn_started", turnId },
{ type: "turn_completed", turnId },
]);
});
test("fails an exhausted Pi recovery only after settlement", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
const { turnId } = await session.startTurn("hello");
fakeSession.emit({ type: "turn_start" });
fakeSession.finishAgentRun({
message: {
role: "assistant",
provider: "test-provider",
model: "test-model",
stopReason: "error",
errorMessage: "Request timed out.",
content: [],
},
willRetry: true,
});
fakeSession.emit({
type: "auto_retry_start",
attempt: 1,
maxAttempts: 1,
delayMs: 2000,
errorMessage: "Request timed out.",
});
fakeSession.emit({ type: "turn_start" });
fakeSession.finishAgentRun({
message: {
role: "assistant",
provider: "test-provider",
model: "test-model",
stopReason: "error",
errorMessage: "Insufficient quota.",
content: [],
},
willRetry: false,
});
fakeSession.emit({
type: "auto_retry_end",
success: false,
attempt: 1,
finalError: "Insufficient quota.",
});
expect(events.turnLifecycleEvents()).toEqual([{ type: "turn_started", turnId }]);
expect(events.timelineItems()).toContainEqual({
type: "error",
message: "Provider retry (attempt 1): Request timed out.",
});
fakeSession.settleTurn();
expect(events.turnLifecycleEvents()).toEqual([
{ type: "turn_started", turnId },
{ type: "turn_failed", turnId },
]);
});
test("completes legacy Pi turns that have no settlement metadata", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
const { turnId } = await session.startTurn("hello");
fakeSession.emit({ type: "turn_start" });
fakeSession.finishLegacyTurn({
role: "assistant",
stopReason: "stop",
content: [{ type: "text", text: "Legacy response" }],
});
expect(events.turnLifecycleEvents()).toEqual([
{ type: "turn_started", turnId },
{ type: "turn_completed", turnId },
]);
});
test("resumes by launching Pi with the persisted session file and cwd metadata", async () => { test("resumes by launching Pi with the persisted session file and cwd metadata", async () => {
const pi = new FakePi(); const pi = new FakePi();
const client = createClient(pi); const client = createClient(pi);
@@ -1003,6 +1003,8 @@ function isPiAgentSessionEvent(event: PiRuntimeEvent): event is PiAgentSessionEv
case "compaction_start": case "compaction_start":
case "compaction_end": case "compaction_end":
case "agent_end": case "agent_end":
case "agent_settled":
case "auto_retry_start":
return true; return true;
default: default:
return false; return false;
@@ -1204,6 +1206,8 @@ export class PiRpcAgentSession implements AgentSession {
private activeClientMessageId: string | null = null; private activeClientMessageId: string | null = null;
private activeAssistantMessageId: string | null = null; private activeAssistantMessageId: string | null = null;
private activeTurnStarted = false; private activeTurnStarted = false;
private activeTurnStartedEmitted = false;
private pendingSettledMessages: PiAgentMessage[] | null = null;
private activeNoTurnPromptText: string | null = null; private activeNoTurnPromptText: string | null = null;
private readonly pendingNoTurnOutputs: Array<{ turnId: string; message: string }> = []; private readonly pendingNoTurnOutputs: Array<{ turnId: string; message: string }> = [];
private activePromptRequestId: string | null = null; private activePromptRequestId: string | null = null;
@@ -1297,6 +1301,8 @@ export class PiRpcAgentSession implements AgentSession {
this.activeClientMessageId = options?.clientMessageId ?? null; this.activeClientMessageId = options?.clientMessageId ?? null;
this.activeAssistantMessageId = null; this.activeAssistantMessageId = null;
this.activeTurnStarted = false; this.activeTurnStarted = false;
this.activeTurnStartedEmitted = false;
this.pendingSettledMessages = null;
this.activePromptRequestId = null; this.activePromptRequestId = null;
this.clearNoTurnBuffers(); this.clearNoTurnBuffers();
this.activeNoTurnPromptText = payload.text; this.activeNoTurnPromptText = payload.text;
@@ -1328,6 +1334,8 @@ export class PiRpcAgentSession implements AgentSession {
this.activeTurnId = null; this.activeTurnId = null;
this.activeClientMessageId = null; this.activeClientMessageId = null;
this.activeTurnStarted = false; this.activeTurnStarted = false;
this.activeTurnStartedEmitted = false;
this.pendingSettledMessages = null;
this.activeAssistantMessageId = null; this.activeAssistantMessageId = null;
this.clearNoTurnBuffers(); this.clearNoTurnBuffers();
if (isPiRequestAbortError(error)) { if (isPiRequestAbortError(error)) {
@@ -1456,6 +1464,8 @@ export class PiRpcAgentSession implements AgentSession {
this.activeTurnId = null; this.activeTurnId = null;
this.activeClientMessageId = null; this.activeClientMessageId = null;
this.activeTurnStarted = false; this.activeTurnStarted = false;
this.activeTurnStartedEmitted = false;
this.pendingSettledMessages = null;
this.activeAssistantMessageId = null; this.activeAssistantMessageId = null;
this.clearNoTurnBuffers(); this.clearNoTurnBuffers();
this.emit({ this.emit({
@@ -1472,6 +1482,8 @@ export class PiRpcAgentSession implements AgentSession {
this.activeTurnId = null; this.activeTurnId = null;
this.activeClientMessageId = null; this.activeClientMessageId = null;
this.activeTurnStarted = false; this.activeTurnStarted = false;
this.activeTurnStartedEmitted = false;
this.pendingSettledMessages = null;
this.activeAssistantMessageId = null; this.activeAssistantMessageId = null;
this.clearNoTurnBuffers(); this.clearNoTurnBuffers();
this.emit({ this.emit({
@@ -2047,6 +2059,8 @@ export class PiRpcAgentSession implements AgentSession {
this.activeTurnId = null; this.activeTurnId = null;
this.activeClientMessageId = null; this.activeClientMessageId = null;
this.activeTurnStarted = false; this.activeTurnStarted = false;
this.activeTurnStartedEmitted = false;
this.pendingSettledMessages = null;
this.clearNoTurnBuffers(); this.clearNoTurnBuffers();
this.emit({ this.emit({
type: "turn_failed", type: "turn_failed",
@@ -2058,6 +2072,10 @@ export class PiRpcAgentSession implements AgentSession {
private handleSessionEvent(event: PiAgentSessionEvent): void { private handleSessionEvent(event: PiAgentSessionEvent): void {
const turnId = this.currentTurnIdForEvent(); const turnId = this.currentTurnIdForEvent();
if (event.type === "agent_end" || event.type === "agent_settled") {
this.handleTurnBoundaryEvent({ event, turnId });
return;
}
switch (event.type) { switch (event.type) {
case "agent_start": case "agent_start":
@@ -2072,6 +2090,10 @@ export class PiRpcAgentSession implements AgentSession {
case "turn_start": case "turn_start":
this.activeTurnStarted = true; this.activeTurnStarted = true;
this.clearNoTurnBuffers(); this.clearNoTurnBuffers();
if (this.activeTurnStartedEmitted) {
return;
}
this.activeTurnStartedEmitted = true;
this.emit({ this.emit({
type: "turn_started", type: "turn_started",
provider: this.provider, provider: this.provider,
@@ -2128,14 +2150,46 @@ export class PiRpcAgentSession implements AgentSession {
}, },
}); });
return; return;
case "agent_end": case "auto_retry_start":
this.completeTurn(turnId, event.messages ?? []); this.emit({
type: "timeline",
provider: this.provider,
turnId,
item: {
type: "error",
message: `Provider retry (attempt ${event.attempt}): ${event.errorMessage}`,
},
});
return; return;
default: default:
return; return;
} }
} }
private handleTurnBoundaryEvent({
event,
turnId,
}: {
event: Extract<PiAgentSessionEvent, { type: "agent_end" | "agent_settled" }>;
turnId: string | undefined;
}): void {
if (event.type === "agent_end") {
// COMPAT(piAgentSettled): added in v0.5.0, remove after 2027-02-21 once the Pi
// floor emits agent_settled and willRetry.
if (event.willRetry === undefined) {
this.completeTurn(turnId, event.messages ?? []);
return;
}
if (this.activeTurnId) {
this.pendingSettledMessages = event.messages ?? [];
}
return;
}
if (this.activeTurnId) {
this.completeTurn(turnId, this.pendingSettledMessages ?? []);
}
}
private handleToolExecutionEnd( private handleToolExecutionEnd(
event: Extract<PiAgentSessionEvent, { type: "tool_execution_end" }>, event: Extract<PiAgentSessionEvent, { type: "tool_execution_end" }>,
): void { ): void {
@@ -2305,6 +2359,8 @@ export class PiRpcAgentSession implements AgentSession {
this.activeClientMessageId = null; this.activeClientMessageId = null;
this.activeAssistantMessageId = null; this.activeAssistantMessageId = null;
this.activeTurnStarted = false; this.activeTurnStarted = false;
this.activeTurnStartedEmitted = false;
this.pendingSettledMessages = null;
this.clearNoTurnBuffers(); this.clearNoTurnBuffers();
const errorMessage = latestPiErrorMessage(messages); const errorMessage = latestPiErrorMessage(messages);
if (typeof errorMessage === "string" && errorMessage.length > 0) { if (typeof errorMessage === "string" && errorMessage.length > 0) {
@@ -187,7 +187,16 @@ export type PiAgentSessionEvent =
} }
| { type: "compaction_start"; reason?: "manual" | "threshold" | "overflow" | string } | { type: "compaction_start"; reason?: "manual" | "threshold" | "overflow" | string }
| { type: "compaction_end"; reason?: string; errorMessage?: string; aborted?: boolean } | { type: "compaction_end"; reason?: string; errorMessage?: string; aborted?: boolean }
| { type: "agent_end"; messages?: PiAgentMessage[] }; | { type: "agent_end"; messages?: PiAgentMessage[]; willRetry?: boolean }
| { type: "agent_settled" }
| {
type: "auto_retry_start";
attempt: number;
maxAttempts: number;
delayMs: number;
errorMessage: string;
}
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string };
export type PiRuntimeEvent = export type PiRuntimeEvent =
| PiAgentSessionEvent | PiAgentSessionEvent
@@ -354,7 +354,25 @@ export class FakePiSession implements PiRuntimeSession {
} }
} }
finishAgentRun({ message, willRetry }: { message: PiAgentMessage; willRetry: boolean }): void {
this.messages = [...this.messages, message];
this.emit({
type: "agent_end",
messages: this.messages,
willRetry,
});
}
settleTurn(): void {
this.emit({ type: "agent_settled" });
}
finishTurn(message: PiAgentMessage = { role: "assistant", content: [] }): void { finishTurn(message: PiAgentMessage = { role: "assistant", content: [] }): void {
this.finishAgentRun({ message, willRetry: false });
this.settleTurn();
}
finishLegacyTurn(message: PiAgentMessage = { role: "assistant", content: [] }): void {
this.messages = [...this.messages, message]; this.messages = [...this.messages, message];
this.emit({ type: "agent_end", messages: this.messages }); this.emit({ type: "agent_end", messages: this.messages });
} }