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
6 changes: 4 additions & 2 deletions src/browser/components/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -860,7 +860,7 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {

const handleEditQueuedMessage = useCallback(async () => {
const queuedMessage = workspaceState?.queuedMessage;
if (!queuedMessage) return;
if (!queuedMessage || queuedMessage.isDispatching) return;

await restoreQueuedDraft(queuedMessage);
}, [restoreQueuedDraft, workspaceState?.queuedMessage]);
Expand Down Expand Up @@ -958,7 +958,9 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
if (!current) return;

if (current.queuedMessage) {
await restoreQueuedDraft(current.queuedMessage);
if (!current.queuedMessage.isDispatching) {
await restoreQueuedDraft(current.queuedMessage);
}
return;
}

Expand Down
1 change: 1 addition & 0 deletions src/browser/features/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3356,6 +3356,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
variant === "workspace" &&
!editingMessageForUi &&
props.queuedMessage != null &&
props.queuedMessage.isDispatching !== true &&
input.trim() === "" &&
attachments.length === 0 &&
reviewPanelItems.length === 0;
Expand Down
24 changes: 15 additions & 9 deletions src/browser/features/Messages/QueuedMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const QueuedMessage: React.FC<QueuedMessageProps> = (props) => {
const queueDispatchMode = props.message.queueDispatchMode ?? "tool-end";
const queueStatusLabel =
queueDispatchMode === "turn-end" ? "Sends after this turn" : "Sends after this step";
const isDispatching = props.message.isDispatching === true;
const isActionPending = pendingAction != null;

const handleDispatchModeChange = (mode: QueueDispatchMode) => {
Expand Down Expand Up @@ -114,7 +115,7 @@ export const QueuedMessage: React.FC<QueuedMessageProps> = (props) => {
className="mt-1.5 flex max-w-full flex-wrap items-center justify-end gap-1 text-[11px]"
data-component="QueuedMessageActions"
>
{props.onEdit && (
{!isDispatching && props.onEdit && (
<button
type="button"
onClick={props.onEdit}
Expand All @@ -141,23 +142,28 @@ export const QueuedMessage: React.FC<QueuedMessageProps> = (props) => {
>
<button
type="button"
onClick={() => setIsMenuOpen((open) => !open)}
aria-haspopup="menu"
aria-expanded={isMenuOpen}
className="text-secondary bg-muted/10 hover:bg-hover hover:text-foreground flex h-6 max-w-full items-center gap-1.5 rounded-md px-2 font-medium transition-colors"
onClick={() => {
if (!isDispatching) setIsMenuOpen((open) => !open);
}}
aria-haspopup={isDispatching ? undefined : "menu"}
aria-expanded={isDispatching ? undefined : isMenuOpen}
disabled={isDispatching}
className="text-secondary bg-muted/10 hover:bg-hover hover:text-foreground flex h-6 max-w-full items-center gap-1.5 rounded-md px-2 font-medium transition-colors disabled:cursor-default"
data-component="QueuedMessageStatus"
>
{pendingAction === "mode" ? (
<Loader2 className="size-3 shrink-0 animate-spin" />
) : (
<Clock3 className="text-pending size-3 shrink-0" />
)}
<span className="text-foreground shrink-0">Queued</span>
<span className="truncate">{queueStatusLabel}</span>
<ChevronDown className="size-3 shrink-0" />
<span className="text-foreground shrink-0">
{isDispatching ? "Sending" : "Queued"}
</span>
{!isDispatching && <span className="truncate">{queueStatusLabel}</span>}
{!isDispatching && <ChevronDown className="size-3 shrink-0" />}
</button>

{isMenuOpen && (
{!isDispatching && isMenuOpen && (
<div
role="menu"
className="bg-separator border-border-light absolute right-0 bottom-full z-[1020] mb-1 min-w-[12rem] rounded-md border p-1.5 shadow-md"
Expand Down
2 changes: 2 additions & 0 deletions src/browser/stores/WorkspaceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1132,12 +1132,14 @@ export class WorkspaceStore {
data.reviews?.map((review) => [review.filePath, review.lineRange]) ?? [],
data.queueDispatchMode,
data.hasCompactionRequest,
data.isDispatching,
])}`,
content: data.displayText,
fileParts: data.fileParts,
reviews: data.reviews,
queueDispatchMode: data.queueDispatchMode,
hasCompactionRequest: data.hasCompactionRequest,
isDispatching: data.isDispatching,
}
: null;

Expand Down
2 changes: 2 additions & 0 deletions src/common/orpc/schemas/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,8 @@ export const QueuedMessageChangedEventSchema = z.object({
queueDispatchMode: z.enum(["tool-end", "turn-end"]).optional(),
/** True when the queued message is a compaction request (/compact) */
hasCompactionRequest: z.boolean().optional(),
/** True when the visible projection includes an entry being durably accepted. */
isDispatching: z.boolean().optional(),
});

export const RestoreToInputEventSchema = z.object({
Expand Down
2 changes: 2 additions & 0 deletions src/common/types/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1079,6 +1079,8 @@ export interface QueuedMessage {
queueDispatchMode?: QueueDispatchMode;
/** True when the queued message is a compaction request (/compact) */
hasCompactionRequest?: boolean;
/** True when this projection includes an entry being durably accepted. */
isDispatching?: boolean;
}

/** Keep every snapshot kind here so history scans and edits retain it with its user message. */
Expand Down
13 changes: 7 additions & 6 deletions src/node/services/agentSession.disposeRace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ describe("AgentSession disposal race conditions", () => {
(
_message: string,
_options?: { model: string; agentId: string },
_internal?: { synthetic?: boolean }
_internal?: { synthetic?: boolean; onAccepted?: () => Promise<void> }
) => Promise.resolve(Ok(undefined))
);

Expand All @@ -440,10 +440,11 @@ describe("AgentSession disposal race conditions", () => {
session.sendQueuedMessages();

expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
"Background compaction request",
expect.objectContaining({ model: "anthropic:claude-sonnet-4-5", agentId: "compact" }),
{ synthetic: true }
);
const call = sendMessage.mock.calls[0];
if (!call) throw new Error("Expected queued message dispatch");
expect(call[0]).toBe("Background compaction request");
expect(call[1]).toMatchObject({ model: "anthropic:claude-sonnet-4-5", agentId: "compact" });
expect(call[2]?.synthetic).toBe(true);
expect(typeof call[2]?.onAccepted).toBe("function");
});
});
95 changes: 95 additions & 0 deletions src/node/services/agentSession.queueDispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,101 @@ describe("AgentSession queued message tool-call dispatch", () => {
}
});

test("keeps a dequeued user message visible until its durable row is emitted", async () => {
const workspaceId = "queue-dispatch-visible-handoff";
const goalSyncStarted = Promise.withResolvers<void>();
const goalSyncRelease = Promise.withResolvers<void>();
const workspaceGoalService = {
assertPricedModelForBudgetedGoal: mock(() => Promise.resolve(Ok(undefined))),
syncGoalModeWithChatTail: mock(async () => {
goalSyncStarted.resolve();
await goalSyncRelease.promise;
}),
clearPendingContinuationForManualUserMessage: mock(() => undefined),
acknowledgeUser: mock(() => Promise.resolve(null)),
} as unknown as WorkspaceGoalService;
const { session, cleanup, historyService, events } = await createAgentSessionHarness({
workspaceId,
workspaceGoalService,
initStateManagerOverrides: { replayInit: mock(() => Promise.resolve()) },
captureEvents: true,
});
const originalAppend = historyService.appendToHistory.bind(historyService);
const appendStarted = Promise.withResolvers<void>();
const appendRelease = Promise.withResolvers<void>();
const appendSpy = spyOn(historyService, "appendToHistory").mockImplementation(
async (...args) => {
appendStarted.resolve();
await appendRelease.promise;
return originalAppend(...args);
}
);
const followUp = "Follow up after compaction";
const nextFollowUp = "A later queued message";
const isFollowUpUserMessage = (event: (typeof events)[number]) =>
event.type === "message" &&
event.role === "user" &&
event.parts.some((part) => part.type === "text" && part.text === followUp);
const latestQueueEvent = () =>
events.filter((event) => event.type === "queued-message-changed").at(-1);

try {
session.queueMessage(followUp, { model: TEST_MODEL, agentId: "exec" });
const queueEventCountBeforeDispatch = events.filter(
(event) => event.type === "queued-message-changed"
).length;
session.sendQueuedMessages();
await appendStarted.promise;

expect(events.filter((event) => event.type === "queued-message-changed").length).toBe(
queueEventCountBeforeDispatch + 1
);
expect(latestQueueEvent()?.queuedMessages).toEqual([followUp]);
expect(events.some(isFollowUpUserMessage)).toBe(false);

// Any queue mutation during persistence must retain the in-flight entry in the
// authoritative projection instead of falling back to a stale renderer snapshot.
session.queueMessage(nextFollowUp, { model: TEST_MODEL, agentId: "exec" });
expect(latestQueueEvent()?.queuedMessages).toEqual([followUp, nextFollowUp]);

appendRelease.resolve();
await goalSyncStarted.promise;

// Reconnect replay already includes the persisted user row, so its queue snapshot must
// omit that dispatch while retaining later queued input.
const replayEvents: Array<(typeof events)[number]> = [];
await session.replayHistory(({ message }) => replayEvents.push(message));
expect(replayEvents.some(isFollowUpUserMessage)).toBe(true);
const replayQueueEvent = replayEvents.find(
(event) => event.type === "queued-message-changed"
);
expect(replayQueueEvent?.queuedMessages).toEqual([nextFollowUp]);
expect(replayQueueEvent?.isDispatching).toBe(false);

goalSyncRelease.resolve();
expect(await waitForCondition(() => events.some(isFollowUpUserMessage))).toBe(true);
expect(
await waitForCondition(() => latestQueueEvent()?.queuedMessages.join() === nextFollowUp)
).toBe(true);

const userMessageIndex = events.findIndex(isFollowUpUserMessage);
const handoffIndex = events.findIndex(
(event, index) =>
index > userMessageIndex &&
event.type === "queued-message-changed" &&
event.queuedMessages.join() === nextFollowUp
);
expect(userMessageIndex).toBeGreaterThanOrEqual(0);
expect(handoffIndex).toBeGreaterThan(userMessageIndex);
} finally {
appendRelease.resolve();
goalSyncRelease.resolve();
appendSpy.mockRestore();
session.dispose();
await cleanup();
}
});

test("cancel signal retracts a synthetic entry after dequeue while history append is preparing", async () => {
const workspaceId = "queue-dispatch-cancel-preparing";
const { session, cleanup, historyService, events } = await createAgentSessionHarness({
Expand Down
Loading
Loading