fix(coding-agent): read the settled turn from agent_end for error notifications

Classifier-refusal failures end a turn with stopReason === "error" but
get pruned from the active context (agent-session.ts's
#removeAssistantMessageFromActiveContext) before agent_end fires.
sendErrorNotification() read viewSession.getLastAssistantMessage(),
which reflects that mutated context and silently missed the
notification for exactly the turns it should fire on.

Thread the agent_end event through #handleAgentEnd -> #finishAgentEnd
so sendErrorNotification reads the turn's own outcome from
agent_end.messages instead.
This commit is contained in:
Mathews-Tom
2026-07-06 04:02:48 +05:30
parent b95155423a
commit 90527a5ae4
2 changed files with 106 additions and 15 deletions
@@ -1,4 +1,4 @@
import type { ImageContent } from "@oh-my-pi/pi-ai";
import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
import * as AIError from "@oh-my-pi/pi-ai/error";
import { getStreamingPartialJson } from "@oh-my-pi/pi-ai/utils/block-symbols";
import { type Component, Loader, TERMINAL } from "@oh-my-pi/pi-tui";
@@ -1060,7 +1060,7 @@ export class EventController {
}
}
}
async #handleAgentEnd(_event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
async #handleAgentEnd(event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
// A superseded agent_end: the agent is already streaming a fresh turn, so
// this event belongs to a turn that has already been replaced. The session
// dispatches to listeners fire-and-forget across an async extension-emit hop
@@ -1072,10 +1072,10 @@ export class EventController {
// then). Mirrors the collab guest's !isStreaming loader reconciler.
if (this.ctx.session.isStreaming) return;
await this.#finishAgentEnd();
await this.#finishAgentEnd(event);
}
async #finishAgentEnd(): Promise<void> {
async #finishAgentEnd(event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
this.#setTerminalProgress(false);
this.ctx.statusLine.markActivityEnd();
this.#streamingReveal.stop();
@@ -1120,7 +1120,7 @@ export class EventController {
this.ctx.ui.requestRender();
this.#scheduleIdleCompaction();
this.#scheduleIdleRecap();
this.sendErrorNotification();
this.sendErrorNotification(event);
this.sendCompletionNotification();
}
@@ -1477,11 +1477,17 @@ export class EventController {
return this.ctx.viewSession.getContextUsage()?.tokens ?? 0;
}
sendErrorNotification(): void {
sendErrorNotification(event: Extract<AgentSessionEvent, { type: "agent_end" }>): void {
const notify = settings.get("error.notify");
if (notify === "off") return;
const last = this.ctx.viewSession.getLastAssistantMessage?.();
// Read the turn's own outcome from `agent_end.messages`, not the mutable
// active context: a classifier-refusal failure is final (stopReason ===
// "error") but gets pruned from `viewSession`'s active context before this
// handler runs (see `#removeAssistantMessageFromActiveContext` in
// agent-session.ts), so `getLastAssistantMessage()` would see a stale or
// absent assistant and silently drop the notification.
const last = event.messages.findLast((message): message is AssistantMessage => message.role === "assistant");
if (last?.stopReason !== "error") return;
const sessionName = this.ctx.sessionManager.getSessionName();