diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index e477f4e93..0390ed549 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -955,6 +955,19 @@ function isAdvisorCard(message: AgentMessage): message is CustomMessage { return message.role === "custom" && message.customType === "advisor"; } +/** + * A queued message the user can restore to the editor / pull back as a draft. + * Only genuinely user-authored messages qualify: plain user turns, or custom + * messages explicitly attributed to the user (e.g. `/skill` invocations). + * Agent-authored queued cards — advisor concern/blocker notes, IRC asides, + * extension notices, hidden goal/plan/budget steers — ride the same + * steer/follow-up queues but must never be dumped into the editor on Esc/Alt+Up. + */ +function isUserQueuedMessage(message: AgentMessage): boolean { + if (message.role === "user") return true; + return message.role === "custom" && message.attribution === "user" && message.display !== false; +} + function queueChipText(message: AgentMessage): string { if (message.role === "custom") { return readQueueChipText(message.details) ?? queuedTextContent(message) ?? ""; @@ -5874,13 +5887,25 @@ export class AgentSession { /** Clear queued messages and return them (text plus any attached images). */ clearQueue(): { steering: RestoredQueuedMessage[]; followUp: RestoredQueuedMessage[] } { - const steering = this.agent.peekSteeringQueue().map(toRestoredQueuedMessage); - const followUp = this.agent.peekFollowUpQueue().map(toRestoredQueuedMessage); - this.agent.clearAllQueues(); + const steeringAll = this.agent.peekSteeringQueue(); + const followUpAll = this.agent.peekFollowUpQueue(); + const steering = steeringAll.filter(isUserQueuedMessage).map(toRestoredQueuedMessage); + const followUp = followUpAll.filter(isUserQueuedMessage).map(toRestoredQueuedMessage); + // User-authored messages go back to the editor. Advisor concern/blocker cards + // are kept queued: a resumed stream still delivers them, and on Esc abort()'s + // #extractQueuedAdvisorCards extracts + preserves them as visible advice + // (with auto-resume suppressed). Every other non-user queued message — hidden + // goal/plan/budget steers, IRC/extension asides — is dropped, matching the + // original full clear: keeping them would both leak into the editor and let + // abort()'s #drainStrandedQueuedMessages silently auto-resume the run the user + // just interrupted (the drain only fires while agent.hasQueuedMessages()). + this.agent.replaceQueues(steeringAll.filter(isAdvisorCard), followUpAll.filter(isAdvisorCard)); return { steering, followUp }; } - /** Number of pending displayable messages (includes steering, follow-up, and next-turn messages) */ + /** Number of pending displayable messages (includes steering, follow-up, and next-turn messages). + * Reflects actual queued work (advisor cards included) — feeds hasPendingMessages()/RPC and the + * empty-submit abort gate. The user-restorable subset is surfaced by getQueuedMessages()/clearQueue(). */ get queuedMessageCount(): number { return ( this.agent.peekSteeringQueue().filter(isDisplayableQueuedMessage).length + @@ -5891,18 +5916,40 @@ export class AgentSession { getQueuedMessages(): { steering: readonly string[]; followUp: readonly string[] } { return { - steering: this.agent.peekSteeringQueue().filter(isDisplayableQueuedMessage).map(queueChipText), - followUp: this.agent.peekFollowUpQueue().filter(isDisplayableQueuedMessage).map(queueChipText), + steering: this.agent.peekSteeringQueue().filter(isUserQueuedMessage).map(queueChipText), + followUp: this.agent.peekFollowUpQueue().filter(isUserQueuedMessage).map(queueChipText), }; } /** * Pop the last queued message (steering first, then follow-up). * Used by dequeue keybinding to restore messages to editor one at a time. + * Steps over agent-authored queued messages (advisor cards, hidden/internal steers). */ popLastQueuedMessage(): RestoredQueuedMessage | undefined { - const message = this.agent.popLastSteer() ?? this.agent.popLastFollowUp(); - return message ? toRestoredQueuedMessage(message) : undefined; + const steering = this.agent.peekSteeringQueue(); + const followUp = this.agent.peekFollowUpQueue(); + const lastUserIndex = (queue: readonly AgentMessage[]): number => { + for (let i = queue.length - 1; i >= 0; i--) { + if (isUserQueuedMessage(queue[i])) return i; + } + return -1; + }; + const fromSteer = lastUserIndex(steering); + if (fromSteer >= 0) { + const nextSteer = steering.slice(); + const [removed] = nextSteer.splice(fromSteer, 1); + this.agent.replaceQueues(nextSteer, followUp.slice()); + return toRestoredQueuedMessage(removed); + } + const fromFollowUp = lastUserIndex(followUp); + if (fromFollowUp >= 0) { + const nextFollowUp = followUp.slice(); + const [removed] = nextFollowUp.splice(fromFollowUp, 1); + this.agent.replaceQueues(steering.slice(), nextFollowUp); + return toRestoredQueuedMessage(removed); + } + return undefined; } get skillsSettings(): SkillsSettings | undefined { diff --git a/packages/coding-agent/test/input-controller-skill-queue.test.ts b/packages/coding-agent/test/input-controller-skill-queue.test.ts index 3853afed8..40719dc4d 100644 --- a/packages/coding-agent/test/input-controller-skill-queue.test.ts +++ b/packages/coding-agent/test/input-controller-skill-queue.test.ts @@ -225,6 +225,7 @@ function queueCustomSteer(session: AgentSession, chip: string, content = "skill customType: SKILL_PROMPT_MESSAGE_TYPE, content, display: true, + attribution: "user", details: { name: "foo", path: "/s.md", @@ -236,6 +237,18 @@ function queueCustomSteer(session: AgentSession, chip: string, content = "skill }); } +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; @@ -259,7 +272,7 @@ describe("AgentSession derived queued custom display", () => { expect(session.queuedMessageCount).toBe(1); }); - it("excludes display-suppressed custom messages from pending chips and counts", async () => { + it("excludes display-suppressed custom messages from chips/count and never restores them", async () => { fixture = await createRealSession(); const { session } = fixture; session.agent.steer({ @@ -273,6 +286,35 @@ describe("AgentSession derived queued custom display", () => { expect(session.getQueuedMessages().steering).toEqual([]); expect(session.queuedMessageCount).toBe(0); + // A hidden agent-authored steer must not leak into the editor, and clearQueue + // must drop 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().steering).toEqual([]); + expect(session.agent.hasQueuedMessages()).toBe(false); + }); + + it("never restores a visible agent-authored custom steer, and drops it on clearQueue", async () => { + fixture = await createRealSession(); + const { session } = fixture; + // An IRC aside / extension notice: visible, but agent-authored — editing it + // makes no sense, so it must not ride the Esc/Alt+Up editor-restore path. + session.agent.steer({ + role: "custom", + customType: "irc", + content: "peer pinged you", + display: true, + attribution: "agent", + details: {}, + timestamp: Date.now(), + }); + + expect(session.getQueuedMessages().steering).toEqual([]); + // popLast leaves the agent steer untouched (not user-restorable)... + expect(session.popLastQueuedMessage()).toBeUndefined(); + expect(session.agent.peekSteeringQueue()).toHaveLength(1); + // ...and clearQueue restores nothing while dropping it (no auto-resume leftover). + expect(session.clearQueue().steering).toEqual([]); + expect(session.agent.hasQueuedMessages()).toBe(false); }); it("popLastQueuedMessage restores chip text and removes the core queue entry", async () => { @@ -283,6 +325,52 @@ describe("AgentSession derived queued custom display", () => { 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) {