/** * Phase 6 — C layer. * * Asserts `EventController.#handleMessageEnd`'s render labeling for the three * abort-classification paths: * * C1 errorMessage = SILENT_ABORT_MARKER + aborted * → `updateContent` receives a message with `stopReason: "stop"`; * `errorMessage` is NOT overwritten. * C2 errorMessage = undefined (no threaded reason) + aborted + no TTSR flag * → `streamingMessage.errorMessage` is set to the generic "Operation * aborted"; `updateContent` receives the original message ref. * C2b errorMessage = USER_INTERRUPT_LABEL (threaded via AbortController) + aborted * → the threaded reason is preserved verbatim, NOT replaced by the generic. * C3 isTtsrAbortPending = true + aborted * → `updateContent` receives a message with `stopReason: "stop"`; * `errorMessage` is NOT set (TTSR existing behavior unchanged). */ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import * as AIError from "@oh-my-pi/pi-ai/error"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { EventController } from "@oh-my-pi/pi-coding-agent/modes/controllers/event-controller"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { SILENT_ABORT_MARKER, USER_INTERRUPT_LABEL } from "@oh-my-pi/pi-coding-agent/session/messages"; function makeAssistantMessage(overrides: Partial = {}): AssistantMessage { return { role: "assistant", content: [{ type: "text", text: "draft" }], api: "anthropic-messages", provider: "anthropic", model: "claude-sonnet-4-5", stopReason: "aborted", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, timestamp: Date.now(), ...overrides, }; } function createFixture(opts: { streamingMessage: AssistantMessage; isTtsrAbortPending?: boolean; retryAttempt?: number; }) { const updateContent = vi.fn(); const setComplete = vi.fn(); const markTranscriptBlockFinalized = vi.fn(); const streamingComponent = { updateContent, setComplete, markTranscriptBlockFinalized }; const requestRender = vi.fn(); const ctxBase = { isInitialized: true, init: vi.fn(async () => {}), ui: { requestRender }, statusLine: { invalidate: vi.fn() }, updateEditorTopBorder: vi.fn(), streamingComponent, streamingMessage: opts.streamingMessage, transcriptMessageComponents: new WeakMap(), pendingTools: new Map(), noteDisplayableThinkingContent: vi.fn(() => false), }; const sessionMock = { isTtsrAbortPending: opts.isTtsrAbortPending ?? false, retryAttempt: opts.retryAttempt ?? 0, }; const ctx = { ...ctxBase, session: sessionMock, viewSession: sessionMock, clearTransientSessionUi: () => {}, } as unknown as InteractiveModeContext; const controller = new EventController(ctx); return { controller, ctx, streamingComponent, requestRender }; } describe("EventController #handleMessageEnd abort labeling", () => { beforeEach(async () => { await Settings.init({ inMemory: true, cwd: process.cwd() }); }); afterEach(() => { resetSettingsForTest(); }); it("C1: SILENT_ABORT_MARKER + aborted -> updateContent stopReason='stop', errorMessage NOT overwritten", async () => { const message = makeAssistantMessage({ stopReason: "aborted", errorMessage: SILENT_ABORT_MARKER, }); const { controller, ctx, streamingComponent } = createFixture({ streamingMessage: message }); const event: Extract = { type: "message_end", message, }; await controller.handleEvent(event); // `updateContent` was called once with a copy whose `stopReason` is "stop". // The marker on errorMessage is preserved unchanged on that display copy. expect(streamingComponent.updateContent).toHaveBeenCalledTimes(1); const arg = streamingComponent.updateContent.mock.calls[0]![0] as AssistantMessage; expect(arg.stopReason).toBe("stop"); expect(arg.errorMessage).toBe(SILENT_ABORT_MARKER); // Per the silent-abort contract: the controller must NOT overwrite errorMessage // with the operator-facing string. The marker is what drives replay-side // suppression, so it has to survive on the persisted message. expect(message.errorMessage).toBe(SILENT_ABORT_MARKER); // And the streamingMessage on ctx was cleared after the handler ran (lifecycle // guard — kept for completeness). expect(ctx.streamingMessage).toBeUndefined(); }); it("C1b: silent-abort errorId without marker suppresses the abort line", async () => { const message = makeAssistantMessage({ stopReason: "aborted", errorMessage: undefined, errorId: AIError.create(AIError.Flag.SilentAbort), }); const { controller, streamingComponent } = createFixture({ streamingMessage: message }); await controller.handleEvent({ type: "message_end", message }); expect(message.errorMessage).toBeUndefined(); expect(streamingComponent.updateContent).toHaveBeenCalledTimes(1); const arg = streamingComponent.updateContent.mock.calls[0]![0] as AssistantMessage; expect(arg.stopReason).toBe("stop"); expect(arg.errorMessage).toBeUndefined(); }); it("C2: errorMessage undefined (no threaded reason) + aborted + no TTSR -> errorMessage='Operation aborted', updateContent receives original ref", async () => { const message = makeAssistantMessage({ stopReason: "aborted", errorMessage: undefined }); const { controller, streamingComponent } = createFixture({ streamingMessage: message, isTtsrAbortPending: false, }); await controller.handleEvent({ type: "message_end", message }); // No threaded reason -> generic operator-facing label stamped in-place. expect(message.errorMessage).toBe("Operation aborted"); // `updateContent` saw the original streaming message ref (no `{...streamingMessage, stopReason:"stop"}` spread). expect(streamingComponent.updateContent).toHaveBeenCalledTimes(1); const arg = streamingComponent.updateContent.mock.calls[0]![0] as AssistantMessage; expect(arg).toBe(message); expect(arg.stopReason).toBe("aborted"); expect(arg.errorMessage).toBe("Operation aborted"); }); it("C2b: threaded user-interrupt reason on aborted message is preserved, not replaced by the generic label", async () => { const message = makeAssistantMessage({ stopReason: "aborted", errorMessage: USER_INTERRUPT_LABEL }); const { controller, streamingComponent } = createFixture({ streamingMessage: message, isTtsrAbortPending: false, }); await controller.handleEvent({ type: "message_end", message }); // The Esc-interrupt reason rode the AbortController onto errorMessage; the // controller must surface it verbatim instead of overwriting with "Operation aborted". expect(message.errorMessage).toBe(USER_INTERRUPT_LABEL); const arg = streamingComponent.updateContent.mock.calls[0]![0] as AssistantMessage; expect(arg.errorMessage).toBe(USER_INTERRUPT_LABEL); expect(arg.stopReason).toBe("aborted"); }); it("C3: isTtsrAbortPending=true + aborted -> updateContent stopReason='stop', errorMessage NOT set", async () => { const message = makeAssistantMessage({ stopReason: "aborted", errorMessage: undefined }); const { controller, streamingComponent } = createFixture({ streamingMessage: message, isTtsrAbortPending: true, }); await controller.handleEvent({ type: "message_end", message }); // TTSR keeps its existing flag-only render path — `errorMessage` stays undefined, // and the display copy gets `stopReason: "stop"`. expect(message.errorMessage).toBeUndefined(); expect(streamingComponent.updateContent).toHaveBeenCalledTimes(1); const arg = streamingComponent.updateContent.mock.calls[0]![0] as AssistantMessage; expect(arg.stopReason).toBe("stop"); expect(arg.errorMessage).toBeUndefined(); }); });