mirror of
https://github.com/earendil-works/pi.git
synced 2026-10-19 05:49:16 +08:00
Follow-up to e687434a6.
Recheck compaction state after navigation dialogs and response cancellation before replacing status indicators or escape handlers. Document navigation conflicts and add regression coverage.
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();
|
|
});
|
|
});
|