diff --git a/packages/cli/src/lib/__tests__/background-job-notification-delivery.test.ts b/packages/cli/src/lib/__tests__/background-job-notification-delivery.test.ts new file mode 100644 index 0000000000..038b7ab571 --- /dev/null +++ b/packages/cli/src/lib/__tests__/background-job-notification-delivery.test.ts @@ -0,0 +1,98 @@ +import type { BackgroundJobTerminalEvent } from "@getpochi/common"; +import type { Message } from "@getpochi/livekit"; +import { describe, expect, it } from "vitest"; +import { + deliverBackgroundJobNotifications, + takeBackgroundJobNotificationMessage, +} from "../background-job-notification-delivery"; + +describe("takeBackgroundJobNotificationMessage", () => { + it("drains every pending event into one message", () => { + const pending = [event("bgjob-cmd-1"), event("bgjob-cmd-2")]; + + const message = takeBackgroundJobNotificationMessage(pending); + + expect(pending).toHaveLength(0); + expect(message?.parts).toHaveLength(2); + }); + + it("returns nothing when no job finished", () => { + expect(takeBackgroundJobNotificationMessage([])).toBeUndefined(); + }); +}); + +describe("deliverBackgroundJobNotifications", () => { + it("delivers at a step boundary that continues the loop", () => { + const pending = [event("bgjob-cmd-1")]; + const chat = fakeChat(); + + const delivered = deliverBackgroundJobNotifications("next", pending, chat); + + expect(delivered).toBe(true); + expect(chat.messages).toHaveLength(1); + expect(chat.messages[0].role).toBe("user"); + expect(chat.messages[0].parts).toEqual([ + expect.objectContaining({ + type: "data-background-job-notification", + data: expect.objectContaining({ backgroundJobId: "bgjob-cmd-1" }), + }), + ]); + // Drained, so the drain at the end of the task does not repeat it. + expect(pending).toHaveLength(0); + }); + + it("delivers nothing when no job finished during the step", () => { + const chat = fakeChat(); + + expect(deliverBackgroundJobNotifications("next", [], chat)).toBe(false); + expect(chat.messages).toHaveLength(0); + }); + + it("keeps notifications pending while the step is retried", () => { + const pending = [event("bgjob-cmd-1")]; + const chat = fakeChat(); + + const delivered = deliverBackgroundJobNotifications("retry", pending, chat); + + expect(delivered).toBe(false); + expect(chat.messages).toHaveLength(0); + expect(pending).toHaveLength(1); + }); + + it("leaves the finished step to drain notifications itself", () => { + const pending = [event("bgjob-cmd-1")]; + const chat = fakeChat(); + + const delivered = deliverBackgroundJobNotifications( + "finished", + pending, + chat, + ); + + expect(delivered).toBe(false); + expect(chat.messages).toHaveLength(0); + expect(pending).toHaveLength(1); + }); +}); + +function fakeChat() { + const messages: Message[] = []; + return { + messages, + appendOrReplaceMessage(message: Message) { + messages.push(message); + }, + }; +} + +function event(backgroundJobId: string): BackgroundJobTerminalEvent { + return { + taskId: "task-1", + backgroundJobId, + outputFile: `/tmp/${backgroundJobId}.log`, + status: "completed", + command: `run ${backgroundJobId}`, + exitCode: 0, + finishedAt: 1, + }; +} diff --git a/packages/cli/src/lib/background-job-notification-delivery.ts b/packages/cli/src/lib/background-job-notification-delivery.ts new file mode 100644 index 0000000000..aa732eeeb5 --- /dev/null +++ b/packages/cli/src/lib/background-job-notification-delivery.ts @@ -0,0 +1,41 @@ +import type { BackgroundJobTerminalEvent } from "@getpochi/common"; +import { + type Message, + createBackgroundJobNotificationMessage, +} from "@getpochi/livekit"; + +export type StepResult = "finished" | "next" | "retry"; + +export interface BackgroundJobNotificationTarget { + appendOrReplaceMessage(message: Message): void; +} + +/** Drains the pending events into one message. */ +export function takeBackgroundJobNotificationMessage( + pending: BackgroundJobTerminalEvent[], +): Message | undefined { + const events = pending.splice(0); + return events.length > 0 + ? createBackgroundJobNotificationMessage(events) + : undefined; +} + +/** Delivers jobs that finished mid loop with the next continuation request. */ +export function deliverBackgroundJobNotifications( + stepResult: StepResult, + pending: BackgroundJobTerminalEvent[], + chat: BackgroundJobNotificationTarget, +): boolean { + // "retry" resends the last message as is, and "finished" has its own drain. + if (stepResult !== "next") { + return false; + } + + const message = takeBackgroundJobNotificationMessage(pending); + if (!message) { + return false; + } + + chat.appendOrReplaceMessage(message); + return true; +} diff --git a/packages/cli/src/task-runner.ts b/packages/cli/src/task-runner.ts index 115d66471a..b8a96d0598 100644 --- a/packages/cli/src/task-runner.ts +++ b/packages/cli/src/task-runner.ts @@ -35,7 +35,6 @@ import { type LiveKitStore, type Message, type Task, - createBackgroundJobNotificationMessage, processContentOutput, } from "@getpochi/livekit"; import { LiveChatKit } from "@getpochi/livekit/node"; @@ -61,6 +60,10 @@ import { } from "ai"; import type z from "zod"; import { BackgroundJobManager } from "./lib/background-job-manager"; +import { + deliverBackgroundJobNotifications, + takeBackgroundJobNotificationMessage, +} from "./lib/background-job-notification-delivery"; import type { FileSystem } from "./lib/file-system"; import { readEnvironment } from "./lib/read-environment"; import { createSpinner } from "./lib/spinner"; @@ -460,10 +463,9 @@ export class TaskRunner { } private takePendingBackgroundJobNotifications(): Message | undefined { - const events = this.pendingBackgroundJobNotifications.splice(0); - return events.length > 0 - ? createBackgroundJobNotificationMessage(events) - : undefined; + return takeBackgroundJobNotificationMessage( + this.pendingBackgroundJobNotifications, + ); } /** @@ -538,6 +540,13 @@ export class TaskRunner { this.stepCount.throwIfReachedMaxRetries(); } + // Deliver at this step boundary instead of waiting for the task to end. + deliverBackgroundJobNotifications( + result, + this.pendingBackgroundJobNotifications, + this.chat, + ); + this.abortSignal?.throwIfAborted(); await this.chatKit.chat.sendMessage(); return result; diff --git a/packages/vscode-webui/src/features/chat/components/chat-toolbar.test.tsx b/packages/vscode-webui/src/features/chat/components/chat-toolbar.test.tsx index 0618747aa8..3506c18eaa 100644 --- a/packages/vscode-webui/src/features/chat/components/chat-toolbar.test.tsx +++ b/packages/vscode-webui/src/features/chat/components/chat-toolbar.test.tsx @@ -1,18 +1,38 @@ +import type { BackgroundJobNotification } from "@getpochi/common"; import type { Message, Task } from "@getpochi/livekit"; import type { Todo } from "@getpochi/tools"; // @vitest-environment jsdom -import { render, screen } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { ChatToolbar } from "./chat-toolbar"; -const chatSubmitMocks = vi.hoisted(() => ({ - useChatSubmit: vi.fn(() => ({ - handleSubmit: vi.fn(), - handleSteerSubmit: vi.fn(), - handleSteerQueuedMessage: vi.fn(), - handleStop: vi.fn(), - pauseQueueRef: { current: false }, - })), +const chatSubmitMocks = vi.hoisted(() => { + const sendQueuedMessage = vi.fn(() => Promise.resolve(true)); + const setQueuedMessages = { + current: undefined as + | React.Dispatch> + | undefined, + }; + return { + sendQueuedMessage, + setQueuedMessages, + useChatSubmit: vi.fn((props: { setQueuedMessages: unknown }) => { + setQueuedMessages.current = props.setQueuedMessages as React.Dispatch< + React.SetStateAction + >; + return { + handleSubmit: vi.fn(), + handleSteerSubmit: vi.fn(), + handleSteerQueuedMessage: vi.fn(), + handleStop: vi.fn(), + sendQueuedMessage, + }; + }), + }; +}); +const backgroundJobMocks = vi.hoisted(() => ({ + notifications: [] as unknown[], + acknowledge: vi.fn(() => Promise.resolve()), })); const userEditsMocks = vi.hoisted(() => ({ userEdits: [] as Array<{ @@ -105,8 +125,8 @@ vi.mock("@/lib/hooks/use-add-complete-tool-calls", () => ({ })); vi.mock("@/lib/hooks/use-background-job-notifications", () => ({ useBackgroundJobNotifications: () => ({ - notifications: [], - acknowledge: undefined, + notifications: backgroundJobMocks.notifications, + acknowledge: backgroundJobMocks.acknowledge, }), })); vi.mock("@/lib/hooks/use-custom-agents", () => ({ @@ -189,7 +209,11 @@ const auditTodo: Todo = { priority: "medium", }; -function renderToolbar(isSubTask: boolean, lastCheckpointHash?: string) { +function renderToolbar( + isSubTask: boolean, + lastCheckpointHash?: string, + deliverBackgroundJobNotificationsRef?: React.RefObject<() => boolean>, +) { render( , ); } +function notification(backgroundJobId: string): BackgroundJobNotification { + return { + notificationId: `${backgroundJobId}:terminal`, + backgroundJobId, + outputFile: `/tmp/${backgroundJobId}.log`, + command: `run ${backgroundJobId}`, + status: "completed", + summary: `Background command "${backgroundJobId}" completed`, + exitCode: 0, + finishedAt: 1, + }; +} + describe("ChatToolbar", () => { beforeEach(() => { chatSubmitMocks.useChatSubmit.mockClear(); + chatSubmitMocks.sendQueuedMessage.mockClear(); + chatSubmitMocks.setQueuedMessages.current = undefined; + backgroundJobMocks.notifications = []; + backgroundJobMocks.acknowledge.mockClear(); userEditsMocks.userEdits = []; }); @@ -301,4 +345,87 @@ describe("ChatToolbar", () => { }), ); }); + + describe("background job notification delivery", () => { + it("sends a queued notification instead of a plain continuation", async () => { + backgroundJobMocks.notifications = [notification("bgjob-cmd-1")]; + const deliverRef: React.RefObject<() => boolean> = { + current: () => false, + }; + + renderToolbar(false, undefined, deliverRef); + + let delivered: boolean | undefined; + await act(async () => { + delivered = deliverRef.current(); + }); + + expect(delivered).toBe(true); + // The guard is kept so an intentional manual approval mode is not + // silently turned into auto approve by a notification. + expect(chatSubmitMocks.sendQueuedMessage).toHaveBeenCalledWith(0, { + keepAutoApproveGuard: true, + }); + }); + + it("delivers nothing when no notification is queued", async () => { + const deliverRef: React.RefObject<() => boolean> = { + current: () => false, + }; + + renderToolbar(false, undefined, deliverRef); + + let delivered: boolean | undefined; + await act(async () => { + delivered = deliverRef.current(); + }); + + expect(delivered).toBe(false); + expect(chatSubmitMocks.sendQueuedMessage).not.toHaveBeenCalled(); + }); + + it("delivers nothing while a queued user message is ahead of the notification", async () => { + backgroundJobMocks.notifications = [notification("bgjob-cmd-1")]; + const deliverRef: React.RefObject<() => boolean> = { + current: () => false, + }; + + renderToolbar(false, undefined, deliverRef); + + await act(async () => { + chatSubmitMocks.setQueuedMessages.current?.((current) => [ + { parts: [{ type: "text", text: "hello" }], raw: { text: "hello" } }, + ...current, + ]); + }); + + let delivered: boolean | undefined; + await act(async () => { + delivered = deliverRef.current(); + }); + + expect(delivered).toBe(false); + expect(chatSubmitMocks.sendQueuedMessage).not.toHaveBeenCalled(); + }); + + it("does not send the same notification twice when the decision is evaluated again", async () => { + backgroundJobMocks.notifications = [notification("bgjob-cmd-1")]; + const deliverRef: React.RefObject<() => boolean> = { + current: () => false, + }; + + renderToolbar(false, undefined, deliverRef); + + let second: boolean | undefined; + await act(async () => { + deliverRef.current(); + second = deliverRef.current(); + }); + + // Still true: the pending delivery starts the next request, so the caller + // must not start a plain continuation on top of it. + expect(second).toBe(true); + expect(chatSubmitMocks.sendQueuedMessage).toHaveBeenCalledOnce(); + }); + }); }); diff --git a/packages/vscode-webui/src/features/chat/components/chat-toolbar.tsx b/packages/vscode-webui/src/features/chat/components/chat-toolbar.tsx index dcf167e740..ba999a0655 100644 --- a/packages/vscode-webui/src/features/chat/components/chat-toolbar.tsx +++ b/packages/vscode-webui/src/features/chat/components/chat-toolbar.tsx @@ -61,6 +61,7 @@ import { useTerminalContextState } from "../hooks/use-terminal-context-state"; import { enqueueBackgroundJobNotifications, getBackgroundJobNotificationIds, + getDeliverableBackgroundJobNotificationIndex, } from "../lib/background-job-notification-queue"; import { ChatInputForm, type ChatInputFormHandle } from "./chat-input-form"; import { ErrorMessageView } from "./error-message-view"; @@ -95,6 +96,8 @@ interface ChatToolbarProps { isRepairingMermaid?: boolean; mcpConfigOverride?: McpConfigOverride; getSystemPrompt?: () => string | undefined; + /** Filled in with the delivery callback, for the page to call at a step boundary. */ + deliverBackgroundJobNotificationsRef?: React.RefObject<() => boolean>; onToolCallApprovalVisible?: () => void; onToolsExecutionStarted?: () => void; onToolsExecutionEnded?: () => void; @@ -121,6 +124,7 @@ export const ChatToolbar: React.FC = ({ isRepairingMermaid = false, mcpConfigOverride, getSystemPrompt, + deliverBackgroundJobNotificationsRef, onToolCallApprovalVisible, onToolsExecutionStarted, onToolsExecutionEnded, @@ -309,6 +313,7 @@ export const ChatToolbar: React.FC = ({ handleSteerSubmit, handleSteerQueuedMessage, handleStop, + sendQueuedMessage, } = useChatSubmit({ chat, input, @@ -344,6 +349,37 @@ export const ChatToolbar: React.FC = ({ }, }); + // Last dispatched entry, so a re-evaluated decision does not send it twice. + const deliveredNotificationsRef = useRef(undefined); + + /** + * Delivers pending notifications instead of waiting for the task to become + * idle. Returns true when this delivery already starts the next request. + */ + const deliverBackgroundJobNotifications = useCallback(() => { + const index = getDeliverableBackgroundJobNotificationIndex(queuedMessages); + if (index === undefined) { + return false; + } + + const message = queuedMessages[index]; + if (deliveredNotificationsRef.current === message) { + return true; + } + deliveredNotificationsRef.current = message; + + // Deferred, so the send does not re-enter the SDK mid tool output. + void Promise.resolve().then(() => + sendQueuedMessage(index, { keepAutoApproveGuard: true }), + ); + return true; + }, [queuedMessages, sendQueuedMessage]); + + if (deliverBackgroundJobNotificationsRef) { + deliverBackgroundJobNotificationsRef.current = + deliverBackgroundJobNotifications; + } + const chatInputFormRef = useRef(null); const handleCurrentInputSubmit = useCallback(async () => { chatInputFormRef.current?.addToSubmitHistory(); diff --git a/packages/vscode-webui/src/features/chat/hooks/use-chat-submit.test.tsx b/packages/vscode-webui/src/features/chat/hooks/use-chat-submit.test.tsx index a8d0038a9a..8c7b38af50 100644 --- a/packages/vscode-webui/src/features/chat/hooks/use-chat-submit.test.tsx +++ b/packages/vscode-webui/src/features/chat/hooks/use-chat-submit.test.tsx @@ -605,6 +605,72 @@ describe("useChatSubmit", () => { }); }); + describe("sendQueuedMessage", () => { + it("sends the queued message without stopping the current run", async () => { + const first = draftMessage({ text: "first queued message" }); + const second = draftMessage({ text: "second queued message" }); + const context = setup({ + isLoading: false, + queuedMessages: [first, second], + }); + + let sent: boolean | undefined; + await act(async () => { + sent = await context.result.current.sendQueuedMessage(0); + }); + + expect(sent).toBe(true); + expect(context.stopChat).not.toHaveBeenCalled(); + expect(context.sendMessage).toHaveBeenCalledWith({ + parts: ["text:first queued message"], + }); + expect(context.queuedMessages).toEqual([second]); + }); + + it("resets the auto approve guard like a user submission by default", async () => { + chatStateMocks.autoApproveGuard.current = "manual"; + const context = setup({ + isLoading: false, + queuedMessages: [draftMessage({ text: "queued message" })], + }); + + await act(async () => { + await context.result.current.sendQueuedMessage(0); + }); + + expect(chatStateMocks.autoApproveGuard.current).toBe("auto"); + }); + + it("keeps the auto approve guard when the caller asks for it", async () => { + chatStateMocks.autoApproveGuard.current = "manual"; + const context = setup({ + isLoading: false, + queuedMessages: [draftMessage({ text: "queued message" })], + }); + + await act(async () => { + await context.result.current.sendQueuedMessage(0, { + keepAutoApproveGuard: true, + }); + }); + + expect(context.sendMessage).toHaveBeenCalledOnce(); + expect(chatStateMocks.autoApproveGuard.current).toBe("manual"); + }); + + it("does nothing when the index has no matching queued message", async () => { + const context = setup({ isLoading: false, queuedMessages: [] }); + + let sent: boolean | undefined; + await act(async () => { + sent = await context.result.current.sendQueuedMessage(0); + }); + + expect(sent).toBe(false); + expect(context.sendMessage).not.toHaveBeenCalled(); + }); + }); + it("captures selection context when the message is created and reuses it when a queued message is later steered, instead of re-reading it at flush time", async () => { const queueTimeActiveSelection: ActiveSelection = { filepath: "/workspace/queued.ts", diff --git a/packages/vscode-webui/src/features/chat/hooks/use-chat-submit.ts b/packages/vscode-webui/src/features/chat/hooks/use-chat-submit.ts index 0a82c06560..b27b312546 100644 --- a/packages/vscode-webui/src/features/chat/hooks/use-chat-submit.ts +++ b/packages/vscode-webui/src/features/chat/hooks/use-chat-submit.ts @@ -47,6 +47,11 @@ export interface DraftMessage { }; } +interface SendChatMessageOptions { + /** Keeps the guard instead of resetting it to "auto", for non user intent. */ + keepAutoApproveGuard?: boolean; +} + interface UseChatSubmitProps { chat: UseChatReturn; input: ChatInput; @@ -281,7 +286,7 @@ export function useChatSubmit({ ); const sendChatMessage = useCallback( - async (message: DraftMessage) => { + async (message: DraftMessage, options?: SendChatMessageOptions) => { const shouldCreateTodo = message.raw.isTodoMode && canCreateTodo; if (message.raw.text && shouldCreateTodo) { onBeforeSendText?.(message.raw.text); @@ -291,7 +296,9 @@ export function useChatSubmit({ pendingApproval.stopCountdown(); } - autoApproveGuard.current = "auto"; + if (!options?.keepAutoApproveGuard) { + autoApproveGuard.current = "auto"; + } await sendMessage({ parts: message.parts, }); @@ -441,10 +448,31 @@ export function useChatSubmit({ ], ); + /** + * Sends a queued message without the steer stop-and-wait, only for callers + * where starting a request is already legal. + */ + const sendQueuedMessage = useCallback( + async (index: number, options?: SendChatMessageOptions) => { + logger.debug("sendQueuedMessage"); + + const message = queuedMessages[index]; + if (!message) { + return false; + } + + setQueuedMessages((messages) => messages.filter((_, i) => i !== index)); + await sendChatMessage(message, options); + return true; + }, + [queuedMessages, setQueuedMessages, sendChatMessage], + ); + return { handleSubmit, handleSteerSubmit, handleSteerQueuedMessage, handleStop, + sendQueuedMessage, }; } diff --git a/packages/vscode-webui/src/features/chat/lib/background-job-notification-queue.test.ts b/packages/vscode-webui/src/features/chat/lib/background-job-notification-queue.test.ts index 022a9f3a38..631976bcf2 100644 --- a/packages/vscode-webui/src/features/chat/lib/background-job-notification-queue.test.ts +++ b/packages/vscode-webui/src/features/chat/lib/background-job-notification-queue.test.ts @@ -4,6 +4,8 @@ import type { DraftMessage } from "../hooks/use-chat-submit"; import { enqueueBackgroundJobNotifications, getBackgroundJobNotificationIds, + getDeliverableBackgroundJobNotificationIndex, + isBackgroundJobNotificationMessage, } from "./background-job-notification-queue"; describe("enqueueBackgroundJobNotifications", () => { @@ -70,6 +72,67 @@ describe("enqueueBackgroundJobNotifications", () => { }); }); +describe("isBackgroundJobNotificationMessage", () => { + it("detects a notification-only queue entry", () => { + const [message] = enqueueBackgroundJobNotifications( + [], + [notification("bgjob-cmd-1")], + ); + + expect(isBackgroundJobNotificationMessage(message)).toBe(true); + }); + + it("rejects user typed and mixed messages", () => { + expect(isBackgroundJobNotificationMessage(regularMessage())).toBe(false); + expect( + isBackgroundJobNotificationMessage({ + parts: [ + { type: "text", text: "hello" }, + { + type: "data-background-job-notification", + data: notification("bgjob-cmd-1"), + }, + ], + }), + ).toBe(false); + expect(isBackgroundJobNotificationMessage({ parts: [] })).toBe(false); + }); +}); + +describe("getDeliverableBackgroundJobNotificationIndex", () => { + it("delivers a notification waiting at the head of the queue", () => { + const messages = enqueueBackgroundJobNotifications( + [], + [notification("bgjob-cmd-1")], + ); + + expect(getDeliverableBackgroundJobNotificationIndex(messages)).toBe(0); + }); + + it("delivers nothing when the queue is empty", () => { + expect(getDeliverableBackgroundJobNotificationIndex([])).toBeUndefined(); + }); + + it("delivers nothing while a queued user message is ahead of it", () => { + const messages = enqueueBackgroundJobNotifications( + [regularMessage()], + [notification("bgjob-cmd-1")], + ); + + expect(messages).toHaveLength(2); + expect( + getDeliverableBackgroundJobNotificationIndex(messages), + ).toBeUndefined(); + }); +}); + +function regularMessage(text = "hello"): DraftMessage { + return { + parts: [{ type: "text", text }], + raw: { text }, + }; +} + function notification(backgroundJobId: string): BackgroundJobNotification { return { notificationId: `${backgroundJobId}:terminal`, diff --git a/packages/vscode-webui/src/features/chat/lib/background-job-notification-queue.ts b/packages/vscode-webui/src/features/chat/lib/background-job-notification-queue.ts index a85f2da8a6..e6a753618a 100644 --- a/packages/vscode-webui/src/features/chat/lib/background-job-notification-queue.ts +++ b/packages/vscode-webui/src/features/chat/lib/background-job-notification-queue.ts @@ -11,11 +11,29 @@ export function getBackgroundJobNotificationIds( ); } +export function isBackgroundJobNotificationMessage( + message: Pick, +): boolean { + return ( + message.parts.length > 0 && + message.parts.every( + (part) => part.type === "data-background-job-notification", + ) + ); +} + /** - * Adds notifications to one non-removable queue entry. If a notification - * entry is already waiting, new parts are merged into it so one dequeue sends - * every notification available at that send point in a single user message. + * Only the head is deliverable mid loop: queued user input is sent by an + * explicit steer, and delivering from behind it would reorder the queue. */ +export function getDeliverableBackgroundJobNotificationIndex( + messages: readonly Pick[], +): number | undefined { + const head = messages[0]; + return head && isBackgroundJobNotificationMessage(head) ? 0 : undefined; +} + +/** Merges notifications into one non-removable queue entry. */ export function enqueueBackgroundJobNotifications( messages: DraftMessage[], notifications: readonly BackgroundJobNotification[], @@ -38,13 +56,7 @@ export function enqueueBackgroundJobNotifications( type: "data-background-job-notification" as const, data: notification, })); - const existingIndex = messages.findIndex( - (message) => - message.parts.length > 0 && - message.parts.every( - (part) => part.type === "data-background-job-notification", - ), - ); + const existingIndex = messages.findIndex(isBackgroundJobNotificationMessage); if (existingIndex === -1) { return [ diff --git a/packages/vscode-webui/src/features/chat/page.tsx b/packages/vscode-webui/src/features/chat/page.tsx index b3694f9323..95b1a117e8 100644 --- a/packages/vscode-webui/src/features/chat/page.tsx +++ b/packages/vscode-webui/src/features/chat/page.tsx @@ -90,6 +90,10 @@ function Chat({ user, uid, info }: ChatProps) { const todoPausedRef = useLatest(todoPaused); const todoModeActiveRef = useRef(false); const lastAutoContinueStateRef = useRef(undefined); + // Filled in by , which owns the queued messages. + const deliverBackgroundJobNotificationsRef = useRef<() => boolean>( + () => false, + ); const { initSubtaskAutoApproveSettings } = useSettingsStore(); const defaultUser = { name: t("chatPage.defaultUserName"), @@ -240,6 +244,15 @@ function Chat({ user, uid, info }: ChatProps) { return true; }; + // The notification starts this continuation request itself. Running + // after the decision keeps every intentional pause intact. + const continueAutomatically = (shouldContinue: boolean) => { + if (!shouldContinue) { + return false; + } + return !deliverBackgroundJobNotificationsRef.current(); + }; + if (chatAbortController.current.signal.aborted) { return false; } @@ -257,7 +270,9 @@ function Chat({ user, uid, info }: ChatProps) { const shouldContinueTodo = getTodoContinuationDecision(candidateMessages); if (shouldContinueTodo !== undefined) { - return claimAutoContinue(!todoPausedRef.current && shouldContinueTodo); + return continueAutomatically( + claimAutoContinue(!todoPausedRef.current && shouldContinueTodo), + ); } if (shouldStopAutoApprove({ messages: candidateMessages })) { @@ -268,10 +283,12 @@ function Chat({ user, uid, info }: ChatProps) { return false; } - return claimAutoContinue( - lastAssistantMessageIsCompleteWithToolCalls({ - messages: candidateMessages, - }), + return continueAutomatically( + claimAutoContinue( + lastAssistantMessageIsCompleteWithToolCalls({ + messages: candidateMessages, + }), + ), ); }, onOverrideMessages, @@ -497,6 +514,9 @@ function Chat({ user, uid, info }: ChatProps) { isRepairingMermaid={!!repairingChart} mcpConfigOverride={mcpConfigOverride} getSystemPrompt={() => chatKit.latestSystemPrompt} + deliverBackgroundJobNotificationsRef={ + deliverBackgroundJobNotificationsRef + } onToolCallApprovalVisible={onToolCallApprovalVisible} onToolsExecutionStarted={chatKit.markStartToolsExecution} onToolsExecutionEnded={chatKit.markEndToolsExecution}