Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/node/services/agentSession.queueDispatch.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, spyOn, test } from "bun:test";

import { isQueuedMessageChanged, isRestoreToInput } from "@/common/orpc/types";
import { Ok } from "@/common/types/result";
import { createAgentSessionHarness } from "./agentSession.testHarness";

Expand Down Expand Up @@ -215,6 +216,84 @@ describe("AgentSession queued message tool-call dispatch", () => {
}
});

test("hides synthetic queued messages from the queued-message-changed event", async () => {
const workspaceId = "queue-dispatch-synthetic-hidden";
const { session, cleanup, events } = await createAgentSessionHarness({
workspaceId,
captureEvents: true,
});

try {
session.queueMessage(
"A background bash monitor matched output.",
{ model: TEST_MODEL, agentId: "exec" },
{ synthetic: true, agentInitiated: true }
);
session.queueMessage("user follow up", { model: TEST_MODEL, agentId: "exec" });

const queueEvents = events.filter(isQueuedMessageChanged);
expect(queueEvents).toHaveLength(2);
// Synthetic-only queue renders as empty (no queue banner)...
expect(queueEvents[0].queuedMessages).toEqual([]);
expect(queueEvents[0].displayText).toBe("");
// ...and a mixed batch shows only the user-authored text.
expect(queueEvents[1].queuedMessages).toEqual(["user follow up"]);
expect(queueEvents[1].displayText).toBe("user follow up");
} finally {
session.dispose();
await cleanup();
}
});

test("does not restore synthetic queued messages to the input box", async () => {
const workspaceId = "queue-dispatch-synthetic-restore";
const { session, cleanup, events } = await createAgentSessionHarness({
workspaceId,
captureEvents: true,
});

try {
session.queueMessage(
"A background bash monitor matched output.",
{ model: TEST_MODEL, agentId: "exec" },
{ synthetic: true, agentInitiated: true }
);
session.restoreQueueToInput();

// Queue is cleared, but no system text leaks into the composer.
expect(session.hasQueuedMessages()).toBe(false);
expect(events.filter(isRestoreToInput)).toHaveLength(0);
} finally {
session.dispose();
await cleanup();
}
});

test("restores only user text when the queue mixes synthetic and user messages", async () => {
const workspaceId = "queue-dispatch-mixed-restore";
const { session, cleanup, events } = await createAgentSessionHarness({
workspaceId,
captureEvents: true,
});

try {
session.queueMessage(
"A background bash monitor matched output.",
{ model: TEST_MODEL, agentId: "exec" },
{ synthetic: true, agentInitiated: true }
);
session.queueMessage("user follow up", { model: TEST_MODEL, agentId: "exec" });
session.restoreQueueToInput();

const restoreEvents = events.filter(isRestoreToInput);
expect(restoreEvents).toHaveLength(1);
expect(restoreEvents[0].text).toBe("user follow up");
} finally {
session.dispose();
await cleanup();
}
});

test("does not send the queued message after a user abort", async () => {
const workspaceId = "queue-dispatch-user-abort";
const { session, cleanup, aiEmitter, aiService } = await createAgentSessionHarness({
Expand Down
44 changes: 28 additions & 16 deletions src/node/services/agentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2173,16 +2173,7 @@ export class AgentSession {
// rebuild queue UI state even when history replay errored mid-flight.
listener({
workspaceId: this.workspaceId,
message: {
type: "queued-message-changed",
workspaceId: this.workspaceId,
queuedMessages: this.messageQueue.getMessages(),
displayText: this.messageQueue.getDisplayText(),
fileParts: this.messageQueue.getFileParts(),
reviews: this.messageQueue.getReviews(),
queueDispatchMode: this.messageQueue.getQueueDispatchMode(),
hasCompactionRequest: this.messageQueue.hasCompactionRequest(),
},
message: this.buildQueuedMessageChangedEvent(),
});

// Rehydrate pending auto-retry countdown state on reconnect/reload so
Expand Down Expand Up @@ -5178,11 +5169,20 @@ export class AgentSession {
restoreQueueToInput(): void {
this.assertNotDisposed("restoreQueueToInput");
if (!this.messageQueue.isEmpty()) {
const displayText = this.messageQueue.getDisplayText();
// Restore only user-authored content. Synthetic (system-injected) entries such as
// bash monitor wake prompts are canceled via clearQueue's callbacks instead of
// leaking system text into the composer.
const displayText = this.messageQueue.getUserDisplayText();
const fileParts = this.messageQueue.getFileParts();
const reviews = this.messageQueue.getReviews();
this.clearQueue();

const hasUserContent =
displayText.length > 0 || fileParts.length > 0 || (reviews?.length ?? 0) > 0;
if (!hasUserContent) {
return;
}

this.emitChatEvent({
type: "restore-to-input",
workspaceId: this.workspaceId,
Expand All @@ -5193,17 +5193,29 @@ export class AgentSession {
}
}

private emitQueuedMessageChanged(): void {
this.emitChatEvent({
/**
* Snapshot of the queue for UI consumption. Only user-authored entries are included;
* synthetic (system-injected) messages like bash monitor wakes still dispatch with the
* queued batch but must never appear in the queued-message banner.
*/
private buildQueuedMessageChangedEvent(): Extract<
WorkspaceChatMessage,
{ type: "queued-message-changed" }
> {
return {
type: "queued-message-changed",
workspaceId: this.workspaceId,
queuedMessages: this.messageQueue.getMessages(),
displayText: this.messageQueue.getDisplayText(),
queuedMessages: this.messageQueue.getUserMessages(),
displayText: this.messageQueue.getUserDisplayText(),
fileParts: this.messageQueue.getFileParts(),
reviews: this.messageQueue.getReviews(),
queueDispatchMode: this.messageQueue.getQueueDispatchMode(),
hasCompactionRequest: this.messageQueue.hasCompactionRequest(),
});
};
}

private emitQueuedMessageChanged(): void {
this.emitChatEvent(this.buildQueuedMessageChangedEvent());
}

/**
Expand Down
87 changes: 63 additions & 24 deletions src/node/services/messageQueue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ describe("MessageQueue", () => {
queue = new MessageQueue();
});

describe("getDisplayText", () => {
describe("getUserDisplayText", () => {
it("should return joined messages for normal messages", () => {
queue.add("First message");
queue.add("Second message");

expect(queue.getDisplayText()).toBe("First message\nSecond message");
expect(queue.getUserDisplayText()).toBe("First message\nSecond message");
});

it("should return rawCommand for compaction request", () => {
Expand All @@ -33,7 +33,7 @@ describe("MessageQueue", () => {

queue.add("Summarize this conversation into a compact form...", options);

expect(queue.getDisplayText()).toBe("/compact -t 3000");
expect(queue.getUserDisplayText()).toBe("/compact -t 3000");
});

it("should throw when adding compaction after normal message", () => {
Expand Down Expand Up @@ -71,11 +71,11 @@ describe("MessageQueue", () => {

queue.add("Regular message", options);

expect(queue.getDisplayText()).toBe("Regular message");
expect(queue.getUserDisplayText()).toBe("Regular message");
});

it("should return empty string for empty queue", () => {
expect(queue.getDisplayText()).toBe("");
expect(queue.getUserDisplayText()).toBe("");
});

it("should return joined messages after clearing compaction metadata", () => {
Expand All @@ -95,7 +95,7 @@ describe("MessageQueue", () => {
queue.clear();
queue.add("New message");

expect(queue.getDisplayText()).toBe("New message");
expect(queue.getUserDisplayText()).toBe("New message");
});
});

Expand All @@ -116,9 +116,9 @@ describe("MessageQueue", () => {
queue.add("Summarize this conversation...", options);

// getMessages should return the actual message text for editing
expect(queue.getMessages()).toEqual(["Summarize this conversation..."]);
// getDisplayText should return the slash command
expect(queue.getDisplayText()).toBe("/compact");
expect(queue.getUserMessages()).toEqual(["Summarize this conversation..."]);
// getUserDisplayText should return the slash command
expect(queue.getUserDisplayText()).toBe("/compact");
});
});

Expand Down Expand Up @@ -225,7 +225,7 @@ describe("MessageQueue", () => {
).toThrow(/Cannot queue compaction request/);

expect(queue.getQueueDispatchMode()).toBe("turn-end");
expect(queue.getMessages()).toHaveLength(1);
expect(queue.getUserMessages()).toHaveLength(1);
});

it("should preserve turn-end mode when agent-skill enqueue is rejected", () => {
Expand Down Expand Up @@ -256,7 +256,7 @@ describe("MessageQueue", () => {
).toThrow(/Cannot queue agent skill/);

expect(queue.getQueueDispatchMode()).toBe("turn-end");
expect(queue.getMessages()).toHaveLength(1);
expect(queue.getUserMessages()).toHaveLength(1);
});

it("should reset mode to tool-end when cleared", () => {
Expand Down Expand Up @@ -374,7 +374,7 @@ describe("MessageQueue", () => {

expect(addedFirst).toBe(true);
expect(addedSecond).toBe(false);
expect(queue.getMessages()).toEqual(["Follow up"]);
expect(queue.getUserMessages()).toEqual(["Follow up"]);
expect(queue.getFileParts()).toEqual([image]);
});
});
Expand All @@ -385,8 +385,8 @@ describe("MessageQueue", () => {
queue.add("Second message");
queue.add("Third message");

expect(queue.getMessages()).toEqual(["First message", "Second message", "Third message"]);
expect(queue.getDisplayText()).toBe("First message\nSecond message\nThird message");
expect(queue.getUserMessages()).toEqual(["First message", "Second message", "Third message"]);
expect(queue.getUserDisplayText()).toBe("First message\nSecond message\nThird message");
});

it("should preserve compaction metadata when follow-up is added", () => {
Expand All @@ -404,10 +404,10 @@ describe("MessageQueue", () => {
queue.add("And then do this follow-up task");

// Display shows all messages (multiple messages = not just compaction)
expect(queue.getDisplayText()).toBe("Summarize...\nAnd then do this follow-up task");
expect(queue.getUserDisplayText()).toBe("Summarize...\nAnd then do this follow-up task");

// getMessages includes both
expect(queue.getMessages()).toEqual(["Summarize...", "And then do this follow-up task"]);
expect(queue.getUserMessages()).toEqual(["Summarize...", "And then do this follow-up task"]);

// produceMessage preserves compaction metadata from first message
const { message, options } = queue.produceMessage();
Expand Down Expand Up @@ -454,7 +454,7 @@ describe("MessageQueue", () => {
muxMetadata: metadata,
});

expect(queue.getDisplayText()).toBe("/init");
expect(queue.getUserDisplayText()).toBe("/init");

expect(() => queue.add("Follow-up message")).toThrow(
/agent skill invocation is already queued/
Expand Down Expand Up @@ -489,13 +489,13 @@ describe("MessageQueue", () => {
fileParts: [image2],
});

expect(queue.getMessages()).toEqual([
expect(queue.getUserMessages()).toEqual([
"Message with image",
"Follow-up without image",
"Another with image",
]);
expect(queue.getFileParts()).toEqual([image1, image2]);
expect(queue.getDisplayText()).toBe(
expect(queue.getUserDisplayText()).toBe(
"Message with image\nFollow-up without image\nAnother with image"
);
});
Expand Down Expand Up @@ -531,6 +531,45 @@ describe("MessageQueue", () => {
});
});

describe("synthetic entry segregation", () => {
it("should hide synthetic entries from user-facing accessors while still dispatching them", () => {
queue.add(
"A background bash monitor matched output.",
{ model: "gpt-4", agentId: "exec" },
{ synthetic: true }
);

// User-facing view is empty (nothing to show in the queue banner or restore to input)...
expect(queue.getUserMessages()).toEqual([]);
expect(queue.getUserDisplayText()).toBe("");
// ...but the entry still counts as queued and dispatches with the batch.
expect(queue.isEmpty()).toBe(false);
expect(queue.produceMessage().message).toBe("A background bash monitor matched output.");
});

it("should show only user text for mixed synthetic + user batches", () => {
queue.add("Synthetic wake prompt", { model: "gpt-4", agentId: "exec" }, { synthetic: true });
queue.add("User follow-up", { model: "gpt-4", agentId: "exec" });

expect(queue.getUserMessages()).toEqual(["User follow-up"]);
expect(queue.getUserDisplayText()).toBe("User follow-up");
// Dispatch still includes both, in queue order.
expect(queue.produceMessage().message).toBe("Synthetic wake prompt\nUser follow-up");
});

it("should not apply rawCommand shortcut when a synthetic entry shares the queue", () => {
queue.add("Summarize this conversation...", {
model: "gpt-4",
agentId: "exec",
muxMetadata: { type: "compaction-request", rawCommand: "/compact" },
});
queue.add("Synthetic follow-up", { model: "gpt-4", agentId: "exec" }, { synthetic: true });

// rawCommand would misrepresent the batch; fall back to the actual user text.
expect(queue.getUserDisplayText()).toBe("Summarize this conversation...");
});
});

describe("getFileParts", () => {
it("should return accumulated images from multiple messages", () => {
const image1 = {
Expand Down Expand Up @@ -600,7 +639,7 @@ describe("MessageQueue", () => {
const image = { url: "data:image/png;base64,abc", mediaType: "image/png" };
queue.add("", { model: "gpt-4", agentId: "exec", fileParts: [image] });

expect(queue.getMessages()).toEqual([]);
expect(queue.getUserMessages()).toEqual([]);
expect(queue.getFileParts()).toEqual([image]);
expect(queue.isEmpty()).toBe(false);
});
Expand All @@ -609,7 +648,7 @@ describe("MessageQueue", () => {
queue.add("", { model: "gpt-4", agentId: "exec" });

expect(queue.isEmpty()).toBe(true);
expect(queue.getMessages()).toEqual([]);
expect(queue.getUserMessages()).toEqual([]);
expect(queue.getFileParts()).toEqual([]);
});

Expand All @@ -620,7 +659,7 @@ describe("MessageQueue", () => {
queue.add("Text message", { model: "gpt-4", agentId: "exec", fileParts: [image1] });
queue.add("", { model: "gpt-4", agentId: "exec", fileParts: [image2] }); // Image-only

expect(queue.getMessages()).toEqual(["Text message"]);
expect(queue.getUserMessages()).toEqual(["Text message"]);
expect(queue.getFileParts()).toEqual([image1, image2]);
expect(queue.isEmpty()).toBe(false);
});
Expand All @@ -643,11 +682,11 @@ describe("MessageQueue", () => {
expect(options?.model).toBe("gpt-4");
});

it("should return empty string for getDisplayText with image-only", () => {
it("should return empty string for getUserDisplayText with image-only", () => {
const image = { url: "data:image/png;base64,abc", mediaType: "image/png" };
queue.add("", { model: "gpt-4", agentId: "exec", fileParts: [image] });

expect(queue.getDisplayText()).toBe("");
expect(queue.getUserDisplayText()).toBe("");
});
});
});
Loading
Loading