Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
145 lines
5.3 KiB
TypeScript
145 lines
5.3 KiB
TypeScript
import { Container } from "@earendil-works/pi-tui";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { SessionManager } from "../src/core/session-manager.ts";
|
|
import { SettingsManager } from "../src/core/settings-manager.ts";
|
|
import type { StatusIndicator } from "../src/modes/interactive/components/status-indicator.ts";
|
|
import type { TreeSelectorComponent } from "../src/modes/interactive/components/tree-selector.ts";
|
|
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts";
|
|
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
|
|
import { assistantMsg, userMsg } from "./utilities.ts";
|
|
|
|
const busyMessage = "Wait for the current compaction or tree navigation to finish before navigating the session tree.";
|
|
|
|
function createTreeUI() {
|
|
const sessionManager = SessionManager.inMemory();
|
|
const targetId = sessionManager.appendMessage(userMsg("first"));
|
|
sessionManager.appendMessage(assistantMsg("reply"));
|
|
let selector: TreeSelectorComponent | undefined;
|
|
const onEscape = vi.fn();
|
|
const ui = {
|
|
sessionManager,
|
|
settingsManager: SettingsManager.inMemory(),
|
|
session: {
|
|
isStreaming: false,
|
|
isCompacting: false,
|
|
abort: vi.fn(async () => {
|
|
ui.session.isStreaming = false;
|
|
}),
|
|
abortBranchSummary: vi.fn(),
|
|
navigateTree: vi.fn(async () => {
|
|
if (ui.session.isCompacting) throw new Error(busyMessage);
|
|
return { cancelled: false };
|
|
}),
|
|
},
|
|
defaultEditor: { onEscape },
|
|
editor: { getText: () => "", setText: vi.fn() },
|
|
chatContainer: new Container(),
|
|
isInitialized: true,
|
|
footer: { invalidate: vi.fn() },
|
|
ui: { terminal: { rows: 24, setProgress: vi.fn() }, requestRender: vi.fn() },
|
|
showSelector: (
|
|
create: (done: () => void) => { component: TreeSelectorComponent; focus: TreeSelectorComponent },
|
|
) => {
|
|
selector = create(vi.fn()).component;
|
|
},
|
|
showExtensionSelector: vi.fn(async () => "No summary"),
|
|
// Dispose newly created indicators so a failing regression cannot leak spinner timers.
|
|
showStatusIndicator: vi.fn((indicator: StatusIndicator) => indicator.dispose()),
|
|
clearStatusIndicator: vi.fn(),
|
|
restoreQueuedMessagesToEditor: vi.fn(),
|
|
renderInitialMessages: vi.fn(),
|
|
showStatus: vi.fn(),
|
|
showError: vi.fn(),
|
|
flushCompactionQueue: vi.fn(async () => {}),
|
|
};
|
|
const showTreeSelector = Reflect.get(InteractiveMode.prototype, "showTreeSelector") as (this: typeof ui) => void;
|
|
showTreeSelector.call(ui);
|
|
|
|
return {
|
|
ui,
|
|
onEscape,
|
|
targetId,
|
|
async select() {
|
|
expect(selector).toBeDefined();
|
|
await selector!.getTreeList().onSelect!(targetId);
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("InteractiveMode tree navigation availability", () => {
|
|
beforeEach(() => initTheme("dark"));
|
|
|
|
// Regression for #9178 / PR #9179: rejection must not replace the active operation's UI.
|
|
it.each(["Summarize", "No summary"])("preserves operation UI when choosing %s while busy", async (choice) => {
|
|
const { ui, onEscape, select } = createTreeUI();
|
|
const originalLeafId = ui.sessionManager.getLeafId();
|
|
ui.showExtensionSelector.mockImplementation(async () => {
|
|
// Compaction or another navigation can start while the dialog is open.
|
|
ui.session.isCompacting = true;
|
|
return choice;
|
|
});
|
|
|
|
await select();
|
|
|
|
expect(ui.showError).toHaveBeenCalledWith(busyMessage);
|
|
expect(ui.showStatusIndicator).not.toHaveBeenCalled();
|
|
expect(ui.clearStatusIndicator).not.toHaveBeenCalled();
|
|
expect(ui.defaultEditor.onEscape).toBe(onEscape);
|
|
expect(ui.session.navigateTree).not.toHaveBeenCalled();
|
|
expect(ui.session.abort).not.toHaveBeenCalled();
|
|
expect(ui.sessionManager.getLeafId()).toBe(originalLeafId);
|
|
});
|
|
|
|
it("allows navigation when compaction finishes while the dialog is open", async () => {
|
|
const { ui, targetId, select } = createTreeUI();
|
|
ui.session.isCompacting = true;
|
|
ui.showExtensionSelector.mockImplementation(async () => {
|
|
ui.session.isCompacting = false;
|
|
return "No summary";
|
|
});
|
|
|
|
await select();
|
|
|
|
expect(ui.session.navigateTree).toHaveBeenCalledWith(targetId, {
|
|
summarize: false,
|
|
customInstructions: undefined,
|
|
});
|
|
expect(ui.showError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("still aborts an active response before navigating", async () => {
|
|
const { ui, select } = createTreeUI();
|
|
ui.session.isStreaming = true;
|
|
ui.session.navigateTree.mockImplementation(async () => {
|
|
expect(ui.session.isStreaming).toBe(false);
|
|
expect(ui.restoreQueuedMessagesToEditor).toHaveBeenCalledOnce();
|
|
return { cancelled: false };
|
|
});
|
|
|
|
await select();
|
|
|
|
expect(ui.session.abort).toHaveBeenCalledOnce();
|
|
expect(ui.session.navigateTree).toHaveBeenCalledOnce();
|
|
expect(ui.showError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rechecks availability after the response abort settles", async () => {
|
|
const { ui, onEscape, select } = createTreeUI();
|
|
ui.session.isStreaming = true;
|
|
ui.showExtensionSelector.mockResolvedValue("Summarize");
|
|
ui.session.abort.mockImplementation(async () => {
|
|
ui.session.isStreaming = false;
|
|
ui.session.isCompacting = true;
|
|
});
|
|
|
|
await select();
|
|
|
|
expect(ui.session.abort).toHaveBeenCalledOnce();
|
|
expect(ui.showError).toHaveBeenCalledWith(busyMessage);
|
|
expect(ui.showStatusIndicator).not.toHaveBeenCalled();
|
|
expect(ui.clearStatusIndicator).not.toHaveBeenCalled();
|
|
expect(ui.defaultEditor.onEscape).toBe(onEscape);
|
|
expect(ui.session.navigateTree).not.toHaveBeenCalled();
|
|
});
|
|
});
|