262 lines
9.7 KiB
TypeScript
262 lines
9.7 KiB
TypeScript
import { afterEach, beforeAll, beforeEach, describe, expect, it, type Mock, 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";
|
|
|
|
interface FakeWorkingLoader {
|
|
stop: Mock<() => void>;
|
|
kind: "working";
|
|
}
|
|
|
|
/**
|
|
* Faithful model of the shared `statusContainer` + working-loader invariant that
|
|
* InteractiveMode owns:
|
|
* - `agent_start` → `ensureLoadingAnimation()` only creates+attaches the loader
|
|
* when `loadingAnimation` is unset (the real `if (!this.loadingAnimation)`
|
|
* guard), so a stale, still-referenced loader makes it a no-op.
|
|
* - A transient overlay (auto-compaction / auto-retry) takes over the container.
|
|
*
|
|
* The regression: the overlay handlers cleared the container (detaching the
|
|
* working loader) but left `loadingAnimation` set, so the resumed turn's
|
|
* `agent_start` skipped re-attaching it — "Working…" vanished while the agent
|
|
* kept streaming. The fix tears the working loader down (stop + dereference) so
|
|
* the next `agent_start` recreates and re-attaches it.
|
|
*/
|
|
function createContext(options: { terminalProgress?: boolean } = {}) {
|
|
const streamState = { isStreaming: false };
|
|
const children: unknown[] = [];
|
|
const statusContainer = {
|
|
children,
|
|
clear() {
|
|
children.length = 0;
|
|
},
|
|
disposeChildren() {
|
|
children.length = 0;
|
|
},
|
|
addChild(child: unknown) {
|
|
children.push(child);
|
|
},
|
|
removeChild(child: unknown) {
|
|
const index = children.indexOf(child);
|
|
if (index !== -1) children.splice(index, 1);
|
|
},
|
|
};
|
|
const workingLoaders: FakeWorkingLoader[] = [];
|
|
const setProgress = vi.fn();
|
|
const ctx = {
|
|
isInitialized: true,
|
|
settings: {
|
|
get: (path: string) => path === "terminal.showProgress" && options.terminalProgress === true,
|
|
},
|
|
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,
|
|
autoCompactionLoader: undefined,
|
|
retryLoader: undefined,
|
|
streamingComponent: undefined,
|
|
streamingMessage: undefined,
|
|
statusContainer,
|
|
chatContainer: { removeChild: vi.fn(), clear: vi.fn() },
|
|
flushPendingModelSwitch: vi.fn(async () => {}),
|
|
flushCompactionQueue: vi.fn(async () => {}),
|
|
rebuildChatFromMessages: vi.fn(),
|
|
reloadTodos: vi.fn(async () => {}),
|
|
showStatus: vi.fn(),
|
|
showWarning: vi.fn(),
|
|
showError: vi.fn(),
|
|
editor: { getText: () => "" },
|
|
sessionManager: { getSessionName: () => "test-session" },
|
|
ui: { requestRender: vi.fn(), requestComponentRender: vi.fn(), terminal: { setProgress } },
|
|
viewSession: {
|
|
isCompacting: false,
|
|
getLastAssistantMessage: () => undefined,
|
|
get isStreaming() {
|
|
return streamState.isStreaming;
|
|
},
|
|
},
|
|
session: {
|
|
get isStreaming() {
|
|
return streamState.isStreaming;
|
|
},
|
|
getToolByName: () => undefined,
|
|
},
|
|
} as unknown as InteractiveModeContext;
|
|
ctx.ensureLoadingAnimation = vi.fn(() => {
|
|
if (ctx.loadingAnimation) return;
|
|
statusContainer.clear();
|
|
const working: FakeWorkingLoader = { stop: vi.fn(), kind: "working" };
|
|
workingLoaders.push(working);
|
|
ctx.loadingAnimation = working as unknown as typeof ctx.loadingAnimation;
|
|
statusContainer.addChild(ctx.loadingAnimation);
|
|
});
|
|
return { ctx, streamState, statusContainer, workingLoaders, setProgress };
|
|
}
|
|
|
|
const AGENT_START = { type: "agent_start" } as unknown as AgentSessionEvent;
|
|
const AGENT_END = { type: "agent_end" } as unknown as AgentSessionEvent;
|
|
const COMPACTION_START = {
|
|
type: "auto_compaction_start",
|
|
reason: "overflow",
|
|
action: "context-full",
|
|
} as unknown as AgentSessionEvent;
|
|
const COMPACTION_END = {
|
|
type: "auto_compaction_end",
|
|
action: "context-full",
|
|
result: { summary: "s", shortSummary: "s", tokensBefore: 10, details: {}, firstKeptEntryId: undefined },
|
|
willRetry: true,
|
|
} as unknown as AgentSessionEvent;
|
|
const RETRY_START = {
|
|
type: "auto_retry_start",
|
|
attempt: 1,
|
|
maxAttempts: 3,
|
|
delayMs: 1000,
|
|
errorMessage: "overloaded",
|
|
} as unknown as AgentSessionEvent;
|
|
const TASK_TOOL_EXECUTION_END = {
|
|
type: "tool_execution_end",
|
|
toolCallId: "call-task-1",
|
|
toolName: "task",
|
|
args: {},
|
|
result: { content: [], details: {} },
|
|
isError: false,
|
|
} as unknown as AgentSessionEvent;
|
|
|
|
describe("EventController loader recovery after overflow maintenance", () => {
|
|
beforeAll(async () => {
|
|
await initTheme(false);
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
resetSettingsForTest();
|
|
await Settings.init({ inMemory: true });
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
resetSettingsForTest();
|
|
});
|
|
|
|
it("re-shows the Working… loader after auto-compaction recovers and streams a new turn", async () => {
|
|
const { ctx, streamState, statusContainer, workingLoaders } = createContext();
|
|
const controller = new EventController(ctx);
|
|
|
|
// Turn 1 begins: the working loader is created and attached.
|
|
await controller.handleEvent(AGENT_START);
|
|
const firstWorking = workingLoaders[0];
|
|
expect(firstWorking).toBeDefined();
|
|
expect(statusContainer.children).toContain(ctx.loadingAnimation);
|
|
|
|
// Overflow recovery hands the status container to the auto-compaction loader.
|
|
// The original turn's agent_end is held while the prompt is in flight, so the
|
|
// session keeps reporting streaming throughout.
|
|
streamState.isStreaming = true;
|
|
await controller.handleEvent(COMPACTION_START);
|
|
|
|
// The working loader must be fully torn down — not detached-but-referenced —
|
|
// so the upcoming agent_start can recreate it.
|
|
expect(firstWorking?.stop).toHaveBeenCalled();
|
|
expect(ctx.loadingAnimation).toBeUndefined();
|
|
expect(statusContainer.children).not.toContain(firstWorking);
|
|
|
|
await controller.handleEvent(COMPACTION_END);
|
|
|
|
// The retry continuation starts a fresh turn: the loader must reappear in the
|
|
// status container so streaming shows "Working…" again (issue: it stayed gone).
|
|
await controller.handleEvent(AGENT_START);
|
|
expect(ctx.loadingAnimation).toBeDefined();
|
|
expect(statusContainer.children).toContain(ctx.loadingAnimation);
|
|
expect(workingLoaders).toHaveLength(2);
|
|
});
|
|
|
|
it("re-shows the Working… loader after an auto-retry resumes the turn", async () => {
|
|
const { ctx, streamState, statusContainer, workingLoaders } = createContext();
|
|
const controller = new EventController(ctx);
|
|
|
|
await controller.handleEvent(AGENT_START);
|
|
const firstWorking = workingLoaders[0];
|
|
expect(statusContainer.children).toContain(ctx.loadingAnimation);
|
|
|
|
// A transient error: the retry loader takes over the status container.
|
|
streamState.isStreaming = true;
|
|
await controller.handleEvent(RETRY_START);
|
|
expect(firstWorking?.stop).toHaveBeenCalled();
|
|
expect(ctx.loadingAnimation).toBeUndefined();
|
|
|
|
// The retry attempt re-enters the agent loop, emitting a fresh agent_start.
|
|
await controller.handleEvent(AGENT_START);
|
|
expect(ctx.loadingAnimation).toBeDefined();
|
|
expect(statusContainer.children).toContain(ctx.loadingAnimation);
|
|
});
|
|
|
|
it("re-shows the Working… loader after a subagent task completes while the session keeps streaming", async () => {
|
|
const { ctx, streamState, statusContainer, workingLoaders } = createContext();
|
|
const controller = new EventController(ctx);
|
|
|
|
// Turn begins: the working loader is created and attached.
|
|
await controller.handleEvent(AGENT_START);
|
|
const firstWorking = workingLoaders[0];
|
|
expect(firstWorking).toBeDefined();
|
|
|
|
// A transient overlay (auto-retry / auto-compaction) tore the loader down
|
|
// mid-tool; the session is still streaming when the subagent's task
|
|
// completes. Before the fix, `tool_execution_end` (unlike `_update`) did
|
|
// not re-arm the loader, so the UI looked idle while the agent kept going.
|
|
streamState.isStreaming = true;
|
|
ctx.loadingAnimation?.stop();
|
|
ctx.loadingAnimation = undefined;
|
|
statusContainer.clear();
|
|
|
|
await controller.handleEvent(TASK_TOOL_EXECUTION_END);
|
|
|
|
expect(ctx.loadingAnimation).toBeDefined();
|
|
expect(statusContainer.children).toContain(ctx.loadingAnimation);
|
|
expect(workingLoaders).toHaveLength(2);
|
|
});
|
|
|
|
it("does not re-arm the Working… loader on tool_execution_end once the session has stopped streaming", async () => {
|
|
const { ctx, streamState, statusContainer } = createContext();
|
|
const controller = new EventController(ctx);
|
|
|
|
await controller.handleEvent(AGENT_START);
|
|
ctx.loadingAnimation?.stop();
|
|
ctx.loadingAnimation = undefined;
|
|
statusContainer.clear();
|
|
streamState.isStreaming = false;
|
|
|
|
await controller.handleEvent(TASK_TOOL_EXECUTION_END);
|
|
|
|
// No streaming → reconciler must stay a no-op; the spinner is not the
|
|
// post-turn idle state.
|
|
expect(ctx.loadingAnimation).toBeUndefined();
|
|
expect(statusContainer.children).toHaveLength(0);
|
|
});
|
|
|
|
it("mirrors agent and auto-compaction activity to OSC 9;4 when enabled", async () => {
|
|
const { ctx, setProgress } = createContext({ terminalProgress: true });
|
|
const controller = new EventController(ctx);
|
|
|
|
await controller.handleEvent(AGENT_START);
|
|
expect(setProgress).toHaveBeenCalledTimes(1);
|
|
expect(setProgress).toHaveBeenLastCalledWith(true);
|
|
|
|
await controller.handleEvent(COMPACTION_START);
|
|
expect(setProgress).toHaveBeenCalledTimes(1);
|
|
|
|
await controller.handleEvent(COMPACTION_END);
|
|
expect(setProgress).toHaveBeenCalledTimes(2);
|
|
expect(setProgress).toHaveBeenLastCalledWith(false);
|
|
|
|
await controller.handleEvent(AGENT_START);
|
|
await controller.handleEvent(AGENT_END);
|
|
expect(setProgress.mock.calls.map(call => call[0])).toEqual([true, false, true, false]);
|
|
});
|
|
});
|