fix(coding-agent/session): restricted queued-message editor restore to user-authored messages
- Added `isUserQueuedMessage()` to `agent-session.ts`, treating only plain user turns and visible `attribution: "user"` custom messages (e.g. `/skill`) as restorable, so advisor concern/blocker notes, hidden goal/plan/budget steers, and IRC/extension asides no longer leak into the editor on Esc/Alt+Up. - Reworked `clearQueue()` to return only user-authored messages while re-queuing advisor cards via `replaceQueues()` (so the user-interrupt abort path still re-records them as advice) and dropping other agent-authored steers to prevent a silent auto-resume on leftover internal context. - Filtered `getQueuedMessages()` chips and rewrote `popLastQueuedMessage()` to skip agent-authored cards and pull the last user-authored entry, while `queuedMessageCount` still counts all displayable queued work. - Extended `input-controller-skill-queue.test.ts` with a `queueAdvisorSteer` helper and cases asserting advisor/IRC cards count as pending work but stay out of chips, restore, and `popLastQueuedMessage`, and survive `clearQueue()`.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user