35307b60a7
With display.showTokenUsage on, the usage row was rendered inside the assistant block above the turn's tool blocks. Finalizing the assistant block was therefore deferred, and the late append recommitted the already-committed tool rows, duplicating them in scrollback (worst with parallel tool calls). The assistant block now always finalizes as soon as a tool-call appears, and the usage row is emitted as a standalone finalized block below the turn's tool blocks across all three render paths (live event-controller, transcript rebuild, agent-hub). The now-dead setUsageInfo/#usageInfo path on AssistantMessageComponent is removed.
176 lines
7.1 KiB
TypeScript
176 lines
7.1 KiB
TypeScript
/**
|
|
* 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 { 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> = {}): 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,
|
|
pendingTools: new Map(),
|
|
};
|
|
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<AgentSessionEvent, { type: "message_end" }> = {
|
|
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("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();
|
|
});
|
|
});
|