Files
oh-my-pi/packages/coding-agent/test/input-controller-skill-queue.test.ts
T
can1357 1e3909a151 fix(coding-agent/session): split queued-message editor restore between dequeue and interrupt
- Generalized `isUserQueuedMessage` to a user-attribution predicate (`role === "user"` or custom `attribution === "user"` and not display-suppressed) so visible agent-authored steers (advisor cards, IRC/extension asides) and hidden goal/plan/budget steers are all excluded from editor restore, not just advisor cards.
- Gave `AgentSession.clearQueue` a `{ forInterrupt }` option: plain Alt+Up dequeue restores user messages and preserves every other queued message for the continuing stream, while Esc+abort keeps only advisor cards (for `abort()`'s `#extractQueuedAdvisorCards` preservation) and drops other internal steers so the post-abort `#drainStrandedQueuedMessages` can't auto-resume the interrupted run.
- Threaded `forInterrupt: options?.abort` from `InputController.restoreQueuedMessagesToEditor` and kept `queuedMessageCount` on actual displayable-queue semantics so `hasPendingMessages()`/RPC and the empty-submit abort gate stay accurate.
- Updated skill-queue tests to cover both policies (hidden and visible agent-authored steers preserved on dequeue, dropped on interrupt) and refreshed the changelog entry.
2026-06-16 16:27:53 +02:00

531 lines
18 KiB
TypeScript

/**
* Skill/custom queued-message display contracts.
*
* Custom queued chips now ride on the queued AgentMessage itself via
* details.__queueChipText. The session derives pending display directly from
* the agent-core queue; there is no separate display mirror to splice.
*/
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "bun:test";
import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { EventController } from "@oh-my-pi/pi-coding-agent/modes/controllers/event-controller";
import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller";
import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers";
import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { Container } from "@oh-my-pi/pi-tui";
import { TempDir } from "@oh-my-pi/pi-utils";
type StubEditor = {
setText: (text: string) => void;
getText: () => string;
addToHistory: Mock<(...args: unknown[]) => unknown>;
onSubmit?: (text: string) => Promise<void>;
};
type PromptCustomMessage = Mock<
(
message: { details: SkillPromptDetails },
options?: { streamingBehavior?: "steer" | "followUp"; queueChipText?: string },
) => Promise<void>
>;
async function writeSkillFile(dir: string, skillName: string, body: string): Promise<string> {
const skillPath = path.join(dir, `${skillName}.md`);
await Bun.write(skillPath, `---\nname: ${skillName}\n---\n${body}\n`);
return skillPath;
}
function createStubInputControllerContext(opts: { skillCommands: Map<string, string>; isStreaming: boolean }) {
let editorText = "";
const editor: StubEditor = {
setText(text) {
editorText = text;
},
getText() {
return editorText;
},
addToHistory: vi.fn(),
};
const promptCustomMessage: PromptCustomMessage = vi.fn(async () => {});
const prompt = vi.fn(async (_text: string, _options?: unknown) => {});
const handleGoalModeCommand = vi.fn(async (_rest?: string) => {});
const updatePendingMessagesDisplay = vi.fn();
const requestRender = vi.fn();
const showError = vi.fn();
const ctx = {
editor,
ui: { requestRender },
skillCommands: opts.skillCommands,
session: {
isStreaming: opts.isStreaming,
isCompacting: false,
isBashRunning: false,
isEvalRunning: false,
extensionRunner: undefined,
prompt,
promptCustomMessage,
},
get viewSession() {
return (this as typeof ctx).session;
},
showError,
handleGoalModeCommand,
goalModeEnabled: false,
updatePendingMessagesDisplay,
isBashMode: false,
isPythonMode: false,
pendingImages: [],
pendingImageLinks: [],
loopModeEnabled: false,
compactionQueuedMessages: [],
locallySubmittedUserSignatures: new Set<string>(),
withLocalSubmission: async (_text: string, fn: () => unknown) => fn(),
} as unknown as InteractiveModeContext;
return {
ctx,
editor,
prompt,
promptCustomMessage,
handleGoalModeCommand,
updatePendingMessagesDisplay,
requestRender,
};
}
describe("InputController skill queue chip metadata", () => {
let tempDir: TempDir;
let skillCommands: Map<string, string>;
beforeEach(async () => {
tempDir = TempDir.createSync("@pi-skill-queue-stub-");
const skillPath = await writeSkillFile(tempDir.path(), "test-skill", "Do the thing.");
skillCommands = new Map<string, string>([["skill:test-skill", skillPath]]);
});
afterEach(() => {
tempDir.removeSync();
vi.restoreAllMocks();
});
it("passes slash-form queueChipText for streaming skill steers", async () => {
const { ctx, editor, promptCustomMessage, updatePendingMessagesDisplay, requestRender } =
createStubInputControllerContext({ skillCommands, isStreaming: true });
const controller = new InputController(ctx);
controller.setupEditorSubmitHandler();
editor.setText("/skill:test-skill arg1 arg2");
await editor.onSubmit?.("/skill:test-skill arg1 arg2");
expect(promptCustomMessage).toHaveBeenCalledTimes(1);
expect(promptCustomMessage.mock.calls[0]?.[1]).toEqual({
streamingBehavior: "steer",
queueChipText: "/skill:test-skill arg1 arg2",
});
expect(promptCustomMessage.mock.calls[0]?.[0].details.__queueChipText).toBeUndefined();
expect(updatePendingMessagesDisplay).toHaveBeenCalledTimes(1);
expect(requestRender).toHaveBeenCalledTimes(1);
});
it("passes slash-form queueChipText for streaming skill follow-ups", async () => {
const { ctx, editor, promptCustomMessage } = createStubInputControllerContext({
skillCommands,
isStreaming: true,
});
const controller = new InputController(ctx);
editor.setText("/skill:test-skill arg1 arg2");
await controller.handleFollowUp();
expect(promptCustomMessage.mock.calls[0]?.[1]).toEqual({
streamingBehavior: "followUp",
queueChipText: "/skill:test-skill arg1 arg2",
});
});
it("streaming follow-up applies builtin slash commands instead of queueing them", async () => {
const { ctx, editor, prompt, handleGoalModeCommand } = createStubInputControllerContext({
skillCommands,
isStreaming: true,
});
const controller = new InputController(ctx);
editor.setText("/goal set Ship the release");
await controller.handleFollowUp();
expect(handleGoalModeCommand).toHaveBeenCalledWith("set Ship the release");
expect(prompt).not.toHaveBeenCalled();
expect(editor.getText()).toBe("");
});
it("idle skill prompt still leaves queueChipText out of persisted details", async () => {
const { ctx, editor, promptCustomMessage } = createStubInputControllerContext({
skillCommands,
isStreaming: false,
});
const controller = new InputController(ctx);
controller.setupEditorSubmitHandler();
editor.setText("/skill:test-skill arg1 arg2");
await editor.onSubmit?.("/skill:test-skill arg1 arg2");
expect(promptCustomMessage.mock.calls[0]?.[1]).toEqual({
streamingBehavior: "steer",
queueChipText: "/skill:test-skill arg1 arg2",
});
expect(promptCustomMessage.mock.calls[0]?.[0].details.__queueChipText).toBeUndefined();
});
});
interface SessionFixture {
tempDir: TempDir;
authStorage: AuthStorage;
session: AgentSession;
}
async function createRealSession(): Promise<SessionFixture> {
const tempDir = TempDir.createSync("@pi-skill-queue-real-");
const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage);
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected built-in anthropic model to exist");
const agent = new Agent({
initialState: {
model,
systemPrompt: ["Test"],
tools: [],
messages: [],
},
});
const session = new AgentSession({
agent,
sessionManager: SessionManager.inMemory(),
settings: Settings.isolated(),
modelRegistry,
});
return { tempDir, authStorage, session };
}
function queueCustomSteer(session: AgentSession, chip: string, content = "skill body"): void {
session.agent.steer({
role: "custom",
customType: SKILL_PROMPT_MESSAGE_TYPE,
content,
display: true,
attribution: "user",
details: {
name: "foo",
path: "/s.md",
args: "bar",
lineCount: 1,
__queueChipText: chip,
} satisfies SkillPromptDetails,
timestamp: Date.now(),
});
}
function queueAdvisorSteer(session: AgentSession, note = "consider X"): void {
session.agent.steer({
role: "custom",
customType: "advisor",
content: `Advisor:\n- [blocker] ${note}`,
display: true,
attribution: "agent",
details: { notes: [{ note, severity: "blocker" }] },
timestamp: Date.now(),
});
}
describe("AgentSession derived queued custom display", () => {
let fixture: SessionFixture | undefined;
afterEach(async () => {
if (fixture) {
await fixture.session.dispose();
fixture.authStorage.close();
fixture.tempDir.removeSync();
fixture = undefined;
}
vi.restoreAllMocks();
});
it("derives queued custom chip text directly from the agent steering queue", async () => {
fixture = await createRealSession();
const { session } = fixture;
queueCustomSteer(session, "/skill:foo bar");
expect(session.getQueuedMessages().steering).toEqual(["/skill:foo bar"]);
expect(session.queuedMessageCount).toBe(1);
});
it("excludes display-suppressed custom messages from chips/count and never restores them", async () => {
fixture = await createRealSession();
const { session } = fixture;
session.agent.steer({
role: "custom",
customType: "internal",
content: "hidden",
display: false,
details: { __queueChipText: "hidden" },
timestamp: Date.now(),
});
expect(session.getQueuedMessages().steering).toEqual([]);
expect(session.queuedMessageCount).toBe(0);
// Plain Alt+Up dequeue restores nothing AND preserves the hidden steer for the
// continuing stream — it isn't the user's draft.
expect(session.clearQueue().steering).toEqual([]);
expect(session.agent.hasQueuedMessages()).toBe(true);
// Esc+abort drops it so abort()'s stranded-message drain can't auto-resume the
// run the user just interrupted (the drain gate is agent.hasQueuedMessages()).
expect(session.clearQueue({ forInterrupt: true }).steering).toEqual([]);
expect(session.agent.hasQueuedMessages()).toBe(false);
});
it("never restores a visible agent-authored custom steer; preserves on dequeue, drops on interrupt", async () => {
fixture = await createRealSession();
const { session } = fixture;
// An IRC aside / extension/hook notice: visible, but agent-authored — editing it
// makes no sense, so it must not ride the Esc/Alt+Up editor-restore path.
const steer = () =>
session.agent.steer({
role: "custom",
customType: "irc",
content: "peer pinged you",
display: true,
attribution: "agent",
details: {},
timestamp: Date.now(),
});
steer();
expect(session.getQueuedMessages().steering).toEqual([]);
// popLast leaves the agent steer untouched (not user-restorable)...
expect(session.popLastQueuedMessage()).toBeUndefined();
expect(session.agent.peekSteeringQueue()).toHaveLength(1);
// ...plain dequeue restores nothing but PRESERVES the extension steer (not lost)...
expect(session.clearQueue().steering).toEqual([]);
expect(session.agent.peekSteeringQueue()).toHaveLength(1);
// ...and only Esc+abort drops it (no auto-resume leftover).
expect(session.clearQueue({ forInterrupt: true }).steering).toEqual([]);
expect(session.agent.hasQueuedMessages()).toBe(false);
});
it("popLastQueuedMessage restores chip text and removes the core queue entry", async () => {
fixture = await createRealSession();
const { session } = fixture;
queueCustomSteer(session, "/skill:foo bar");
expect(session.popLastQueuedMessage()?.text).toBe("/skill:foo bar");
expect(session.getQueuedMessages().steering).toEqual([]);
});
it("counts a queued advisor card as pending work but keeps it out of chips and restore", async () => {
fixture = await createRealSession();
const { session } = fixture;
queueAdvisorSteer(session, "guard the null path");
// Advisor cards are real pending work (feeds hasPendingMessages/empty-Enter abort)...
expect(session.queuedMessageCount).toBe(1);
// ...but are never editable user input.
expect(session.getQueuedMessages().steering).toEqual([]);
// clearQueue must not surface the advisor note for editor restore, and must
// leave the card queued so the abort/resume path still delivers it.
const cleared = session.clearQueue();
expect(cleared.steering).toEqual([]);
expect(cleared.followUp).toEqual([]);
expect(session.agent.peekSteeringQueue()).toHaveLength(1);
expect(session.popLastQueuedMessage()).toBeUndefined();
});
it("clearQueue restores user messages but preserves a queued advisor card", async () => {
fixture = await createRealSession();
const { session } = fixture;
queueCustomSteer(session, "/skill:foo bar");
queueAdvisorSteer(session, "rename the symbol");
const cleared = session.clearQueue();
expect(cleared.steering).toEqual([{ text: "/skill:foo bar", images: undefined }]);
// The advisor card survives in the agent-core queue; the user's message left.
const remaining = session.agent.peekSteeringQueue();
expect(remaining).toHaveLength(1);
expect(remaining[0]).toMatchObject({ customType: "advisor" });
});
it("popLastQueuedMessage steps over an advisor card to the user message", async () => {
fixture = await createRealSession();
const { session } = fixture;
queueCustomSteer(session, "/skill:foo bar");
queueAdvisorSteer(session, "watch the race");
expect(session.popLastQueuedMessage()?.text).toBe("/skill:foo bar");
// Advisor card remains queued, not restored.
const remaining = session.agent.peekSteeringQueue();
expect(remaining).toHaveLength(1);
expect(remaining[0]).toMatchObject({ customType: "advisor" });
});
});
function createStubInteractiveModeContextForUiHelpers(session: AgentSession) {
let editorText = "";
const editor: StubEditor = {
setText(text) {
editorText = text;
},
getText() {
return editorText;
},
addToHistory: vi.fn(),
};
const pendingMessagesContainer = new Container();
const requestRender = vi.fn();
const updatePendingMessagesDisplay = vi.fn();
const ctx = {
editor,
ui: { requestRender },
pendingMessagesContainer,
session,
viewSession: session,
compactionQueuedMessages: [],
keybindings: {
getDisplayString: (_action: string) => "Alt+Up",
},
updatePendingMessagesDisplay,
locallySubmittedUserSignatures: new Set<string>(),
} as unknown as InteractiveModeContext;
return { ctx, editor, pendingMessagesContainer };
}
describe("UiHelpers / InputController against derived queued custom display", () => {
let fixture: SessionFixture | undefined;
beforeEach(async () => {
const themeInstance = await getThemeByName("dark");
expect(themeInstance).toBeDefined();
setThemeInstance(themeInstance!);
});
afterEach(async () => {
if (fixture) {
await fixture.session.dispose();
fixture.authStorage.close();
fixture.tempDir.removeSync();
fixture = undefined;
}
vi.restoreAllMocks();
});
it("renders the compact slash form for queued skills", async () => {
fixture = await createRealSession();
const { session } = fixture;
queueCustomSteer(session, "/skill:test-skill arg1 arg2");
const { ctx, pendingMessagesContainer } = createStubInteractiveModeContextForUiHelpers(session);
const uiHelpers = new UiHelpers(ctx);
uiHelpers.updatePendingMessagesDisplay();
const rendered = pendingMessagesContainer.render(120).join("\n");
expect(rendered).toMatch(/Steer: \/skill:test-skill arg1 arg2/);
});
it("restores the compact slash form into the editor and clears the queue", async () => {
fixture = await createRealSession();
const { session } = fixture;
queueCustomSteer(session, "/skill:test-skill arg1 arg2");
const { ctx, editor } = createStubInteractiveModeContextForUiHelpers(session);
const controller = new InputController(ctx);
const count = controller.restoreQueuedMessagesToEditor();
expect(count).toBe(1);
expect(editor.getText()).toBe("/skill:test-skill arg1 arg2");
expect(session.getQueuedMessages()).toEqual({ steering: [], followUp: [] });
});
});
function createEventControllerFixture() {
const updatePendingMessagesDisplay = vi.fn();
const addMessageToChat = vi.fn();
const requestRender = vi.fn();
const ctx = {
isInitialized: true,
init: vi.fn(async () => {}),
ui: { requestRender },
statusLine: { invalidate: vi.fn() },
updateEditorTopBorder: vi.fn(),
addMessageToChat,
updatePendingMessagesDisplay,
pendingTools: new Map(),
session: {},
get viewSession() {
return (this as typeof ctx).session;
},
} as unknown as InteractiveModeContext;
const controller = new EventController(ctx);
return { controller, updatePendingMessagesDisplay, addMessageToChat };
}
describe("EventController custom queued-message refresh", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("refreshes the pending bar only for custom messages carrying __queueChipText", async () => {
const { controller, updatePendingMessagesDisplay, addMessageToChat } = createEventControllerFixture();
const queuedEvent: Extract<AgentSessionEvent, { type: "message_start" }> = {
type: "message_start",
message: {
role: "custom",
customType: SKILL_PROMPT_MESSAGE_TYPE,
content: "first",
display: true,
details: {
__queueChipText: "/skill:foo bar",
name: "foo",
path: "/s.md",
args: "bar",
lineCount: 1,
} satisfies SkillPromptDetails,
timestamp: Date.now(),
},
};
await controller.handleEvent(queuedEvent);
expect(updatePendingMessagesDisplay).toHaveBeenCalledTimes(1);
expect(addMessageToChat).toHaveBeenCalledTimes(1);
const unqueuedEvent: Extract<AgentSessionEvent, { type: "message_start" }> = {
type: "message_start",
message: {
role: "custom",
customType: SKILL_PROMPT_MESSAGE_TYPE,
content: "second",
display: true,
details: undefined,
timestamp: Date.now() + 1,
},
};
await controller.handleEvent(unqueuedEvent);
expect(updatePendingMessagesDisplay).toHaveBeenCalledTimes(1);
expect(addMessageToChat).toHaveBeenCalledTimes(2);
});
});