111 lines
4.2 KiB
TypeScript
111 lines
4.2 KiB
TypeScript
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
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 { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
|
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 { TERMINAL } from "@oh-my-pi/pi-tui";
|
|
|
|
/**
|
|
* Models the loader lifecycle InteractiveMode owns: `agent_start` creates the
|
|
* loader via `ensureLoadingAnimation`; `agent_end` stops and drops it. The
|
|
* streaming getter is backed by mutable flags the tests drive directly.
|
|
*/
|
|
function createContext() {
|
|
const streamState = { isStreaming: false };
|
|
const loader = { stop: vi.fn() };
|
|
const ctx = {
|
|
isInitialized: true,
|
|
settings: { get: () => false },
|
|
statusLine: { invalidate: vi.fn(), markActivityStart: vi.fn(), markActivityEnd: vi.fn() },
|
|
updateEditorTopBorder: vi.fn(),
|
|
pendingTools: new Map<string, unknown>(),
|
|
hideThinkingBlock: false,
|
|
setWorkingMessage: vi.fn(),
|
|
clearPinnedError: vi.fn(),
|
|
loadingAnimation: undefined,
|
|
retryLoader: undefined,
|
|
streamingComponent: undefined,
|
|
streamingMessage: undefined,
|
|
statusContainer: { clear: vi.fn(), disposeChildren: vi.fn() },
|
|
chatContainer: { removeChild: vi.fn() },
|
|
flushPendingModelSwitch: vi.fn(async () => {}),
|
|
editor: { getText: () => "" },
|
|
sessionManager: { getSessionName: () => "test-session" },
|
|
ensureLoadingAnimation: vi.fn(),
|
|
ui: { requestRender: vi.fn() },
|
|
viewSession: { isCompacting: false, getLastAssistantMessage: () => undefined },
|
|
session: {
|
|
get isStreaming() {
|
|
return streamState.isStreaming;
|
|
},
|
|
getToolByName: () => undefined,
|
|
},
|
|
} as unknown as InteractiveModeContext;
|
|
ctx.ensureLoadingAnimation = vi.fn(() => {
|
|
ctx.loadingAnimation ??= loader as unknown as typeof ctx.loadingAnimation;
|
|
});
|
|
return { ctx, streamState, loader };
|
|
}
|
|
|
|
const AGENT_START = { type: "agent_start" } as unknown as AgentSessionEvent;
|
|
const AGENT_END = { type: "agent_end", messages: [] } as unknown as AgentSessionEvent;
|
|
|
|
describe("EventController superseded agent_end", () => {
|
|
beforeAll(async () => {
|
|
await initTheme(false);
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
resetSettingsForTest();
|
|
await Settings.init({ inMemory: true });
|
|
vi.spyOn(TERMINAL, "sendNotification").mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.useRealTimers();
|
|
resetSettingsForTest();
|
|
});
|
|
|
|
it("keeps the loader alive when a stale agent_end lands after the resumed turn's agent_start", async () => {
|
|
const { ctx, streamState, loader } = createContext();
|
|
const controller = new EventController(ctx);
|
|
|
|
// Turn 1 begins and creates the loader.
|
|
await controller.handleEvent(AGENT_START);
|
|
expect(ctx.loadingAnimation).toBeDefined();
|
|
|
|
// User abort of a queued steer: the resumed turn's agent_start arrives and
|
|
// the agent is streaming again. The interrupted turn's agent_end is still in
|
|
// flight through the async event pipeline.
|
|
streamState.isStreaming = true;
|
|
await controller.handleEvent(AGENT_START);
|
|
|
|
// The interrupted turn's agent_end finally propagates. Because the agent is
|
|
// already streaming the resumed turn, it must not tear down the live loader —
|
|
// otherwise "Working…" vanishes while the agent keeps running.
|
|
await controller.handleEvent(AGENT_END);
|
|
|
|
expect(loader.stop).not.toHaveBeenCalled();
|
|
expect(ctx.loadingAnimation).toBeDefined();
|
|
expect(TERMINAL.sendNotification).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("tears the loader down on the live turn's own final agent_end", async () => {
|
|
const { ctx, streamState, loader } = createContext();
|
|
const controller = new EventController(ctx);
|
|
|
|
await controller.handleEvent(AGENT_START);
|
|
expect(ctx.loadingAnimation).toBeDefined();
|
|
|
|
// A genuine turn boundary: the agent is no longer streaming, so the guard
|
|
// must not fire and the loader is torn down as before.
|
|
streamState.isStreaming = false;
|
|
await controller.handleEvent(AGENT_END);
|
|
|
|
expect(loader.stop).toHaveBeenCalledTimes(1);
|
|
expect(ctx.loadingAnimation).toBeUndefined();
|
|
});
|
|
});
|