- Added optional `hasSteeringMessages` config hook and limited steering checks to boundaries. - Fixed interrupted tool-batch steering by keeping queued messages until boundary handling. - Added idle text and image submissions to steer queueing when no input waiter exists. - Auto-continued resumable sessions after queued steering and preserved submit metadata.
156 lines
5.5 KiB
TypeScript
156 lines
5.5 KiB
TypeScript
import { describe, expect, it, vi } from "bun:test";
|
|
import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller";
|
|
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
|
|
|
|
/**
|
|
* Regression: a submission arriving while the main loop has no input waiter
|
|
* (`onInputCallback === undefined` — post-turn epilogue, retry backoff, or a
|
|
* scheduled continue) and the session is neither streaming nor compacting used
|
|
* to fall through every branch of the submit ladder. The editor clears itself
|
|
* on Enter, so the message vanished without a trace (no queue entry, no error,
|
|
* no transcript message).
|
|
*
|
|
* Contract: such a submission must be queued as a steer (the session's idle
|
|
* drain / next run start delivers it) and recorded as a local submission so
|
|
* its eventual delivery does not clobber the editor.
|
|
*/
|
|
|
|
type FakeEditor = {
|
|
onSubmit?: (text: string) => Promise<void>;
|
|
imageLinks?: readonly (string | undefined)[];
|
|
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 createContext() {
|
|
let editorText = "";
|
|
const steer = vi.fn(async (_text: string, _images?: unknown) => {});
|
|
const prompt = vi.fn(async () => {});
|
|
const updatePendingMessagesDisplay = vi.fn();
|
|
const requestRender = vi.fn();
|
|
const showError = vi.fn();
|
|
const addToHistory = vi.fn();
|
|
const flushPendingBashComponents = vi.fn();
|
|
|
|
const editor: FakeEditor = {
|
|
setText(text: string) {
|
|
editorText = text;
|
|
},
|
|
getText() {
|
|
return editorText;
|
|
},
|
|
addToHistory,
|
|
setActionKeys: vi.fn(),
|
|
setCustomKeyHandler: vi.fn(),
|
|
clearCustomKeyHandlers: vi.fn(),
|
|
};
|
|
|
|
const ctx = {
|
|
editor: editor as unknown as InteractiveModeContext["editor"],
|
|
ui: { requestRender } as unknown as InteractiveModeContext["ui"],
|
|
session: {
|
|
isStreaming: false,
|
|
isCompacting: false,
|
|
isBashRunning: false,
|
|
isEvalRunning: false,
|
|
extensionRunner: undefined,
|
|
steer,
|
|
prompt,
|
|
queuedMessageCount: 0,
|
|
getQueuedMessages: () => ({ steering: [], followUp: [] }),
|
|
} as unknown as InteractiveModeContext["session"],
|
|
sessionManager: { getSessionName: () => "named-session" } as InteractiveModeContext["sessionManager"],
|
|
pendingImages: [] as InteractiveModeContext["pendingImages"],
|
|
pendingImageLinks: [] as InteractiveModeContext["pendingImageLinks"],
|
|
fileSlashCommands: new Set<string>(),
|
|
locallySubmittedUserSignatures: new Set<string>(),
|
|
isKnownSlashCommand: () => false,
|
|
recordLocalSubmission(this: InteractiveModeContext, text: string, imageCount = 0) {
|
|
const sig = `${text}\u0000${imageCount}`;
|
|
this.locallySubmittedUserSignatures.add(sig);
|
|
return () => {
|
|
this.locallySubmittedUserSignatures.delete(sig);
|
|
};
|
|
},
|
|
async withLocalSubmission<T>(
|
|
this: InteractiveModeContext,
|
|
text: string,
|
|
fn: () => Promise<T>,
|
|
options?: { imageCount?: number },
|
|
): Promise<T> {
|
|
const dispose = this.recordLocalSubmission(text, options?.imageCount ?? 0);
|
|
try {
|
|
return await fn();
|
|
} catch (err) {
|
|
dispose();
|
|
throw err;
|
|
}
|
|
},
|
|
// No input waiter: the state under test.
|
|
onInputCallback: undefined,
|
|
updatePendingMessagesDisplay,
|
|
flushPendingBashComponents,
|
|
showError,
|
|
isBashMode: false,
|
|
isPythonMode: false,
|
|
} as unknown as InteractiveModeContext;
|
|
|
|
return {
|
|
ctx,
|
|
editor,
|
|
spies: { steer, prompt, updatePendingMessagesDisplay, requestRender, showError, addToHistory },
|
|
};
|
|
}
|
|
|
|
describe("InputController orphaned submit", () => {
|
|
it("queues an idle submit with no input waiter as a steer instead of dropping it", async () => {
|
|
const { ctx, editor, spies } = createContext();
|
|
const controller = new InputController(ctx);
|
|
controller.setupEditorSubmitHandler();
|
|
|
|
await editor.onSubmit?.("do not lose me");
|
|
|
|
expect(spies.steer).toHaveBeenCalledWith("do not lose me", undefined);
|
|
expect(spies.prompt).not.toHaveBeenCalled();
|
|
// Delivery protection: the queued message is marked as locally submitted.
|
|
expect(ctx.locallySubmittedUserSignatures.has("do not lose me\u00000")).toBe(true);
|
|
// The queue chip becomes visible right away.
|
|
expect(spies.updatePendingMessagesDisplay).toHaveBeenCalled();
|
|
expect(spies.requestRender).toHaveBeenCalled();
|
|
expect(spies.addToHistory).toHaveBeenCalledWith("do not lose me");
|
|
});
|
|
|
|
it("forwards pending images and counts them in the local-submission signature", async () => {
|
|
const { ctx, editor, spies } = createContext();
|
|
const image = { type: "image", data: "abc", mimeType: "image/png" };
|
|
(ctx.pendingImages as unknown[]).push(image);
|
|
const controller = new InputController(ctx);
|
|
controller.setupEditorSubmitHandler();
|
|
|
|
await editor.onSubmit?.("look at this");
|
|
|
|
expect(spies.steer).toHaveBeenCalledWith("look at this", [image]);
|
|
expect(ctx.locallySubmittedUserSignatures.has("look at this\u00001")).toBe(true);
|
|
expect(ctx.pendingImages.length).toBe(0);
|
|
});
|
|
|
|
it("surfaces a steer rejection instead of failing silently", async () => {
|
|
const { ctx, editor, spies } = createContext();
|
|
spies.steer.mockImplementationOnce(async () => {
|
|
throw new Error("queue exploded");
|
|
});
|
|
const controller = new InputController(ctx);
|
|
controller.setupEditorSubmitHandler();
|
|
|
|
await editor.onSubmit?.("doomed message");
|
|
|
|
expect(spies.showError).toHaveBeenCalledWith("queue exploded");
|
|
// The signature must not leak for a message that never queued.
|
|
expect(ctx.locallySubmittedUserSignatures.has("doomed message\u00000")).toBe(false);
|
|
});
|
|
});
|