349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
import { beforeAll, describe, expect, it, vi } from "bun:test";
|
|
import { CommandController } from "@oh-my-pi/pi-coding-agent/modes/controllers/command-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 { SessionContext } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
|
import { type Component, Spacer, Text } from "@oh-my-pi/pi-tui";
|
|
|
|
const setSessionTerminalTitleMock = vi.fn();
|
|
|
|
vi.mock("@oh-my-pi/pi-coding-agent/utils/title-generator", () => ({
|
|
setSessionTerminalTitle: setSessionTerminalTitleMock,
|
|
}));
|
|
|
|
type TestContainer = {
|
|
children: Component[];
|
|
clear: () => void;
|
|
addChild: (child: Component) => void;
|
|
};
|
|
|
|
type TestContext = InteractiveModeContext & {
|
|
chatContainer: TestContainer;
|
|
pendingMessagesContainer: TestContainer;
|
|
statusContainer: TestContainer;
|
|
pendingTools: {
|
|
clear: () => void;
|
|
};
|
|
loadingAnimation:
|
|
| {
|
|
stop: () => void;
|
|
}
|
|
| undefined;
|
|
statusLine: {
|
|
invalidate: () => void;
|
|
setSessionStartTime: (time: number) => void;
|
|
};
|
|
ui: {
|
|
requestRender: () => void;
|
|
terminal: { columns: number; rows: number };
|
|
};
|
|
session: {
|
|
canStartNewSession: () => Promise<boolean>;
|
|
newSession: (...args: unknown[]) => Promise<boolean>;
|
|
isStreaming: boolean;
|
|
isCompacting: boolean;
|
|
abortCompaction: () => void;
|
|
};
|
|
sessionManager: {
|
|
getSessionName: () => string;
|
|
getCwd: () => string;
|
|
buildSessionContext: () => SessionContext;
|
|
};
|
|
reloadTodos: () => Promise<void>;
|
|
updateEditorTopBorder: () => void;
|
|
};
|
|
|
|
function createTrackedContainer(name: string, calls: string[], initialChildren: Component[] = []): TestContainer {
|
|
const container: TestContainer = {
|
|
children: [...initialChildren],
|
|
clear: vi.fn(() => {
|
|
container.children = [];
|
|
calls.push(`${name}.clear`);
|
|
}),
|
|
addChild: vi.fn((child: Component) => {
|
|
container.children.push(child);
|
|
calls.push(`${name}.addChild`);
|
|
}),
|
|
};
|
|
|
|
return container;
|
|
}
|
|
|
|
function createContext(options?: {
|
|
withLoadingAnimation?: boolean;
|
|
canStartNewSessionResult?: boolean;
|
|
newSessionResult?: boolean;
|
|
isStreaming?: boolean;
|
|
isCompacting?: boolean;
|
|
initialSessionMode?: string;
|
|
}): { ctx: TestContext; calls: string[] } {
|
|
const calls: string[] = [];
|
|
const chatContainer = createTrackedContainer("chatContainer", calls, [new Text("stale chat", 0, 0)]);
|
|
const pendingMessagesContainer = createTrackedContainer("pendingMessagesContainer", calls, [
|
|
new Text("pending", 0, 0),
|
|
]);
|
|
const statusContainer = createTrackedContainer("statusContainer", calls, [new Text("streaming status", 0, 0)]);
|
|
const loadingAnimation =
|
|
options?.withLoadingAnimation === false
|
|
? undefined
|
|
: {
|
|
stop: vi.fn(() => {
|
|
calls.push("loadingAnimation.stop");
|
|
}),
|
|
};
|
|
|
|
const ctx = {
|
|
chatContainer,
|
|
pendingMessagesContainer,
|
|
statusContainer,
|
|
pendingTools: {
|
|
clear: vi.fn(() => {
|
|
calls.push("pendingTools.clear");
|
|
}),
|
|
},
|
|
loadingAnimation,
|
|
compactionQueuedMessages: ["queued"],
|
|
streamingComponent: { active: true },
|
|
streamingMessage: { active: true },
|
|
statusLine: {
|
|
invalidate: vi.fn(() => {
|
|
calls.push("statusLine.invalidate");
|
|
}),
|
|
setSessionStartTime: vi.fn(() => {
|
|
calls.push("statusLine.setSessionStartTime");
|
|
}),
|
|
},
|
|
ui: {
|
|
requestRender: vi.fn(() => {
|
|
calls.push("ui.requestRender");
|
|
}),
|
|
terminal: { columns: 120, rows: 40 },
|
|
},
|
|
session: {
|
|
canStartNewSession: vi.fn(async () => {
|
|
calls.push("session.canStartNewSession");
|
|
return options?.canStartNewSessionResult ?? true;
|
|
}),
|
|
newSession: vi.fn(async () => {
|
|
calls.push("session.newSession");
|
|
return options?.newSessionResult ?? true;
|
|
}),
|
|
isStreaming: options?.isStreaming ?? false,
|
|
isCompacting: options?.isCompacting ?? false,
|
|
abortCompaction: vi.fn(() => {
|
|
calls.push("session.abortCompaction");
|
|
}),
|
|
},
|
|
sessionManager: {
|
|
getSessionName: vi.fn(() => "Fresh session"),
|
|
getCwd: vi.fn(() => "/tmp/project"),
|
|
buildSessionContext: vi.fn(() => ({ mode: options?.initialSessionMode ?? "none" }) as SessionContext),
|
|
},
|
|
reloadTodos: vi.fn(async () => {
|
|
calls.push("reloadTodos");
|
|
}),
|
|
updateEditorTopBorder: vi.fn(() => {
|
|
calls.push("updateEditorTopBorder");
|
|
}),
|
|
} as unknown as TestContext;
|
|
|
|
return { ctx, calls };
|
|
}
|
|
|
|
beforeAll(() => {
|
|
initTheme();
|
|
});
|
|
|
|
describe("CommandController /new command", () => {
|
|
it("clearCommand clears session state and starts a fresh session without extra renders", async () => {
|
|
setSessionTerminalTitleMock.mockReset();
|
|
const { ctx, calls } = createContext();
|
|
const loadingAnimation = ctx.loadingAnimation;
|
|
setSessionTerminalTitleMock.mockImplementation(() => {
|
|
calls.push("setSessionTerminalTitle");
|
|
});
|
|
const controller = new CommandController(ctx);
|
|
|
|
const result = await controller.handleClearCommand();
|
|
expect(result).toBe(true);
|
|
|
|
expect(ctx.session.canStartNewSession).toHaveBeenCalledTimes(1);
|
|
expect(ctx.session.newSession).toHaveBeenCalledTimes(1);
|
|
expect(ctx.session.abortCompaction).not.toHaveBeenCalled();
|
|
expect(ctx.chatContainer.clear).toHaveBeenCalledTimes(1);
|
|
expect(ctx.pendingMessagesContainer.clear).toHaveBeenCalledTimes(1);
|
|
expect(ctx.statusContainer.clear).toHaveBeenCalledTimes(1);
|
|
expect(ctx.pendingTools.clear).toHaveBeenCalledTimes(1);
|
|
expect(loadingAnimation?.stop).toHaveBeenCalledTimes(1);
|
|
expect(ctx.loadingAnimation).toBeUndefined();
|
|
expect(ctx.streamingComponent).toBeUndefined();
|
|
expect(ctx.streamingMessage).toBeUndefined();
|
|
expect(ctx.compactionQueuedMessages).toEqual([]);
|
|
expect(ctx.statusLine.invalidate).toHaveBeenCalledTimes(1);
|
|
expect(ctx.statusLine.setSessionStartTime).toHaveBeenCalledTimes(1);
|
|
expect(ctx.reloadTodos).toHaveBeenCalledTimes(1);
|
|
expect(ctx.ui.requestRender).toHaveBeenCalledTimes(2);
|
|
expect(setSessionTerminalTitleMock).toHaveBeenCalledWith("Fresh session", "/tmp/project");
|
|
expect(calls).toEqual(
|
|
expect.arrayContaining([
|
|
"session.canStartNewSession",
|
|
"session.newSession",
|
|
"loadingAnimation.stop",
|
|
"statusContainer.clear",
|
|
"setSessionTerminalTitle",
|
|
"statusLine.invalidate",
|
|
"statusLine.setSessionStartTime",
|
|
"updateEditorTopBorder",
|
|
"chatContainer.clear",
|
|
"pendingMessagesContainer.clear",
|
|
"pendingTools.clear",
|
|
"reloadTodos",
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("clearCommand without loadingAnimation does not throw", async () => {
|
|
setSessionTerminalTitleMock.mockReset();
|
|
const { ctx, calls } = createContext({ withLoadingAnimation: false });
|
|
const controller = new CommandController(ctx);
|
|
|
|
await expect(controller.handleClearCommand()).resolves.toBe(true);
|
|
|
|
expect(ctx.loadingAnimation).toBeUndefined();
|
|
expect(calls).not.toContain("loadingAnimation.stop");
|
|
expect(ctx.ui.requestRender).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("clearCommand keeps the current streaming session UI when newSession is cancelled", async () => {
|
|
setSessionTerminalTitleMock.mockReset();
|
|
const { ctx, calls } = createContext({ canStartNewSessionResult: false, isStreaming: true });
|
|
expect(ctx.session.isStreaming).toBe(true);
|
|
const loadingAnimation = ctx.loadingAnimation;
|
|
const controller = new CommandController(ctx);
|
|
|
|
const result = await controller.handleClearCommand();
|
|
expect(result).toBe(false);
|
|
expect(ctx.session.canStartNewSession).toHaveBeenCalledTimes(1);
|
|
expect(ctx.session.newSession).not.toHaveBeenCalled();
|
|
expect(setSessionTerminalTitleMock).not.toHaveBeenCalled();
|
|
expect(ctx.statusLine.invalidate).not.toHaveBeenCalled();
|
|
expect(ctx.statusLine.setSessionStartTime).not.toHaveBeenCalled();
|
|
expect(ctx.updateEditorTopBorder).not.toHaveBeenCalled();
|
|
expect(ctx.ui.requestRender).toHaveBeenCalledTimes(1);
|
|
expect(ctx.chatContainer.clear).not.toHaveBeenCalled();
|
|
expect(ctx.pendingMessagesContainer.clear).not.toHaveBeenCalled();
|
|
expect(ctx.statusContainer.clear).not.toHaveBeenCalled();
|
|
expect(ctx.pendingTools.clear).not.toHaveBeenCalled();
|
|
expect(ctx.reloadTodos).not.toHaveBeenCalled();
|
|
expect(loadingAnimation?.stop).not.toHaveBeenCalled();
|
|
expect(ctx.loadingAnimation).toBe(loadingAnimation);
|
|
expect(ctx.chatContainer.children).toHaveLength(3);
|
|
const existingMessage = ctx.chatContainer.children[0];
|
|
const spacer = ctx.chatContainer.children[1];
|
|
const errorMessage = ctx.chatContainer.children[2];
|
|
if (!(existingMessage instanceof Text)) {
|
|
throw new Error("Expected stale chat to remain visible");
|
|
}
|
|
if (!(spacer instanceof Spacer)) {
|
|
throw new Error("Expected spacer before cancellation message");
|
|
}
|
|
if (!(errorMessage instanceof Text)) {
|
|
throw new Error("Expected cancellation error message");
|
|
}
|
|
expect(existingMessage.render(120).join("\n")).toContain("stale chat");
|
|
expect(errorMessage.render(120).join("\n")).toContain("Error: New session cancelled");
|
|
expect(ctx.pendingMessagesContainer.children).toHaveLength(1);
|
|
expect(ctx.statusContainer.children).toHaveLength(1);
|
|
const existingStatus = ctx.statusContainer.children[0];
|
|
if (!(existingStatus instanceof Text)) {
|
|
throw new Error("Expected current status to remain visible");
|
|
}
|
|
expect(existingStatus.render(120).join("\n")).toContain("streaming status");
|
|
expect(ctx.compactionQueuedMessages as unknown).toEqual(["queued"]);
|
|
expect(ctx.streamingComponent as unknown).toEqual({ active: true });
|
|
expect(ctx.streamingMessage as unknown).toEqual({ active: true });
|
|
expect(calls).toEqual([
|
|
"session.canStartNewSession",
|
|
"chatContainer.addChild",
|
|
"chatContainer.addChild",
|
|
"ui.requestRender",
|
|
]);
|
|
});
|
|
|
|
it("clearCommand rolls back temporary pre-switch teardown when new session approval is denied", async () => {
|
|
const { ctx, calls } = createContext({ canStartNewSessionResult: false, initialSessionMode: "plan" });
|
|
const hookState = { mode: "plan" as "plan" | "none" };
|
|
const controller = new CommandController(ctx);
|
|
|
|
ctx.sessionManager.buildSessionContext = vi.fn(() => ({ mode: hookState.mode }) as SessionContext);
|
|
ctx.session.canStartNewSession = vi.fn(async () => {
|
|
calls.push(`session.canStartNewSession:${ctx.sessionManager.buildSessionContext().mode}`);
|
|
return false;
|
|
});
|
|
|
|
const rollbackBeforeSwitchCheck = vi.fn(() => {
|
|
hookState.mode = "plan";
|
|
calls.push(`rollbackBeforeSwitchCheck:${hookState.mode}`);
|
|
});
|
|
const beforeSwitchCheck = vi.fn(() => {
|
|
hookState.mode = "none";
|
|
calls.push(`prepareBeforeSwitchCheck:${hookState.mode}`);
|
|
return rollbackBeforeSwitchCheck;
|
|
});
|
|
const beforeSwitch = vi.fn(() => {
|
|
calls.push("beforeSwitch");
|
|
});
|
|
|
|
const result = await controller.handleClearCommand({
|
|
beforeSwitchCheck,
|
|
beforeSwitch,
|
|
});
|
|
|
|
expect(result).toBe(false);
|
|
expect(beforeSwitchCheck).toHaveBeenCalledTimes(1);
|
|
expect(rollbackBeforeSwitchCheck).toHaveBeenCalledTimes(1);
|
|
expect(beforeSwitch).not.toHaveBeenCalled();
|
|
expect(ctx.session.newSession).not.toHaveBeenCalled();
|
|
expect(hookState.mode).toBe("plan");
|
|
expect(calls).toEqual([
|
|
"prepareBeforeSwitchCheck:none",
|
|
"session.canStartNewSession:none",
|
|
"rollbackBeforeSwitchCheck:plan",
|
|
"chatContainer.addChild",
|
|
"chatContainer.addChild",
|
|
"ui.requestRender",
|
|
]);
|
|
});
|
|
|
|
it("clearCommand does not abort compaction when new session approval is denied", async () => {
|
|
const { ctx } = createContext({ canStartNewSessionResult: false, isCompacting: true });
|
|
const controller = new CommandController(ctx);
|
|
|
|
const result = await controller.handleClearCommand();
|
|
|
|
expect(result).toBe(false);
|
|
expect(ctx.session.canStartNewSession).toHaveBeenCalledTimes(1);
|
|
expect(ctx.session.abortCompaction).not.toHaveBeenCalled();
|
|
expect(ctx.session.newSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("clearCommand adds a new session started message after clearing chat", async () => {
|
|
setSessionTerminalTitleMock.mockReset();
|
|
const { ctx } = createContext();
|
|
const controller = new CommandController(ctx);
|
|
|
|
await controller.handleClearCommand();
|
|
|
|
expect(ctx.chatContainer.children).toHaveLength(2);
|
|
const spacer = ctx.chatContainer.children[0];
|
|
const message = ctx.chatContainer.children[1];
|
|
if (!(spacer instanceof Spacer)) {
|
|
throw new Error("Expected spacer after clearing chat");
|
|
}
|
|
if (!(message instanceof Text)) {
|
|
throw new Error("Expected new session message");
|
|
}
|
|
expect(message.render(120).join("\n")).toContain("New session started");
|
|
});
|
|
});
|