181419b65b
- Added an interrupt state in EventController to switch the working label to `Interrupting...` and suspend intent-driven updates until the turn resets. - Called `notifyInterrupting()` from streaming-interrupt paths in InputController and exposed it on InteractiveModeContext so Esc acknowledgement shows immediately while tools tear down. - Added tests to verify loader acknowledgement, freeze of late intent updates during interruption, and label updates resuming on the next agent start.
340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
import { describe, expect, it, type Mock, vi } from "bun:test";
|
|
import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller";
|
|
import type { InteractiveModeContext, SubmittedUserInput } from "@oh-my-pi/pi-coding-agent/modes/types";
|
|
import { USER_INTERRUPT_LABEL } from "@oh-my-pi/pi-coding-agent/session/messages";
|
|
|
|
type Spy = Mock<(...args: unknown[]) => unknown>;
|
|
type StartPendingSubmissionSpy = Mock<InteractiveModeContext["startPendingSubmission"]>;
|
|
type FakeEditor = {
|
|
onEscape?: () => void;
|
|
onSubmit?: (text: string) => Promise<void>;
|
|
onClear?: () => void;
|
|
onExit?: () => void;
|
|
onSuspend?: () => void;
|
|
onCycleThinkingLevel?: () => void;
|
|
onCycleModelForward?: () => void;
|
|
onCycleModelBackward?: () => void;
|
|
onSelectModelTemporary?: () => void;
|
|
onSelectModel?: () => void;
|
|
onHistorySearch?: () => void;
|
|
onPasteImage?: () => void;
|
|
onCopyPrompt?: () => void;
|
|
onExpandTools?: () => void;
|
|
onToggleThinking?: () => void;
|
|
onExternalEditor?: () => void;
|
|
onDequeue?: () => void;
|
|
onChange?: (text: string) => void;
|
|
setText(text: string): void;
|
|
getText(): string;
|
|
addToHistory(text: string): void;
|
|
setActionKeys(action: string, keys: string[]): void;
|
|
setCustomKeyHandler(key: string, handler: () => void): void;
|
|
clearCustomKeyHandlers(): void;
|
|
};
|
|
|
|
function createSubmission(input: {
|
|
text: string;
|
|
images?: InteractiveModeContext["pendingImages"];
|
|
imageLinks?: InteractiveModeContext["pendingImageLinks"];
|
|
}): SubmittedUserInput {
|
|
return {
|
|
text: input.text,
|
|
images: input.images,
|
|
imageLinks: input.imageLinks,
|
|
cancelled: false,
|
|
started: false,
|
|
};
|
|
}
|
|
|
|
function createContext(): {
|
|
ctx: InteractiveModeContext;
|
|
editor: FakeEditor;
|
|
spies: {
|
|
abort: Spy;
|
|
abortBash: Spy;
|
|
abortEval: Spy;
|
|
addMessageToChat: Spy;
|
|
cancelPendingSubmission: Spy;
|
|
clearQueue: Spy;
|
|
ensureLoadingAnimation: Spy;
|
|
handleBtwCommand: Spy;
|
|
handleBtwEscape: Spy;
|
|
handleOmfgEscape: Spy;
|
|
hasActiveBtw: Spy;
|
|
hasActiveOmfg: Spy;
|
|
onInputCallback: Spy;
|
|
prompt: Spy;
|
|
requestRender: Spy;
|
|
startPendingSubmission: StartPendingSubmissionSpy;
|
|
};
|
|
} {
|
|
let editorText = "";
|
|
const abort = vi.fn();
|
|
const abortBash = vi.fn();
|
|
const abortEval = vi.fn();
|
|
const addMessageToChat = vi.fn();
|
|
const cancelPendingSubmission = vi.fn(() => false);
|
|
const clearQueue = vi.fn(() => ({ steering: [], followUp: [] }));
|
|
const onInputCallback = vi.fn();
|
|
const prompt = vi.fn();
|
|
const requestRender = vi.fn();
|
|
const handleBtwCommand = vi.fn(async () => {});
|
|
const handleBtwEscape = vi.fn(() => true);
|
|
const hasActiveBtw = vi.fn(() => false);
|
|
const handleOmfgEscape = vi.fn(() => true);
|
|
const hasActiveOmfg = vi.fn(() => false);
|
|
const startPendingSubmission = vi.fn(
|
|
(input: {
|
|
text: string;
|
|
images?: InteractiveModeContext["pendingImages"];
|
|
imageLinks?: InteractiveModeContext["pendingImageLinks"];
|
|
}) => {
|
|
ensureLoadingAnimation();
|
|
return createSubmission(input);
|
|
},
|
|
);
|
|
const editor: FakeEditor = {
|
|
setText(text: string) {
|
|
editorText = text;
|
|
},
|
|
getText() {
|
|
return editorText;
|
|
},
|
|
addToHistory: vi.fn(),
|
|
setActionKeys: vi.fn(),
|
|
setCustomKeyHandler: vi.fn(),
|
|
clearCustomKeyHandlers: vi.fn(),
|
|
};
|
|
|
|
let ctx!: InteractiveModeContext;
|
|
const ensureLoadingAnimation = vi.fn(() => {
|
|
ctx.loadingAnimation = {} as InteractiveModeContext["loadingAnimation"];
|
|
});
|
|
|
|
ctx = {
|
|
editor: editor as unknown as InteractiveModeContext["editor"],
|
|
ui: {
|
|
requestRender,
|
|
addInputListener: vi.fn(),
|
|
addStartListener: vi.fn(),
|
|
} as unknown as InteractiveModeContext["ui"],
|
|
loadingAnimation: undefined,
|
|
autoCompactionLoader: undefined,
|
|
retryLoader: undefined,
|
|
autoCompactionEscapeHandler: undefined,
|
|
retryEscapeHandler: undefined,
|
|
session: {
|
|
isStreaming: false,
|
|
isCompacting: false,
|
|
isGeneratingHandoff: false,
|
|
isBashRunning: false,
|
|
isEvalRunning: false,
|
|
queuedMessageCount: 0,
|
|
messages: [],
|
|
extensionRunner: undefined,
|
|
abort,
|
|
abortBash,
|
|
abortEval,
|
|
clearQueue,
|
|
prompt,
|
|
} as unknown as InteractiveModeContext["session"],
|
|
sessionManager: {
|
|
getSessionName: () => "existing session",
|
|
} as unknown as InteractiveModeContext["sessionManager"],
|
|
keybindings: {
|
|
getKeys: () => [],
|
|
} as unknown as InteractiveModeContext["keybindings"],
|
|
pendingImages: [],
|
|
pendingImageLinks: [],
|
|
isBashMode: false,
|
|
isPythonMode: false,
|
|
optimisticUserMessageSignature: undefined,
|
|
locallySubmittedUserSignatures: new Set<string>(),
|
|
onInputCallback,
|
|
addMessageToChat,
|
|
cancelPendingSubmission,
|
|
ensureLoadingAnimation,
|
|
notifyInterrupting: vi.fn(),
|
|
finishPendingSubmission: vi.fn(),
|
|
flushPendingBashComponents: vi.fn(),
|
|
markPendingSubmissionStarted: vi.fn(() => true),
|
|
startPendingSubmission,
|
|
updatePendingMessagesDisplay: vi.fn(),
|
|
updateEditorBorderColor: vi.fn(),
|
|
showDebugSelector: vi.fn(),
|
|
toggleTodoExpansion: vi.fn(),
|
|
handleHotkeysCommand: vi.fn(),
|
|
handleSTTToggle: vi.fn(),
|
|
handleBtwEscape,
|
|
handleBtwCommand,
|
|
hasActiveBtw,
|
|
handleOmfgEscape,
|
|
hasActiveOmfg,
|
|
showTreeSelector: vi.fn(),
|
|
showUserMessageSelector: vi.fn(),
|
|
showSessionSelector: vi.fn(),
|
|
} as unknown as InteractiveModeContext;
|
|
|
|
return {
|
|
ctx,
|
|
editor,
|
|
spies: {
|
|
abort,
|
|
abortBash,
|
|
abortEval,
|
|
addMessageToChat,
|
|
cancelPendingSubmission,
|
|
clearQueue,
|
|
ensureLoadingAnimation,
|
|
handleBtwCommand,
|
|
handleBtwEscape,
|
|
hasActiveBtw,
|
|
handleOmfgEscape,
|
|
hasActiveOmfg,
|
|
onInputCallback,
|
|
prompt,
|
|
requestRender,
|
|
startPendingSubmission,
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("InputController escape behavior", () => {
|
|
it("prefers canceling a pending optimistic submission before aborting the session", async () => {
|
|
const { ctx, editor, spies } = createContext();
|
|
const submission = createSubmission({ text: "hello" });
|
|
spies.startPendingSubmission.mockReturnValue(submission);
|
|
spies.cancelPendingSubmission.mockReturnValue(true);
|
|
ctx.loadingAnimation = {} as InteractiveModeContext["loadingAnimation"];
|
|
const controller = new InputController(ctx);
|
|
|
|
controller.setupKeyHandlers();
|
|
controller.setupEditorSubmitHandler();
|
|
await editor.onSubmit?.("hello");
|
|
|
|
expect(spies.startPendingSubmission).toHaveBeenCalledWith({
|
|
text: "hello",
|
|
images: undefined,
|
|
imageLinks: undefined,
|
|
});
|
|
expect(spies.onInputCallback).toHaveBeenCalledWith(submission);
|
|
|
|
editor.onEscape?.();
|
|
expect(spies.cancelPendingSubmission).toHaveBeenCalledTimes(1);
|
|
expect(spies.clearQueue).not.toHaveBeenCalled();
|
|
expect(spies.abort).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("runs /btw as a builtin side request instead of steering the active stream", async () => {
|
|
const { ctx, editor, spies } = createContext();
|
|
(ctx.session as { isStreaming: boolean }).isStreaming = true;
|
|
const controller = new InputController(ctx);
|
|
|
|
controller.setupEditorSubmitHandler();
|
|
editor.setText("/btw why is it doing that?");
|
|
await editor.onSubmit?.("/btw why is it doing that?");
|
|
|
|
expect(spies.handleBtwCommand).toHaveBeenCalledWith("why is it doing that?");
|
|
expect(spies.prompt).not.toHaveBeenCalled();
|
|
expect(editor.addToHistory).not.toHaveBeenCalled();
|
|
expect(editor.getText()).toBe("");
|
|
});
|
|
|
|
it("falls back to aborting the active session when no pending optimistic submission exists", () => {
|
|
const { ctx, editor, spies } = createContext();
|
|
ctx.loadingAnimation = {} as InteractiveModeContext["loadingAnimation"];
|
|
const controller = new InputController(ctx);
|
|
|
|
controller.setupKeyHandlers();
|
|
editor.onEscape?.();
|
|
|
|
expect(spies.cancelPendingSubmission).toHaveBeenCalledTimes(1);
|
|
expect(spies.clearQueue).toHaveBeenCalledTimes(1);
|
|
expect(spies.abort).toHaveBeenCalledTimes(1);
|
|
// The Esc interrupt threads a user-facing reason so the aborted turn and its
|
|
// synthetic tool results read as a deliberate interrupt, not "Request was aborted".
|
|
expect(spies.abort).toHaveBeenCalledWith({ reason: USER_INTERRUPT_LABEL });
|
|
});
|
|
|
|
it("prefers aborting bash before aborting an overlapping stream", () => {
|
|
const { ctx, editor, spies } = createContext();
|
|
(ctx.session as { isStreaming: boolean; isBashRunning: boolean }).isStreaming = true;
|
|
(ctx.session as { isStreaming: boolean; isBashRunning: boolean }).isBashRunning = true;
|
|
const controller = new InputController(ctx);
|
|
|
|
controller.setupKeyHandlers();
|
|
editor.onEscape?.();
|
|
|
|
expect(spies.abortBash).toHaveBeenCalledTimes(1);
|
|
expect(spies.abort).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("prefers aborting python before aborting an overlapping stream", () => {
|
|
const { ctx, editor, spies } = createContext();
|
|
(ctx.session as { isStreaming: boolean; isEvalRunning: boolean }).isStreaming = true;
|
|
(ctx.session as { isStreaming: boolean; isEvalRunning: boolean }).isEvalRunning = true;
|
|
const controller = new InputController(ctx);
|
|
|
|
controller.setupKeyHandlers();
|
|
editor.onEscape?.();
|
|
|
|
expect(spies.abortEval).toHaveBeenCalledTimes(1);
|
|
expect(spies.abort).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("dismisses an active /btw panel before aborting the main stream", () => {
|
|
const { ctx, editor, spies } = createContext();
|
|
(ctx.session as { isStreaming: boolean }).isStreaming = true;
|
|
spies.hasActiveBtw.mockReturnValue(true);
|
|
const controller = new InputController(ctx);
|
|
|
|
controller.setupKeyHandlers();
|
|
editor.onEscape?.();
|
|
|
|
expect(spies.handleBtwEscape).toHaveBeenCalledTimes(1);
|
|
expect(spies.abort).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("dismisses an active /btw panel before canceling a pending optimistic submission", () => {
|
|
const { ctx, editor, spies } = createContext();
|
|
ctx.loadingAnimation = {} as InteractiveModeContext["loadingAnimation"];
|
|
spies.hasActiveBtw.mockReturnValue(true);
|
|
const controller = new InputController(ctx);
|
|
|
|
controller.setupKeyHandlers();
|
|
editor.onEscape?.();
|
|
|
|
expect(spies.handleBtwEscape).toHaveBeenCalledTimes(1);
|
|
expect(spies.cancelPendingSubmission).not.toHaveBeenCalled();
|
|
expect(spies.clearQueue).not.toHaveBeenCalled();
|
|
expect(spies.abort).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("dismisses an active /btw panel before aborting bash", () => {
|
|
const { ctx, editor, spies } = createContext();
|
|
(ctx.session as { isBashRunning: boolean }).isBashRunning = true;
|
|
spies.hasActiveBtw.mockReturnValue(true);
|
|
const controller = new InputController(ctx);
|
|
|
|
controller.setupKeyHandlers();
|
|
editor.onEscape?.();
|
|
|
|
expect(spies.handleBtwEscape).toHaveBeenCalledTimes(1);
|
|
expect(spies.abortBash).not.toHaveBeenCalled();
|
|
expect(spies.abort).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("aborts streaming even when the working loader is no longer present", () => {
|
|
const { ctx, editor, spies } = createContext();
|
|
(ctx.session as { isStreaming: boolean }).isStreaming = true;
|
|
const controller = new InputController(ctx);
|
|
|
|
controller.setupKeyHandlers();
|
|
editor.onEscape?.();
|
|
|
|
expect(spies.cancelPendingSubmission).not.toHaveBeenCalled();
|
|
expect(spies.clearQueue).not.toHaveBeenCalled();
|
|
expect(spies.abort).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|