/** * 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 type { ImageContent, TextContent } from "@oh-my-pi/pi-ai"; 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 type { Skill } from "@oh-my-pi/pi-coding-agent/extensibility/skills"; 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 { CompactionQueuedMessage, 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; getExpandedText: () => string; clearDraft: (historyText?: string) => void; addToHistory: Mock<(...args: unknown[]) => unknown>; onSubmit?: (text: string) => Promise; pendingImages: ImageContent[]; pendingImageLinks: (string | undefined)[]; imageLinks?: (string | undefined)[]; }; type PromptCustomMessage = Mock< ( message: { customType?: string; content?: string | (TextContent | ImageContent)[]; display?: boolean; attribution?: string; details: SkillPromptDetails; }, options?: { streamingBehavior?: "steer" | "followUp"; queueChipText?: string; queueOnly?: boolean }, ) => Promise >; async function writeSkillFile(dir: string, skillName: string, body: string): Promise { const skillPath = path.join(dir, `${skillName}.md`); await Bun.write(skillPath, `---\nname: ${skillName}\n---\n${body}\n`); return { name: skillName, description: "", filePath: skillPath, baseDir: dir, source: "test" }; } function createStubInputControllerContext(opts: { skillCommands: Map; isStreaming: boolean; isCompacting?: boolean; }) { let editorText = ""; const editor: StubEditor = { setText(text) { editorText = text; }, getText() { return editorText; }, getExpandedText() { return editorText; }, clearDraft(historyText?: string) { if (historyText !== undefined) this.addToHistory(historyText); this.setText(""); this.imageLinks = undefined; this.pendingImages = []; this.pendingImageLinks = []; }, addToHistory: vi.fn(), pendingImages: [] as ImageContent[], pendingImageLinks: [] as (string | undefined)[], }; 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 queueCompactionMessage = vi.fn((_text: string, _mode: "steer" | "followUp", _images?: ImageContent[]) => {}); const ctx = { editor, ui: { requestRender }, skillCommands: opts.skillCommands, session: { isStreaming: opts.isStreaming, isCompacting: opts.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, loopModeEnabled: false, compactionQueuedMessages: [], locallySubmittedUserSignatures: new Set(), withLocalSubmission: async (_text: string, fn: () => unknown) => fn(), queueCompactionMessage, } as unknown as InteractiveModeContext; return { ctx, editor, prompt, promptCustomMessage, handleGoalModeCommand, updatePendingMessagesDisplay, requestRender, queueCompactionMessage, }; } describe("InputController skill queue chip metadata", () => { let tempDir: TempDir; let skillCommands: Map; beforeEach(async () => { tempDir = TempDir.createSync("@pi-skill-queue-stub-"); const skill = await writeSkillFile(tempDir.path(), "test-skill", "Do the thing."); skillCommands = new Map([["skill:test-skill", skill]]); }); 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("queues known skill steers during compaction instead of dispatching immediately", async () => { const { ctx, editor, promptCustomMessage, queueCompactionMessage } = createStubInputControllerContext({ skillCommands, isStreaming: false, isCompacting: true, }); const controller = new InputController(ctx); controller.setupEditorSubmitHandler(); editor.setText("/skill:test-skill arg1 arg2"); await editor.onSubmit?.("/skill:test-skill arg1 arg2"); expect(queueCompactionMessage).toHaveBeenCalledWith("/skill:test-skill arg1 arg2", "steer", undefined); expect(promptCustomMessage).not.toHaveBeenCalled(); }); 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(); }); it("routes pending images through immediate skill submit and clears the draft", async () => { const image: ImageContent = { type: "image", data: "aGVsbG8=", mimeType: "image/png" }; const { ctx, editor, promptCustomMessage } = createStubInputControllerContext({ skillCommands, isStreaming: false, }); const controller = new InputController(ctx); controller.setupEditorSubmitHandler(); editor.setText("/skill:test-skill inspect this"); editor.pendingImages = [image]; editor.pendingImageLinks = ["file:///tmp/skill-image.png"]; editor.imageLinks = editor.pendingImageLinks; await editor.onSubmit?.("/skill:test-skill inspect this"); expect(promptCustomMessage).toHaveBeenCalledTimes(1); const message = promptCustomMessage.mock.calls[0]?.[0]; if (!message || !Array.isArray(message.content)) { throw new Error("expected skill prompt to include image content blocks"); } expect(message.content[0]).toMatchObject({ type: "text", text: expect.stringContaining("Do the thing.") }); expect(message.content[1]).toEqual(image); expect(editor.getText()).toBe(""); expect(editor.pendingImages).toEqual([]); expect(editor.pendingImageLinks).toEqual([]); expect(editor.imageLinks).toBeUndefined(); }); }); describe("compaction skill re-invocation", () => { let tempDir: TempDir; let skillCommands: Map; function firstPromptCustomCall(promptCustomMessage: PromptCustomMessage) { const call = promptCustomMessage.mock.calls[0]; if (!call) { throw new Error("expected promptCustomMessage to be called"); } return call; } function createCompactionDrainContext(queuedMessages: CompactionQueuedMessage[]) { const promptCustomMessageCalled = Promise.withResolvers(); const promptCustomMessage: PromptCustomMessage = vi.fn(async () => { promptCustomMessageCalled.resolve(); }); const prompt = vi.fn(async (_text: string, _options?: { streamingBehavior?: "steer" | "followUp" }) => {}); const steer = vi.fn(async (_text: string, _images?: ImageContent[]) => {}); const followUp = vi.fn(async (_text: string, _images?: ImageContent[]) => {}); const ctx = { skillCommands, compactionQueuedMessages: queuedMessages, updatePendingMessagesDisplay: vi.fn(), showError: vi.fn(), isKnownSlashCommand: vi.fn(() => false), recordLocalSubmission: vi.fn((_text: string, _imageCount: number) => vi.fn()), withLocalSubmission: vi.fn(async (_text: string, fn: () => unknown) => Promise.resolve(fn())), session: { promptCustomMessage, prompt, steer, followUp, clearQueue: vi.fn(), }, } as unknown as InteractiveModeContext; return { ctx, promptCustomMessage, promptCustomMessageCalled, prompt, steer, followUp }; } beforeEach(async () => { tempDir = TempDir.createSync("@pi-skill-compaction-stub-"); const skill = await writeSkillFile(tempDir.path(), "test-skill", "Do the thing."); skillCommands = new Map([["skill:test-skill", skill]]); }); afterEach(() => { tempDir.removeSync(); vi.restoreAllMocks(); }); it("re-invokes a queued skill as a user-attributed skill prompt", async () => { const image: ImageContent = { type: "image", data: "aGVsbG8=", mimeType: "image/png" }; const { ctx, promptCustomMessage, promptCustomMessageCalled, prompt, steer, followUp } = createCompactionDrainContext([{ text: "/skill:test-skill arg1 arg2", mode: "followUp", images: [image] }]); const uiHelpers = new UiHelpers(ctx); await uiHelpers.flushCompactionQueue({ willRetry: false }); await promptCustomMessageCalled; const [message, options] = firstPromptCustomCall(promptCustomMessage); expect(message.customType).toBe(SKILL_PROMPT_MESSAGE_TYPE); expect(message.attribution).toBe("user"); if (!Array.isArray(message.content)) { throw new Error("expected queued skill prompt to preserve image content blocks"); } const renderedText = message.content[0]; if (renderedText?.type !== "text") { throw new Error("expected first content block to be rendered skill text"); } // Bug fix contract: a re-invoked user skill identifies itself and exposes its // skill directory so relative skill paths resolve after compaction. expect(renderedText.text).toContain("Do the thing."); expect(renderedText.text).toContain('The user has invoked the "test-skill" skill'); expect(renderedText.text).toContain(`[Skill directory: ${tempDir.path()}]`); expect(renderedText.text).toMatch(/[Rr]esolve any relative paths/); expect(renderedText.text).toContain("User: arg1 arg2"); expect(message.content[1]).toEqual(image); expect(message.details).toMatchObject({ name: "test-skill", args: "arg1 arg2", lineCount: 1 }); expect(options).toEqual({ streamingBehavior: "followUp", queueChipText: "/skill:test-skill arg1 arg2", }); expect(prompt).not.toHaveBeenCalled(); expect(steer).not.toHaveBeenCalled(); expect(followUp).not.toHaveBeenCalled(); }); it("queues retry-drained skills without appending them to session history", async () => { const fixture = await createRealSession(); try { const image: ImageContent = { type: "image", data: "cmV0cnk=", mimeType: "image/png" }; const { ctx } = createCompactionDrainContext([ { text: "/skill:test-skill retry args", mode: "followUp", images: [image] }, ]); ctx.session = fixture.session; const uiHelpers = new UiHelpers(ctx); await uiHelpers.flushCompactionQueue({ willRetry: true }); expect(fixture.session.getQueuedMessages().followUp).toEqual(["/skill:test-skill retry args"]); const queued = fixture.session.agent.peekFollowUpQueue()[0]; if (queued?.role !== "custom" || !Array.isArray(queued.content)) { throw new Error("expected retry-drained skill to be queued as image-bearing custom content"); } expect(queued.customType).toBe(SKILL_PROMPT_MESSAGE_TYPE); expect(queued.content[1]).toEqual(image); expect(fixture.session.messages).toEqual([]); } finally { await fixture.session.dispose(); fixture.authStorage.close(); fixture.tempDir.removeSync(); } }); }); interface SessionFixture { tempDir: TempDir; authStorage: AuthStorage; session: AgentSession; } async function createRealSession(): Promise { 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(), }); } /** Mirror a hidden magic-keyword companion notice (`display:false`, `attribution:"user"`). */ function queueMagicCompanion(session: AgentSession, customType = "ultrathink-notice"): void { session.agent.steer({ role: "custom", customType, content: "hidden notice", display: false, attribution: "user", details: {}, timestamp: Date.now(), }); } /** Mirror a steered user prompt (`AgentSession.#queueUserMessage(..., "steer")`). */ function queueUserSteer(session: AgentSession, text: string): void { session.agent.steer({ role: "user", content: [{ type: "text", text }], steering: true, attribution: "user", 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" }); }); it("clearQueue drops a queued magic-keyword companion with its dequeued user prompt", async () => { fixture = await createRealSession(); const { session } = fixture; // Queue order mirrors prompt("ultrathink do X", { streamingBehavior: "steer" }): // the hidden companion notice queues right before the user message. queueMagicCompanion(session, "ultrathink-notice"); queueUserSteer(session, "ultrathink do X"); // The companion is display:false, so only the user prompt is displayable work. expect(session.queuedMessageCount).toBe(1); // Alt+Up bulk restore returns the user's text and leaves no orphaned companion. const cleared = session.clearQueue(); expect(cleared.steering).toEqual([{ text: "ultrathink do X", images: undefined }]); expect(session.agent.hasQueuedMessages()).toBe(false); }); it("popLastQueuedMessage drops only the popped prompt's preceding companion", async () => { fixture = await createRealSession(); const { session } = fixture; // [ultrathink-notice, "first", orchestrate-notice, "second"]. queueMagicCompanion(session, "ultrathink-notice"); queueUserSteer(session, "first"); queueMagicCompanion(session, "orchestrate-notice"); queueUserSteer(session, "second"); expect(session.popLastQueuedMessage()?.text).toBe("second"); // Only the popped prompt's companion (orchestrate-notice) leaves; the earlier // prompt and its own companion stay intact. const remaining = session.agent.peekSteeringQueue(); expect(remaining.map(m => (m.role === "custom" ? m.customType : m.role))).toEqual(["ultrathink-notice", "user"]); }); }); function createStubInteractiveModeContextForUiHelpers(session: AgentSession) { let editorText = ""; const editor: StubEditor = { setText(text) { editorText = text; }, getText() { return editorText; }, getExpandedText() { return editorText; }, clearDraft(historyText?: string) { if (historyText !== undefined) this.addToHistory(historyText); this.setText(""); this.imageLinks = undefined; this.pendingImages = []; this.pendingImageLinks = []; }, addToHistory: vi.fn(), pendingImages: [] as ImageContent[], pendingImageLinks: [] as (string | undefined)[], }; 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(), } 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 = Bun.stripANSI(pendingMessagesContainer.render(120).join("\n")); expect(rendered).toContain("Steering · 1"); expect(rendered).toContain("1. /skill:test-skill arg1 arg2"); expect(rendered).not.toContain("Steer:"); }); it("groups yield follow-ups under one heading", async () => { fixture = await createRealSession(); const { session } = fixture; for (const text of ["inspect types", "run tests", "summarize"]) { session.agent.followUp({ role: "user", content: text, attribution: "user", timestamp: Date.now(), }); } const { ctx, pendingMessagesContainer } = createStubInteractiveModeContextForUiHelpers(session); new UiHelpers(ctx).updatePendingMessagesDisplay(); const rendered = Bun.stripANSI(pendingMessagesContainer.render(120).join("\n")); expect(rendered).toContain("After yield · 3"); expect(rendered).toContain("1. inspect types"); expect(rendered).toContain("2. run tests"); expect(rendered).toContain("3. summarize"); expect(rendered).not.toContain("Follow-up:"); }); 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 = { 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 = { 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); }); });