diff --git a/LAWS/CHAT.md b/LAWS/CHAT.md index 6bbd789d8..917e91747 100644 --- a/LAWS/CHAT.md +++ b/LAWS/CHAT.md @@ -1,39 +1,39 @@ -# Sending messages from the composer +# Composer queues and session dispatch -## Sending +## Queue acceptance and dispatch -- Every message sent from the composer MUST go through the queue. -- The queue MUST keep messages in the order they were added. -- A queued message MUST retain the message and persona intent accepted when it was added or last edited. -- Each send attempt MUST use one authoritative session model and provider from the start of preparation through dispatch. -- A message that is not first in the queue MUST NOT be sent. -- A message MUST NOT be sent until its session is ready. -- A session MUST be considered ready if and only if it exists, its preparation is complete, and it can accept a message. -- The queue MUST resume sending when the session becomes ready. +- The composer MUST queue every accepted message into the selected chat's queue, including before that chat's session is ready. +- The composer MUST queue accepted messages into the selected chat's queue in their acceptance order. +- The selected chat's queue MUST retain the message and persona intent most recently accepted from the composer or a user edit. +- A chat's queue MUST NOT dispatch a message to that chat's session before every message ahead of it in the queue. +- A message MUST NOT be dispatched from the queue until its session is ready. +- A session MUST be ready for dispatch from its queue only when it can begin processing that queue's first message. +- When a chat's session becomes ready, that chat's queue MUST resume dispatching its first message to that session. -## Success and failure +## Dispatch outcomes -- A message MUST remain in the queue until its session begins processing it or the user removes it. -- A message MUST produce at most one user turn, including across retries. -- A failed message MUST remain first in the queue. -- A message MUST NOT have more than one active send attempt. -- A send result MUST affect only the message and attempt that produced it. +- A chat's queue MUST NOT dequeue a message before that chat's session begins processing it. +- A user action to remove a message from a chat's queue MUST remove only the selected message from that queue. +- A chat's queue MUST NOT dispatch a message to that chat's session in a way that creates more than one user turn, including after a failed dispatch. +- A failed dispatch from a chat's queue to its session MUST leave the message first in that queue. +- A chat's queue MUST NOT dispatch a second copy of a message to that chat's session while the first dispatch is unresolved. +- A dispatch outcome from a chat's queue to its session MUST NOT change any other message in that queue. -## Editing and removal +## Queue editing and removal -- Editing a queued message MUST NOT change its position. -- Removing a queued message MUST NOT change the order of the remaining messages. -- Canceling an edit MUST leave the message unchanged. -- Sending a queued message MUST NOT alter text entered in the composer after that message was queued. +- A user edit to a message in a chat's queue MUST NOT change that message's position in the queue. +- A user removal from a chat's queue MUST NOT change the order of messages remaining in that queue. +- A user cancellation of an edit MUST leave the selected message unchanged in that chat's queue. +- Dispatching a message from a chat's queue to its session MUST NOT alter text entered later in that chat's composer. -## Steering +## Queue steering -- A message that is not first in the queue MUST NOT steer the session. -- A steering result MUST affect only the message that produced it. -- While the session is running, a send shortcut with an empty composer MUST steer the first queued message when steering is available. -- A send shortcut MUST NOT steer a queued message while the composer holds draft content or a queued message is being edited. +- A message that is not first in a chat's queue MUST NOT be steered from that queue to that chat's session. +- A steering outcome from a chat's queue to its session MUST NOT change any other message in that queue. +- While a chat's session is running, an empty-composer shortcut MUST steer the first message from that chat's queue to that session when steering is available. +- A composer shortcut MUST NOT steer a message from a chat's queue to that chat's session while the composer contains draft text or a message in that queue is being edited. -## Subagent activity +## Session activity presentation -- Subagent activity MUST attribute the subagent when its identity is known. -- Subagent activity MUST describe the delegated task when it is known. +- A session's subagent activity MUST appear in the chat transcript with the subagent identity when known. +- A session's subagent activity MUST appear in the chat transcript with the delegated task when known. diff --git a/src/app/AppShell.navigation.test.tsx b/src/app/AppShell.navigation.test.tsx index c4d6e4a24..2f64d8e68 100644 --- a/src/app/AppShell.navigation.test.tsx +++ b/src/app/AppShell.navigation.test.tsx @@ -15,14 +15,12 @@ import { i18n } from "@/shared/i18n"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { getAppNavigationController } from "@/features/berdctl/navigation"; -import { placeholderAgentName } from "@/features/agents/lib/agentBuilderIdentity"; import { resetAgentBuilderSourceLifecycleForTests } from "@/features/agents/lib/agentBuilderSourceLifecycle"; import { useChatStore } from "@/features/chat/stores/chatStore"; import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; import { ensureReplayBuffer } from "@/features/chat/hooks/replayBuffer"; import { createUserMessage } from "@/shared/types/messages"; import { useSessionWindowStore } from "@/features/chat/stores/sessionWindowStore"; -import { BackgroundQueuedMessageDrain } from "@/features/chat/ui/BackgroundQueuedMessageDrain"; import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; import type { Message } from "@/shared/types/messages"; import type { GitState } from "@/shared/types/git"; @@ -97,10 +95,6 @@ const mockCreatePersonaSource = vi.hoisted(() => vi.fn()); const mockListPersonaSources = vi.hoisted(() => vi.fn()); const mockReadAgentSourceFile = vi.hoisted(() => vi.fn()); const mockDeletePersonaSource = vi.hoisted(() => vi.fn()); -const mockUpdatePersonaSource = vi.hoisted(() => vi.fn()); -const mockSendQueuedPromptToExistingSessionInBackground = vi.hoisted(() => - vi.fn(), -); const mockListPersonas = vi.hoisted(() => vi.fn()); const mockRepairBundledAgent = vi.hoisted(() => vi.fn()); const mockAutomationBuilderSave = vi.hoisted(() => vi.fn()); @@ -151,10 +145,7 @@ function flushAfterNextPaintCallbacks() { } } -function appShellWithTheme( - children?: ReactNode, - options?: { backgroundQueueDrain?: boolean }, -) { +function appShellWithTheme(children?: ReactNode) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); @@ -162,19 +153,13 @@ function appShellWithTheme( {children} - {options?.backgroundQueueDrain ? ( - - ) : null} ); } -function renderAppShell( - children?: ReactNode, - options?: { backgroundQueueDrain?: boolean }, -) { - return render(appShellWithTheme(children, options)); +function renderAppShell(children?: ReactNode) { + return render(appShellWithTheme(children)); } function managedWorktreeGitState( @@ -465,11 +450,6 @@ vi.mock("@/shared/api/acpApi", () => ({ updateSessionProject: vi.fn().mockResolvedValue(undefined), })); -vi.mock("@/features/chat/lib/queuedSessionSend", () => ({ - sendQueuedPromptToExistingSessionInBackground: (...args: unknown[]) => - mockSendQueuedPromptToExistingSessionInBackground(...args), -})); - vi.mock("@/shared/api/git", () => ({ countBranchCommitsNotInBase: (...args: unknown[]) => gitMocks.countBranchCommitsNotInBase(...args), @@ -499,7 +479,6 @@ vi.mock("@/shared/api/agents", () => ({ repairBundledAgent: (...args: unknown[]) => mockRepairBundledAgent(...args), readAgentSourceFile: (...args: unknown[]) => mockReadAgentSourceFile(...args), deletePersonaSource: (...args: unknown[]) => mockDeletePersonaSource(...args), - updatePersonaSource: (...args: unknown[]) => mockUpdatePersonaSource(...args), promotePersonaSource: vi.fn().mockResolvedValue(null), })); @@ -1044,11 +1023,6 @@ describe("AppShell global navigation", () => { mockReadAgentSourceFile.mockRejectedValue(new Error("not found")); mockDeletePersonaSource.mockReset(); mockDeletePersonaSource.mockResolvedValue(undefined); - mockUpdatePersonaSource.mockReset(); - mockSendQueuedPromptToExistingSessionInBackground.mockReset(); - mockSendQueuedPromptToExistingSessionInBackground.mockResolvedValue( - undefined, - ); mockAutomationBuilderSave.mockReset(); useChatStore.setState({ messagesBySession: {}, @@ -3419,162 +3393,6 @@ describe("AppShell global navigation", () => { }); }); - it("holds a queued builder prompt until draft identity migration completes", async () => { - const pendingSession = deferred<{ sessionId: string }>(); - const pendingMigration = deferred<{ - type: "agent"; - path: string; - name: string; - description: string; - content: string; - global: boolean; - writable: boolean; - properties: { draft: true; builderSessionId: string }; - }>(); - mockAcpCreateSession.mockReturnValueOnce(pendingSession.promise); - const user = userEvent.setup(); - renderAppShell(undefined, { backgroundQueueDrain: true }); - - await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); - await waitFor(() => expect(mockAcpCreateSession).toHaveBeenCalled()); - - const draftSessionId = useChatSessionStore.getState().activeSessionId ?? ""; - const targetAgentPath = - "/Users/test/.agents/agents/pending-builder-draft.md"; - const localDraft = { - type: "agent" as const, - path: targetAgentPath, - name: placeholderAgentName(draftSessionId), - description: "Draft", - content: "Draft in progress.", - global: true, - writable: true, - properties: { draft: true, builderSessionId: draftSessionId }, - }; - useChatSessionStore.getState().patchSession(draftSessionId, { - intent: "build-agent", - agentBuilderOpen: true, - targetAgentPath, - targetAgentSlug: "pending-builder-draft", - }); - mockListPersonaSources.mockResolvedValue([localDraft]); - mockReadAgentSourceFile.mockResolvedValue(localDraft); - mockUpdatePersonaSource.mockReturnValueOnce(pendingMigration.promise); - useChatStore.getState().enqueueTransportReadyMessage(draftSessionId, { - text: "make a reviewer", - persona: { kind: "inherit" }, - sendOptions: { - assistantPrompt: `Use agent-builder at ${targetAgentPath}.`, - }, - }); - useChatSessionStore.setState({ hasHydratedSessions: true }); - - act(() => pendingSession.resolve({ sessionId: "created-session" })); - - await waitFor(() => { - expect(mockUpdatePersonaSource).toHaveBeenCalledWith(targetAgentPath, { - name: placeholderAgentName("created-session"), - properties: { - draft: true, - builderSessionId: "created-session", - }, - }); - }); - expect( - useChatSessionStore.getState().getSession(draftSessionId), - ).toMatchObject({ creationState: "pending" }); - expect( - useChatStore.getState().queuedMessageBySession[draftSessionId]?.[0], - ).toMatchObject({ payload: { text: "make a reviewer" } }); - expect( - mockSendQueuedPromptToExistingSessionInBackground, - ).not.toHaveBeenCalled(); - - act(() => { - pendingMigration.resolve({ - ...localDraft, - name: placeholderAgentName("created-session"), - properties: { - draft: true, - builderSessionId: "created-session", - }, - }); - }); - - await waitFor(() => { - expect( - mockSendQueuedPromptToExistingSessionInBackground, - ).toHaveBeenCalledTimes(1); - }); - expect( - mockSendQueuedPromptToExistingSessionInBackground.mock.calls[0]?.[0], - ).toBe("created-session"); - expect(useChatSessionStore.getState().activeSessionId).toBe( - "created-session", - ); - }); - - it("fails creation without dispatch when builder draft migration fails", async () => { - const pendingSession = deferred<{ sessionId: string }>(); - mockAcpCreateSession.mockReturnValueOnce(pendingSession.promise); - const user = userEvent.setup(); - renderAppShell(undefined, { backgroundQueueDrain: true }); - - await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); - await waitFor(() => expect(mockAcpCreateSession).toHaveBeenCalled()); - - const draftSessionId = useChatSessionStore.getState().activeSessionId ?? ""; - const targetAgentPath = - "/Users/test/.agents/agents/pending-builder-draft.md"; - const localDraft = { - type: "agent" as const, - path: targetAgentPath, - name: placeholderAgentName(draftSessionId), - description: "Draft", - content: "Draft in progress.", - global: true, - writable: true, - properties: { draft: true, builderSessionId: draftSessionId }, - }; - useChatSessionStore.getState().patchSession(draftSessionId, { - intent: "build-agent", - agentBuilderOpen: true, - targetAgentPath, - targetAgentSlug: "pending-builder-draft", - }); - mockListPersonaSources.mockResolvedValue([localDraft]); - mockReadAgentSourceFile.mockResolvedValue(localDraft); - mockUpdatePersonaSource.mockRejectedValueOnce( - new Error("draft identity update failed"), - ); - useChatStore.getState().enqueueTransportReadyMessage(draftSessionId, { - text: "make a reviewer", - persona: { kind: "inherit" }, - }); - useChatSessionStore.setState({ hasHydratedSessions: true }); - - act(() => pendingSession.resolve({ sessionId: "created-session" })); - - await waitFor(() => { - expect(mockAcpArchiveSession).toHaveBeenCalledWith("created-session"); - expect( - useChatSessionStore.getState().getSession(draftSessionId), - ).toMatchObject({ - creationState: "failed", - creationError: "draft identity update failed", - }); - }); - expect( - useChatStore.getState().queuedMessageBySession[draftSessionId]?.[0], - ).toMatchObject({ payload: { text: "make a reviewer" } }); - expect( - useChatStore.getState().queuedMessageBySession["created-session"], - ).toBeUndefined(); - expect( - mockSendQueuedPromptToExistingSessionInBackground, - ).not.toHaveBeenCalled(); - }); - it("applies the latest pending draft selection before promotion", async () => { const pendingSession = deferred<{ sessionId: string }>(); const pendingPrepare = deferred>(); diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 71b588b86..410e680c6 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -114,7 +114,6 @@ import { setSettingsSectionUrl, } from "./lib/settingsSectionUrl"; import { useAgentBuilderCoordinator } from "@/features/agents/hooks/useAgentBuilderCoordinator"; -import { migratePendingDraftAgent } from "@/features/agents/lib/agentBuilderSession"; import { type ArchiveCleanupPolicy, MUTATION_DEADLINE_MARGIN_MS, @@ -1975,13 +1974,6 @@ export function AppShell({ pendingSelectionIntent.requestId, ); } - if (latestSessionPatch.targetAgentPath) { - await migratePendingDraftAgent( - session.id, - sessionId, - latestSessionPatch.targetAgentPath, - ); - } promoteChatSessionId(session.id, sessionId); transferSessionTargetOwnership(session.id, sessionId); promoteDraftSession(session.id, sessionId, { diff --git a/src/features/agents/lib/__tests__/agentBuilderSession.test.ts b/src/features/agents/lib/__tests__/agentBuilderSession.test.ts index da0ffc934..49211ceb6 100644 --- a/src/features/agents/lib/__tests__/agentBuilderSession.test.ts +++ b/src/features/agents/lib/__tests__/agentBuilderSession.test.ts @@ -29,7 +29,6 @@ const mocks = vi.hoisted(() => ({ createPersonaSource: vi.fn(), deletePersonaSource: vi.fn(), promotePersonaSource: vi.fn(), - updatePersonaSource: vi.fn(), listPersonaSources: vi.fn(), readAgentSourceFile: vi.fn(), })); @@ -90,7 +89,6 @@ vi.mock("@/shared/api/agents", () => ({ createPersonaSource: mocks.createPersonaSource, deletePersonaSource: mocks.deletePersonaSource, promotePersonaSource: mocks.promotePersonaSource, - updatePersonaSource: mocks.updatePersonaSource, listPersonaSources: mocks.listPersonaSources, readAgentSourceFile: mocks.readAgentSourceFile, })); @@ -105,7 +103,6 @@ import { discardDraftAgentSession, hasAgentBuilderSessionUserContent, isEmptyDraftAgentSession, - migratePendingDraftAgent, promoteDraft, recoverDraftAgent, reconcileAgentBuilderSessions, @@ -172,17 +169,6 @@ describe("agentBuilderSession", () => { mocks.createPersonaSource.mockReset(); mocks.deletePersonaSource.mockReset(); mocks.promotePersonaSource.mockReset(); - mocks.updatePersonaSource.mockReset(); - mocks.updatePersonaSource.mockImplementation(async (path, patch) => { - const existing = await mocks.readAgentSourceFile(path, undefined); - return { - ...existing, - ...patch, - properties: patch.properties - ? { ...(existing?.properties ?? {}), ...patch.properties } - : existing?.properties, - }; - }); mocks.listPersonaSources.mockReset(); mocks.readAgentSourceFile.mockReset(); mocks.readAgentSourceFile.mockImplementation( @@ -838,83 +824,6 @@ describe("agentBuilderSession", () => { expect(mocks.createPersonaSource).toHaveBeenCalled(); }); - it("migrates a pending builder draft to the promoted session id", async () => { - const localDraft = { - ...draftSource, - path: "/Users/x/.agents/agents/draft-local-session.md", - name: "Untitled agent local-sessio", - properties: { draft: true, builderSessionId: "local-session" }, - }; - chatState.sessions = [ - { - id: "backend-session", - clientSessionId: "local-session", - intent: "build-agent", - targetAgentPath: localDraft.path, - }, - ]; - mocks.listPersonaSources.mockResolvedValue([localDraft]); - mocks.readAgentSourceFile.mockResolvedValue(localDraft); - - await migratePendingDraftAgent("local-session", "backend-session"); - - expect(mocks.deletePersonaSource).not.toHaveBeenCalled(); - expect(mocks.createPersonaSource).not.toHaveBeenCalled(); - expect(mocks.patchSession).not.toHaveBeenCalled(); - expect(mocks.promotePersonaSource).not.toHaveBeenCalled(); - expect(mocks.updatePersonaSource).toHaveBeenCalledWith(localDraft.path, { - name: "Untitled agent backend-sess", - properties: { - draft: true, - builderSessionId: "backend-session", - }, - }); - }); - - it("rejects migration when the draft path belongs to another session", async () => { - const collidingDraft = { - ...draftSource, - path: "/Users/x/.agents/agents/colliding-draft.md", - name: "Collision", - properties: { draft: true, builderSessionId: "other-session" }, - }; - mocks.listPersonaSources.mockResolvedValue([collidingDraft]); - mocks.readAgentSourceFile.mockResolvedValue(collidingDraft); - - await expect( - migratePendingDraftAgent( - "local-session", - "backend-session", - collidingDraft.path, - ), - ).rejects.toThrow( - "Pending Agent Builder draft belongs to a different session.", - ); - - expect(mocks.updatePersonaSource).not.toHaveBeenCalled(); - }); - - it("does not rebind an existing non-draft agent target", async () => { - const existingAgent = { - ...draftSource, - path: "/Users/x/.agents/agents/reviewer.md", - name: "Reviewer", - properties: { draft: false }, - }; - mocks.listPersonaSources.mockResolvedValue([existingAgent]); - mocks.readAgentSourceFile.mockResolvedValue(existingAgent); - - await expect( - migratePendingDraftAgent( - "local-session", - "backend-session", - existingAgent.path, - ), - ).resolves.toBeUndefined(); - - expect(mocks.updatePersonaSource).not.toHaveBeenCalled(); - }); - it("startup reconciliation patches loaded sessions from draft frontmatter", async () => { chatState.sessions = [{ id: "sess-1" }]; mocks.listPersonaSources.mockResolvedValue([draftSource]); diff --git a/src/features/agents/lib/agentBuilderSession.ts b/src/features/agents/lib/agentBuilderSession.ts index 4dd8983d9..277f55159 100644 --- a/src/features/agents/lib/agentBuilderSession.ts +++ b/src/features/agents/lib/agentBuilderSession.ts @@ -22,17 +22,13 @@ import { listAgentBuilderSources, promoteAgentBuilderDraftSource, readFreshAgentSource, - updateAgentBuilderSource, type DraftAgentDefaults, - type PersonaSourcePatch, } from "./agentBuilderSourceLifecycle"; import type { AgentSourceEntry } from "@/shared/api/agents"; import { deriveSlug, fileStem, isEmptyPlaceholderDraft, - isPlaceholderDraftForSession, - placeholderAgentName, } from "./agentBuilderIdentity"; export { deriveSlug, @@ -187,7 +183,7 @@ export async function startAgentBuilderSession( await deps.navigateChat(provisionalSessionId); void prepareProvisionalDraftTarget(sessionId).catch((error) => { console.error("Failed to prepare agent builder draft:", error); - markProvisionalDraftTargetFailed(sessionId); + markAgentBuilderSessionPreparationFailed(sessionId); }); return sessionId; } @@ -224,7 +220,9 @@ async function prepareProvisionalDraftTarget( }); } -function markProvisionalDraftTargetFailed(initialSessionId: string): void { +export function markAgentBuilderSessionPreparationFailed( + initialSessionId: string, +): void { const session = findSessionByInitialId(initialSessionId); if ( !session || @@ -373,50 +371,6 @@ export async function preSeedDraftAgent( }); } -export async function migratePendingDraftAgent( - draftSessionId: string, - backendSessionId: string, - targetPath?: string | null, -): Promise { - const resolvedTargetPath = - targetPath ?? - useChatSessionStore.getState().getSession(backendSessionId) - ?.targetAgentPath; - if (!resolvedTargetPath) { - return; - } - - const source = await findAgentBuilderSource( - draftSessionId, - resolvedTargetPath, - ); - if (!source) { - throw new Error( - "Pending Agent Builder target could not be verified before session promotion.", - ); - } - if (source.properties?.draft !== true) { - return; - } - if (source.properties.builderSessionId !== draftSessionId) { - throw new Error( - "Pending Agent Builder draft belongs to a different session.", - ); - } - - const patch: PersonaSourcePatch = { - properties: { - ...source.properties, - builderSessionId: backendSessionId, - }, - }; - if (isPlaceholderDraftForSession(source, draftSessionId)) { - patch.name = placeholderAgentName(backendSessionId); - } - - await updateAgentBuilderSource(source.path, patch); -} - export async function recoverDraftAgent( sessionId: string, stalePath?: string | null, diff --git a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts index 2ebe191f9..d12c33d75 100644 --- a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts +++ b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts @@ -42,19 +42,11 @@ const mockUseChatSteerMessage = vi.fn(); const mockTrackChatMessageSent = vi.fn(); const mockTrackChatSessionStarted = vi.fn(); const mockUseChatHook = vi.fn(); -const mockUseChatOptionsBySession = new Map< - string, - { - onMessageAccepted?: ( - sessionId: string, - text: string, - ) => boolean | undefined; - } ->(); const mockUseMessageQueue = vi.fn(); const mockPickerOpen = vi.fn(); const mockPreSeedDraftAgent = vi.fn(); -const mockMigratePendingDraftAgent = vi.fn(); +const mockClearBuilderSessionState = vi.fn(); +const mockMarkAgentBuilderSessionPreparationFailed = vi.fn(); const mockDeletePersonaSource = vi.fn(); const mockAcpCreateSession = vi.fn(); const mockAcpSessionArchive = vi.fn(); @@ -170,7 +162,6 @@ vi.mock("../useChat", () => ({ systemPromptOverride, personaInfo, ); - mockUseChatOptionsBySession.set(sessionId, options ?? {}); const optionsWithSessionId = { ...options, __sessionId: sessionId }; return { messages: [], @@ -204,8 +195,10 @@ vi.mock("../useAutoCompactPreferences", () => ({ vi.mock("@/features/agents/lib/agentBuilderSession", () => ({ preSeedDraftAgent: (...args: unknown[]) => mockPreSeedDraftAgent(...args), - migratePendingDraftAgent: (...args: unknown[]) => - mockMigratePendingDraftAgent(...args), + clearBuilderSessionState: (...args: unknown[]) => + mockClearBuilderSessionState(...args), + markAgentBuilderSessionPreparationFailed: (...args: unknown[]) => + mockMarkAgentBuilderSessionPreparationFailed(...args), })); vi.mock("@/shared/api/agents", () => ({ @@ -309,29 +302,6 @@ function latestMessageQueueArgs() { ]; } -function mockQueueAdmissionWithoutDrain(): void { - mockUseMessageQueue.mockImplementation((sessionId: string) => ({ - queuedMessage: null, - queuedRecords: [], - enqueue: ( - text: string, - personaId?: string, - attachments?: ChatAttachmentDraft[], - sendOptions?: ChatSendOptions, - personaName?: string, - ) => - useChatStore.getState().enqueueTransportReadyMessage(sessionId, { - text, - persona: personaId - ? { kind: "persona", id: personaId, name: personaName } - : { kind: "inherit" }, - attachments, - sendOptions, - }), - dismiss: vi.fn(), - })); -} - function expectSessionPreparation({ sessionId, modelProviderId, @@ -436,7 +406,6 @@ describe("useChatSessionController", () => { beforeEach(() => { resetSessionTargetCoordinatorsForTests(); vi.clearAllMocks(); - mockUseChatOptionsBySession.clear(); delete modelFixtures["legacy-v1-model"]; resetManagedModelSelectionRepairCacheForTests(); useRuntimeConfigStore.setState({ @@ -572,7 +541,6 @@ describe("useChatSessionController", () => { path: "/Users/x/.agents/agents/draft-from-chat.md", slug: "draft-from-chat", }); - mockMigratePendingDraftAgent.mockResolvedValue(undefined); mockPickerState.selectedAgentId = "goose"; mockPickerState.pickerAgents = [{ id: "goose", label: "Goose" }]; mockPickerState.availableModels = []; @@ -852,84 +820,6 @@ describe("useChatSessionController", () => { expect(sendResult).toBe(true); }); - it("accepts a composer message into a pending draft session queue", () => { - mockQueueAdmissionWithoutDrain(); - useChatSessionStore.setState({ - sessions: [ - sessionFixture({ - id: "draft-session", - clientSessionId: "draft-session", - creationState: "pending", - executionTarget: { - harnessId: "goose", - modelProviderId: "openai", - modelId: "gpt-4o", - modelName: "GPT-4o", - }, - }), - ], - }); - - const { result } = renderHook(() => - useChatSessionController({ sessionId: "draft-session" }), - ); - - act(() => { - expect(result.current.handleSend("send when ready")).toBe(true); - }); - - expect(mockUseChatSendMessage).not.toHaveBeenCalled(); - expect( - useChatStore.getState().queuedMessageBySession["draft-session"]?.[0], - ).toMatchObject({ - kind: "transport-ready", - payload: { - text: "send when ready", - persona: { kind: "inherit" }, - }, - }); - }); - - it("preserves a newer draft when a pending send drains during promotion", () => { - mockQueueAdmissionWithoutDrain(); - useChatSessionStore.setState({ - sessions: [ - sessionFixture({ - id: "draft-session", - clientSessionId: "draft-session", - creationState: "pending", - executionTarget: { - harnessId: "goose", - modelProviderId: "openai", - modelId: "gpt-4o", - modelName: "GPT-4o", - }, - }), - ], - }); - - const { result } = renderHook(() => - useChatSessionController({ sessionId: "draft-session" }), - ); - const acceptMessage = - mockUseChatOptionsBySession.get("draft-session")?.onMessageAccepted; - let shouldClearDraft: boolean | undefined; - - act(() => { - expect(result.current.handleSend("send when ready")).toBe(true); - result.current.handleDraftChange("newer draft"); - useChatStore - .getState() - .promoteSessionId("draft-session", "backend-session"); - useChatSessionStore - .getState() - .promoteDraftSession("draft-session", "backend-session"); - shouldClearDraft = acceptMessage?.("backend-session", "send when ready"); - }); - - expect(shouldClearDraft).toBe(false); - }); - it("preserves a newer draft when an older send is accepted later", async () => { vi.useFakeTimers(); try { @@ -1120,25 +1010,469 @@ describe("useChatSessionController", () => { }); rerender({ sessionId: "backend-session" }); - expect(useChatStore.getState().draftsBySession["backend-session"]).toBe( - "draft during promotion", - ); - expect( - useChatStore.getState().draftsBySession["draft-session"], - ).toBeUndefined(); + expect(useChatStore.getState().draftsBySession["backend-session"]).toBe( + "draft during promotion", + ); + expect( + useChatStore.getState().draftsBySession["draft-session"], + ).toBeUndefined(); + + act(() => { + vi.advanceTimersByTime(300); + }); + expect(useChatStore.getState().draftsBySession["backend-session"]).toBe( + "draft during promotion", + ); + expect( + useChatStore.getState().draftsBySession["draft-session"], + ).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + + it("accepts a send into the queue while a draft session is pending", () => { + useChatSessionStore.setState({ + sessions: [ + sessionFixture({ + id: "draft-session", + clientSessionId: "draft-session", + executionTarget: { + harnessId: "goose", + modelProviderId: "openai", + }, + creationState: "pending", + }), + ], + }); + + const { result } = renderHook(() => + useChatSessionController({ sessionId: "draft-session" }), + ); + + act(() => { + expect(result.current.handleSend("send when ready")).toBe(true); + }); + expect(mockUseChatSendMessage).not.toHaveBeenCalled(); + expect( + useChatStore.getState().queuedMessageBySession["draft-session"]?.[0], + ).toMatchObject({ + kind: "transport-ready", + payload: { text: "send when ready" }, + }); + }); + + it("preserves a newer draft when a pending send drains after promotion", () => { + vi.useFakeTimers(); + try { + let acceptCommittedMessage!: (sessionId: string, text: string) => void; + mockUseChatSendMessage.mockImplementationOnce( + (options?: { + onMessageAccepted?: ( + sessionId: string, + text: string, + ) => boolean | undefined; + }) => { + acceptCommittedMessage = (sessionId, text) => { + if (options?.onMessageAccepted?.(sessionId, text) !== false) { + useChatStore.getState().clearDraft(sessionId); + } + }; + }, + ); + useChatSessionStore.setState({ + sessions: [ + sessionFixture({ + id: "draft-session", + clientSessionId: "draft-session", + executionTarget: { + harnessId: "goose", + modelProviderId: "openai", + }, + creationState: "pending", + }), + ], + }); + + const { result, rerender } = renderHook( + ({ sessionId }: { sessionId: string }) => + useChatSessionController({ sessionId }), + { initialProps: { sessionId: "draft-session" } }, + ); + + act(() => { + expect(result.current.handleSend("send when ready")).toBe(true); + result.current.handleDraftChange("newer draft"); + useChatStore + .getState() + .promoteSessionId("draft-session", "backend-session"); + useChatSessionStore + .getState() + .promoteDraftSession("draft-session", "backend-session"); + }); + rerender({ sessionId: "backend-session" }); + const [, , drainQueuedMessage] = latestMessageQueueArgs(); + act(() => { + (drainQueuedMessage as (text: string) => void)("send when ready"); + }); + + act(() => { + acceptCommittedMessage("backend-session", "send when ready"); + vi.advanceTimersByTime(300); + }); + + expect(useChatStore.getState().draftsBySession["backend-session"]).toBe( + "newer draft", + ); + } finally { + vi.useRealTimers(); + } + }); + + it("queues an agent-builder send during session creation and prepares it after promotion", async () => { + useChatSessionStore.setState({ + sessions: [ + sessionFixture({ + id: "draft-session", + clientSessionId: "draft-session", + executionTarget: { + harnessId: "goose", + modelProviderId: "openai", + }, + creationState: "pending", + }), + ], + }); + + const { result, rerender } = renderHook( + ({ sessionId }: { sessionId: string }) => + useChatSessionController({ sessionId }), + { initialProps: { sessionId: "draft-session" } }, + ); + + act(() => { + expect( + result.current.handleSend("make a reviewer", undefined, undefined, { + chips: [{ label: "agent-builder", type: "skill" }], + assistantPrompt: "Use agent-builder.", + }), + ).toBe(true); + }); + + expect(mockPreSeedDraftAgent).not.toHaveBeenCalled(); + expect(mockUseChatSendMessage).not.toHaveBeenCalled(); + expect( + useChatStore.getState().queuedMessageBySession["draft-session"]?.[0], + ).toMatchObject({ + kind: "transport-ready", + payload: { + text: "make a reviewer", + sendOptions: { + chips: [{ label: "agent-builder", type: "skill" }], + }, + }, + }); + + act(() => { + useChatStore + .getState() + .promoteSessionId("draft-session", "backend-session"); + useChatSessionStore + .getState() + .promoteDraftSession("draft-session", "backend-session"); + }); + rerender({ sessionId: "backend-session" }); + + await waitFor(() => { + expect(mockPreSeedDraftAgent).toHaveBeenCalledWith("backend-session"); + }); + }); + + it("discards in-flight Agent Builder preparation when its queue record is removed", async () => { + const pendingDraft = deferred<{ path: string; slug: string }>(); + mockPreSeedDraftAgent.mockReturnValueOnce(pendingDraft.promise); + useChatStore.getState().enqueueTransportReadyMessage("session-1", { + persona: { kind: "inherit" }, + text: "make a reviewer", + sendOptions: { + chips: [{ label: "agent-builder", type: "skill" }], + }, + }); + const queuedRecord = + useChatStore.getState().queuedMessageBySession["session-1"]?.[0]; + + renderHook(() => useChatSessionController({ sessionId: "session-1" })); + + await waitFor(() => { + expect(mockPreSeedDraftAgent).toHaveBeenCalledWith("session-1"); + }); + act(() => { + useChatStore + .getState() + .dismissQueuedMessage("session-1", queuedRecord?.recordId); + }); + await act(async () => { + pendingDraft.resolve({ + path: "/Users/x/.agents/agents/removed-queue-record.md", + slug: "removed-queue-record", + }); + await pendingDraft.promise; + }); + + await waitFor(() => { + expect(mockDeletePersonaSource).toHaveBeenCalledWith( + "/Users/x/.agents/agents/removed-queue-record.md", + ); + }); + const session = useChatSessionStore.getState().getSession("session-1"); + expect(session?.intent).toBeUndefined(); + expect(session?.targetAgentPath).toBeUndefined(); + expect(mockUseChatSendMessage).not.toHaveBeenCalled(); + }); + + it("marks queued Agent Builder preparation as failed without dropping its send", async () => { + mockPreSeedDraftAgent.mockRejectedValueOnce( + new Error("draft creation failed"), + ); + useChatStore.getState().enqueueTransportReadyMessage("session-1", { + persona: { kind: "inherit" }, + text: "make a reviewer", + sendOptions: { + chips: [{ label: "agent-builder", type: "skill" }], + }, + }); + + renderHook(() => useChatSessionController({ sessionId: "session-1" })); + + await waitFor(() => { + expect(mockMarkAgentBuilderSessionPreparationFailed).toHaveBeenCalledWith( + "session-1", + ); + }); + expect( + useChatStore.getState().queuedMessageBySession["session-1"], + ).toHaveLength(1); + }); + + it("defers an eagerly selected Agent Builder draft until promotion", async () => { + useChatSessionStore.setState({ + sessions: [ + sessionFixture({ + id: "draft-session", + clientSessionId: "draft-session", + executionTarget: { + harnessId: "goose", + modelProviderId: "openai", + }, + creationState: "pending", + }), + ], + }); + useChatStore + .getState() + .setSkillDrafts("draft-session", [ + { id: "builtin:agent-builder", name: "agent-builder" }, + ]); + + renderHook(() => useChatSessionController({ sessionId: "draft-session" })); + + await act(async () => { + await Promise.resolve(); + }); + expect(mockPreSeedDraftAgent).not.toHaveBeenCalled(); + }); + + it("marks eagerly selected Agent Builder preparation as failed", async () => { + mockPreSeedDraftAgent.mockRejectedValueOnce(new Error("draft failed")); + useChatStore + .getState() + .setSkillDrafts("session-1", [ + { id: "builtin:agent-builder", name: "agent-builder" }, + ]); + + renderHook(() => useChatSessionController({ sessionId: "session-1" })); + + await waitFor(() => { + expect(mockMarkAgentBuilderSessionPreparationFailed).toHaveBeenCalledWith( + "session-1", + ); + }); + }); + + it("removing a deferred workspace send prevents Agent Builder preparation", () => { + useChatStore.getState().enqueueDeferredMessage( + "draft-session", + { + persona: { kind: "inherit" }, + text: "make a reviewer", + sendOptions: { + chips: [{ label: "agent-builder", type: "skill" }], + }, + }, + { type: "workspace-first-send", status: "choice" }, + ); + useChatSessionStore.setState({ + sessions: [ + sessionFixture({ + id: "draft-session", + clientSessionId: "draft-session", + executionTarget: { + harnessId: "goose", + modelProviderId: "openai", + }, + creationState: "pending", + }), + ], + }); + + const { result } = renderHook(() => + useChatSessionController({ sessionId: "draft-session" }), + ); + const deferredRecord = + useChatStore.getState().queuedMessageBySession["draft-session"]?.[0]; + + act(() => { + result.current.queue.dismiss(deferredRecord?.recordId); + useChatStore + .getState() + .promoteSessionId("draft-session", "backend-session"); + useChatSessionStore + .getState() + .promoteDraftSession("draft-session", "backend-session"); + }); + + expect(mockPreSeedDraftAgent).not.toHaveBeenCalled(); + }); + + it("keeps a promoted builder send parked until its draft target is ready", () => { + useChatSessionStore.setState({ + sessions: [ + sessionFixture({ + intent: "build-agent", + agentBuilderOpen: true, + targetAgentPath: null, + targetAgentDraftState: "preparing", + }), + ], + }); + useChatStore.getState().enqueueTransportReadyMessage("session-1", { + persona: { kind: "inherit" }, + text: "make a reviewer", + sendOptions: { + chips: [{ label: "agent-builder", type: "skill" }], + }, + }); + + const { rerender } = renderHook(() => + useChatSessionController({ sessionId: "session-1" }), + ); + + expect(latestMessageQueueArgs()[1]).toBe("thinking"); + expect(mockUseChatSendMessage).not.toHaveBeenCalled(); + + act(() => { + useChatSessionStore.getState().patchSession("session-1", { + targetAgentPath: "/Users/x/.agents/agents/draft-session-1.md", + targetAgentSlug: "draft-session-1", + targetAgentDraftState: null, + }); + }); + rerender(); + + expect(latestMessageQueueArgs()[1]).toBe("idle"); + const drainSend = latestMessageQueueArgs()[2] as ( + text: string, + persona?: { id: string }, + attachments?: ChatAttachmentDraft[], + sendOptions?: ChatSendOptions, + ) => boolean; + act(() => { + drainSend( + "make a reviewer", + undefined, + undefined, + useChatStore.getState().queuedMessageBySession["session-1"]?.[0] + ?.payload.sendOptions, + ); + }); + + const sendOptions = mockUseChatSendMessage.mock.calls.at(-1)?.[4] as + | ChatSendOptions + | undefined; + expect(sendOptions?.assistantPrompt).toContain("draft-session-1.md"); + }); + + it("routes a pending project first send through workspace startup", () => { + setMultiWorkspaceEnabled(true); + const onWorkspaceNameRequest = vi.fn(); + useProjectStore.setState({ + projects: [ + { + id: "project-1", + path: "/tmp/project.md", + name: "Project", + description: "", + prompt: "", + icon: "", + color: "#22c55e", + projectWorkspaces: [ + { + id: "workspace-1", + path: "/repo/project", + kind: "git-main-worktree", + source: "selected", + branch: "main", + usedByAgent: false, + repositoryPath: "/repo/project", + startupMode: "worktree", + }, + ], + workingDirs: ["/repo/project"], + useWorktrees: true, + order: 0, + archivedAt: null, + artifact: null, + }, + ], + loading: false, + activeProjectId: "project-1", + }); + useChatSessionStore.setState({ + sessions: [ + sessionFixture({ + id: "draft-session", + clientSessionId: "draft-session", + projectId: "project-1", + workingDir: "/repo/project", + workspaceAttachments: [], + executionTarget: { + harnessId: "goose", + modelProviderId: "openai", + }, + creationState: "pending", + }), + ], + }); + + const { result } = renderHook(() => + useChatSessionController({ + sessionId: "draft-session", + onWorkspaceNameRequest, + }), + ); - act(() => { - vi.advanceTimersByTime(300); - }); - expect(useChatStore.getState().draftsBySession["backend-session"]).toBe( - "draft during promotion", - ); - expect( - useChatStore.getState().draftsBySession["draft-session"], - ).toBeUndefined(); - } finally { - vi.useRealTimers(); - } + act(() => { + expect(result.current.handleSend("send after setup")).toBe(true); + }); + + expect(mockUseChatSendMessage).not.toHaveBeenCalled(); + expect( + useChatStore.getState().queuedMessageBySession["draft-session"]?.[0], + ).toMatchObject({ + kind: "deferred", + payload: { text: "send after setup" }, + state: { type: "workspace-first-send", status: "choice" }, + }); }); it("keeps queued messages from draining while a project draft session is pending", () => { @@ -2508,216 +2842,6 @@ describe("useChatSessionController", () => { expect(sendOptions?.assistantPrompt).toContain("draft-from-chat.md"); }); - it("queues agent-builder activation while draft session creation is pending", async () => { - mockQueueAdmissionWithoutDrain(); - useChatSessionStore.setState({ - sessions: [ - sessionFixture({ - id: "draft-session", - clientSessionId: "draft-session", - creationState: "pending", - executionTarget: { - harnessId: "goose", - modelProviderId: "openai", - modelId: "gpt-4o", - modelName: "GPT-4o", - }, - }), - ], - }); - const { result } = renderHook(() => - useChatSessionController({ sessionId: "draft-session" }), - ); - - await act(async () => { - expect( - await result.current.handleSend( - "make a reviewer", - undefined, - undefined, - { - chips: [{ label: "agent-builder", type: "skill" }], - assistantPrompt: "Use agent-builder.", - }, - ), - ).toBe(true); - }); - - expect(mockPreSeedDraftAgent).toHaveBeenCalledWith("draft-session"); - expect(mockUseChatSendMessage).not.toHaveBeenCalled(); - expect( - useChatSessionStore.getState().getSession("draft-session"), - ).toMatchObject({ - intent: "build-agent", - agentBuilderOpen: true, - targetAgentPath: "/Users/x/.agents/agents/draft-from-chat.md", - }); - expect( - useChatStore.getState().queuedMessageBySession["draft-session"]?.[0], - ).toMatchObject({ - kind: "transport-ready", - payload: { - text: "make a reviewer", - sendOptions: { - assistantPrompt: expect.stringContaining("draft-from-chat.md"), - }, - }, - }); - }); - - it("queues an in-flight builder activation against the promoted session", async () => { - mockQueueAdmissionWithoutDrain(); - const pendingDraft = deferred<{ path: string; slug: string }>(); - const pendingMigration = deferred(); - mockPreSeedDraftAgent.mockReturnValueOnce(pendingDraft.promise); - mockMigratePendingDraftAgent.mockReturnValueOnce(pendingMigration.promise); - useChatSessionStore.setState({ - sessions: [ - sessionFixture({ - id: "draft-session", - clientSessionId: "draft-session", - creationState: "pending", - executionTarget: { - harnessId: "goose", - modelProviderId: "openai", - modelId: "gpt-4o", - modelName: "GPT-4o", - }, - }), - ], - }); - const { result } = renderHook(() => - useChatSessionController({ sessionId: "draft-session" }), - ); - - const sendResult = result.current.handleSend( - "make a reviewer", - undefined, - undefined, - { - chips: [{ label: "agent-builder", type: "skill" }], - assistantPrompt: "Use agent-builder.", - }, - ); - expect(sendResult).toBeInstanceOf(Promise); - await waitFor(() => { - expect(mockPreSeedDraftAgent).toHaveBeenCalledWith("draft-session"); - }); - - act(() => { - useChatStore - .getState() - .promoteSessionId("draft-session", "backend-session"); - useChatSessionStore - .getState() - .promoteDraftSession("draft-session", "backend-session"); - }); - await act(async () => { - pendingDraft.resolve({ - path: "/Users/x/.agents/agents/draft-from-chat.md", - slug: "draft-from-chat", - }); - await pendingDraft.promise; - }); - - await waitFor(() => { - expect(mockMigratePendingDraftAgent).toHaveBeenCalledWith( - "draft-session", - "backend-session", - "/Users/x/.agents/agents/draft-from-chat.md", - ); - }); - expect(useChatStore.getState().queuedMessageBySession).toEqual({}); - - let accepted: boolean | undefined; - await act(async () => { - pendingMigration.resolve(); - accepted = await sendResult; - }); - - expect(accepted).toBe(true); - expect(mockDeletePersonaSource).not.toHaveBeenCalled(); - expect( - useChatSessionStore.getState().getSession("backend-session"), - ).toMatchObject({ - intent: "build-agent", - agentBuilderOpen: true, - targetAgentPath: "/Users/x/.agents/agents/draft-from-chat.md", - }); - expect( - useChatStore.getState().queuedMessageBySession["draft-session"], - ).toBeUndefined(); - expect( - useChatStore.getState().queuedMessageBySession["backend-session"]?.[0], - ).toMatchObject({ - kind: "transport-ready", - payload: { - text: "make a reviewer", - sendOptions: { - assistantPrompt: expect.stringContaining("draft-from-chat.md"), - }, - }, - }); - }); - - it("does not queue an in-flight builder activation after creation fails", async () => { - mockQueueAdmissionWithoutDrain(); - const pendingDraft = deferred<{ path: string; slug: string }>(); - mockPreSeedDraftAgent.mockReturnValueOnce(pendingDraft.promise); - useChatSessionStore.setState({ - sessions: [ - sessionFixture({ - id: "draft-session", - clientSessionId: "draft-session", - creationState: "pending", - executionTarget: { - harnessId: "goose", - modelProviderId: "openai", - modelId: "gpt-4o", - modelName: "GPT-4o", - }, - }), - ], - }); - const { result } = renderHook(() => - useChatSessionController({ sessionId: "draft-session" }), - ); - - const sendResult = result.current.handleSend( - "make a reviewer", - undefined, - undefined, - { - chips: [{ label: "agent-builder", type: "skill" }], - assistantPrompt: "Use agent-builder.", - }, - ); - await waitFor(() => { - expect(mockPreSeedDraftAgent).toHaveBeenCalledWith("draft-session"); - }); - act(() => { - useChatSessionStore - .getState() - .markSessionCreationFailed("draft-session", "creation failed"); - }); - - let accepted: boolean | undefined; - await act(async () => { - pendingDraft.resolve({ - path: "/Users/x/.agents/agents/draft-from-chat.md", - slug: "draft-from-chat", - }); - accepted = await sendResult; - }); - - expect(accepted).toBe(false); - expect(mockDeletePersonaSource).toHaveBeenCalledWith( - "/Users/x/.agents/agents/draft-from-chat.md", - ); - expect(mockMigratePendingDraftAgent).not.toHaveBeenCalled(); - expect(useChatStore.getState().queuedMessageBySession).toEqual({}); - }); - it("activates builder mode before appending an agent-builder send", async () => { useChatStore.getState().enqueueTransportReadyMessage("session-1", { persona: { kind: "inherit" }, @@ -4734,6 +4858,54 @@ describe("useChatSessionController", () => { expect(mockAcpPrepareSession).not.toHaveBeenCalled(); }); + it("keeps a provider-qualified persona target local while session creation is pending", () => { + useProviderCatalogStore.getState().mergeEntries([ + { + id: "databricks_v2", + displayName: "Databricks", + category: "model", + description: "Databricks", + setupMethod: "single_api_key", + group: "default", + }, + ]); + useAgentStore.setState({ + personas: [ + personaFixture({ + provider: "goose", + modelProviderId: "databricks_v2", + model: "goose-claude-opus-4-8", + }), + ], + }); + useChatSessionStore.setState((state) => ({ + sessions: state.sessions.map((candidate) => + candidate.id === "session-1" + ? { ...candidate, creationState: "pending" } + : candidate, + ), + })); + const { result } = renderHook(() => + useChatSessionController({ sessionId: "session-1" }), + ); + + act(() => { + result.current.handlePersonaChange("persona-1"); + }); + + expect( + useChatSessionStore.getState().getSession("session-1"), + ).toMatchObject({ + personaId: "persona-1", + executionTarget: { + harnessId: "goose", + modelProviderId: "databricks_v2", + modelId: "goose-claude-opus-4-8", + }, + }); + expect(mockAcpPrepareSession).not.toHaveBeenCalled(); + }); + it("applies a persona's provider-qualified model without model inventory", async () => { useProviderCatalogStore.getState().mergeEntries([ { diff --git a/src/features/chat/hooks/useBackgroundQueuedMessageDrain.test.tsx b/src/features/chat/hooks/useBackgroundQueuedMessageDrain.test.tsx index d8922248d..b569c4b02 100644 --- a/src/features/chat/hooks/useBackgroundQueuedMessageDrain.test.tsx +++ b/src/features/chat/hooks/useBackgroundQueuedMessageDrain.test.tsx @@ -86,6 +86,22 @@ function releasedRecord(): QueuedMessageRecord & { kind: "transport-ready" } { }; } +function agentBuilderRecord(): QueuedMessageRecord & { + kind: "transport-ready"; +} { + return { + kind: "transport-ready", + recordId: "agent-builder-record", + payload: { + text: "make a reviewer", + persona: { kind: "inherit" }, + sendOptions: { + chips: [{ label: "agent-builder", type: "skill" }], + }, + }, + }; +} + function ordinaryRecord(): QueuedMessageRecord & { kind: "transport-ready" } { return { kind: "transport-ready", @@ -1210,6 +1226,53 @@ describe("useBackgroundQueuedMessageDrain", () => { ).toEqual([BACKEND_SESSION_ID]); }); + it("keeps an unmounted Agent Builder head parked after promotion until its draft target is prepared", async () => { + const builder = agentBuilderRecord(); + seedDraftSession(); + useChatStore.setState({ + queuedMessageBySession: { [DRAFT_SESSION_ID]: [builder] }, + }); + + render(); + act(() => promoteDraft()); + + expect( + mocks.sendQueuedPromptToExistingSessionInBackground, + ).not.toHaveBeenCalled(); + expect( + useChatStore.getState().queuedMessageBySession[BACKEND_SESSION_ID]?.[0], + ).toBe(builder); + + const releaseOwner = registerForegroundQueueOwner(BACKEND_SESSION_ID); + act(() => { + useChatSessionStore.getState().patchSession(BACKEND_SESSION_ID, { + intent: "build-agent", + agentBuilderOpen: true, + targetAgentPath: "/Users/x/.agents/agents/reviewer.md", + targetAgentSlug: "reviewer", + }); + }); + expect( + mocks.sendQueuedPromptToExistingSessionInBackground, + ).not.toHaveBeenCalled(); + + act(() => releaseOwner()); + + await waitFor(() => + expect( + mocks.sendQueuedPromptToExistingSessionInBackground, + ).toHaveBeenCalledOnce(), + ); + expect( + mocks.sendQueuedPromptToExistingSessionInBackground, + ).toHaveBeenCalledWith( + BACKEND_SESSION_ID, + builder, + expect.any(Function), + expect.any(Function), + ); + }); + it("keeps a queued head parked without toasting when creation failed", async () => { const ordinary = ordinaryRecord(); seedDraftSession("failed"); diff --git a/src/features/chat/hooks/useBackgroundQueuedMessageDrain.ts b/src/features/chat/hooks/useBackgroundQueuedMessageDrain.ts index 9107ca96b..783e0a6f2 100644 --- a/src/features/chat/hooks/useBackgroundQueuedMessageDrain.ts +++ b/src/features/chat/hooks/useBackgroundQueuedMessageDrain.ts @@ -5,6 +5,7 @@ import { i18n } from "@/shared/i18n"; import { assertQueuedSessionReady, isQueuedSessionReady, + QueuedSessionNotReadyError, } from "@/features/chat/lib/queuedMessageReadiness"; import { PreCommitSendRejectedError } from "@/features/chat/lib/preCommitSendRejection"; import { @@ -17,6 +18,7 @@ import { subscribeForegroundQueueOwnership, } from "@/features/chat/lib/foregroundQueueOwnership"; import { isBerdctlCrossSessionQueuedMessage } from "@/features/chat/lib/queuedMessageOrigin"; +import { isAgentBuilderQueuePreparationReady } from "@/features/chat/lib/agentBuilderQueueReadiness"; import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; import { type QueuedMessageRecord, @@ -138,7 +140,13 @@ function isBackgroundDrainableHead( record: QueuedMessageRecord & { kind: "transport-ready" }, sessionId: string, ): boolean { - if (isBerdctlCrossSessionQueuedMessage(record)) { + if ( + isBerdctlCrossSessionQueuedMessage(record) || + !isAgentBuilderQueuePreparationReady( + record, + useChatSessionStore.getState().getSession(sessionId), + ) + ) { return false; } if (record.releasedFromDeferred) { @@ -246,6 +254,14 @@ function drainQueuedMessage(sessionId: string, ownerId: string): void { queuedMessage, ); assertQueuedSessionReady(state.getSessionRuntime(sessionId)); + if ( + !isAgentBuilderQueuePreparationReady( + queuedMessage, + useChatSessionStore.getState().getSession(sessionId), + ) + ) { + throw new QueuedSessionNotReadyError(); + } }, () => { useChatStore diff --git a/src/features/chat/hooks/useChatSessionController.ts b/src/features/chat/hooks/useChatSessionController.ts index 8bb8e0848..3cbe97a42 100644 --- a/src/features/chat/hooks/useChatSessionController.ts +++ b/src/features/chat/hooks/useChatSessionController.ts @@ -80,7 +80,7 @@ import { moveSessionToProject } from "../stores/chatSessionOperations"; import { acpSetSessionConfigOption } from "@/shared/api/acp"; import { updateSessionProject } from "@/shared/api/acpApi"; import { - migratePendingDraftAgent, + markAgentBuilderSessionPreparationFailed, preSeedDraftAgent, } from "@/features/agents/lib/agentBuilderSession"; import { personaExecutionTarget } from "@/features/agents/lib/personaExecutionTarget"; @@ -1735,23 +1735,9 @@ export function useChatSessionController({ submittedDraftGeneration, ); } - let wasSubmittedWithoutDraftOwnership = + const wasSubmittedWithoutDraftOwnership = submittedDraftGeneration === null && takeDraftPreservingSubmission(acceptedSessionId, submittedText); - if ( - submittedDraftGeneration === null && - !wasSubmittedWithoutDraftOwnership - ) { - const clientSessionId = useChatSessionStore - .getState() - .getSession(acceptedSessionId)?.clientSessionId; - if (clientSessionId && clientSessionId !== acceptedSessionId) { - wasSubmittedWithoutDraftOwnership = takeDraftPreservingSubmission( - clientSessionId, - submittedText, - ); - } - } const draftSnapshot = getDraftSnapshot(acceptedSessionId); const hasNewerDraftEdit = submittedDraftGeneration !== null && @@ -2102,9 +2088,10 @@ export function useChatSessionController({ (s.messagesBySession[sessionId]?.length ?? 0) === 0 : false, ); - const hasQueuedMessages = useChatStore( - (state) => (state.queuedMessageBySession[stateSessionId]?.length ?? 0) > 0, + const queuedHead = useChatStore( + (state) => state.queuedMessageBySession[stateSessionId]?.[0] ?? null, ); + const hasQueuedMessages = queuedHead !== null; const deferredWorkspaceRecord = useChatStore((state) => { const record = state.queuedMessageBySession[stateSessionId]?.[0]; return record?.kind === "deferred" && @@ -2162,11 +2149,26 @@ export function useChatSessionController({ null, ); }, [deferredWorkspaceRecord, session?.executionTarget, stateSessionId]); + const queuedAgentBuilderSendNeedsPreparation = Boolean( + session?.creationState == null && + queuedHead?.kind === "transport-ready" && + isAgentBuilderSkillSendOptions(queuedHead.payload.sendOptions) && + !session?.targetAgentPath, + ); const isQueuePreparationReady = Boolean( sessionId && session?.creationState == null && workspaceContextReady && - !deferredWorkspaceRecord, + !deferredWorkspaceRecord && + !queuedAgentBuilderSendNeedsPreparation && + // Agent Builder owns a draft file that is keyed to the final backend + // session id. Keep its accepted first send parked until that target is + // ready, then let the normal queue drain compose the path-bound prompt. + !( + session?.intent === "build-agent" && + session.agentBuilderOpen !== false && + !session.targetAgentPath + ), ); const queueChatState = isQueuePreparationReady ? chatState : "thinking"; const sendQueuedMessageWithAutoCompact = useCallback( @@ -2225,27 +2227,58 @@ export function useChatSessionController({ isQueuePreparationReady, ); const pendingBuilderActivationRef = useRef< - Record> + Record< + string, + { + promise: Promise; + queueRecordId?: string; + } + > >({}); + const isQueuedAgentBuilderRecordAuthoritative = useCallback( + (recordId: string) => { + const record = + useChatStore.getState().queuedMessageBySession[stateSessionId]?.[0]; + return Boolean( + record?.recordId === recordId && + record.kind === "transport-ready" && + isAgentBuilderSkillSendOptions(record.payload.sendOptions), + ); + }, + [stateSessionId], + ); + const ensureCurrentSessionIsAgentBuilder = useCallback( - async (options?: { requireSelectedSkill?: boolean }) => { + async (options?: { + requireSelectedSkill?: boolean; + queueRecordId?: string; + }) => { if (!sessionId) { return null; } const pendingActivation = pendingBuilderActivationRef.current[sessionId]; if (pendingActivation) { - return pendingActivation; + if ( + !options?.queueRecordId || + pendingActivation.queueRecordId === options.queueRecordId + ) { + return pendingActivation.promise; + } + await pendingActivation.promise; + } + + if ( + options?.queueRecordId && + !isQueuedAgentBuilderRecordAuthoritative(options.queueRecordId) + ) { + return null; } const activation = (async () => { const chatSessions = useChatSessionStore.getState(); - const currentSession = chatSessions.sessions.find( - (candidate) => - candidate.id === sessionId || - candidate.clientSessionId === sessionId, - ); + const currentSession = chatSessions.getSession(sessionId); if (!currentSession) { return null; } @@ -2254,31 +2287,32 @@ export function useChatSessionController({ currentSession.targetAgentPath ) { if (currentSession.agentBuilderOpen !== true) { - chatSessions.patchSession(currentSession.id, { - agentBuilderOpen: true, - }); + chatSessions.patchSession(sessionId, { agentBuilderOpen: true }); return { ...currentSession, agentBuilderOpen: true }; } return currentSession; } const target = await preSeedDraftAgent(sessionId); + if ( + options?.queueRecordId && + !isQueuedAgentBuilderRecordAuthoritative(options.queueRecordId) + ) { + await deletePersonaSource(target.path).catch((error) => { + console.error("Failed to delete superseded agent draft:", error); + }); + return null; + } + const liveChatSessions = useChatSessionStore.getState(); - const liveSession = liveChatSessions.sessions.find( - (candidate) => - candidate.id === sessionId || - candidate.clientSessionId === sessionId, - ); - const liveSessionId = liveSession?.id; + const liveSession = liveChatSessions.getSession(sessionId); const liveSkills = - useChatStore.getState().skillDraftsBySession[ - liveSessionId ?? stateSessionId - ] ?? EMPTY_SKILL_DRAFTS; + useChatStore.getState().skillDraftsBySession[stateSessionId] ?? + EMPTY_SKILL_DRAFTS; if ( !liveSession || liveSession.archivedAt || - liveSession.creationState === "failed" || (options?.requireSelectedSkill && !hasAgentBuilderSkillDraft(liveSkills)) ) { @@ -2288,14 +2322,6 @@ export function useChatSessionController({ return null; } - if (liveSession.id !== sessionId) { - await migratePendingDraftAgent( - sessionId, - liveSession.id, - target.path, - ); - } - if ( liveSession.intent === "build-agent" && liveSession.targetAgentPath @@ -2313,32 +2339,61 @@ export function useChatSessionController({ targetAgentSlug: target.slug, }; - liveChatSessions.patchSession(liveSession.id, patch); + liveChatSessions.patchSession(sessionId, patch); const chatStateNow = useChatStore.getState(); const currentSkills = - chatStateNow.skillDraftsBySession[liveSession.id] ?? + chatStateNow.skillDraftsBySession[stateSessionId] ?? EMPTY_SKILL_DRAFTS; chatStateNow.setSkillDrafts( - liveSession.id, + stateSessionId, ensureAgentBuilderSkillDraft(currentSkills), ); - return { ...liveSession, ...patch }; + return { ...currentSession, ...patch }; })(); - pendingBuilderActivationRef.current[sessionId] = activation; + const pendingEntry = { + promise: activation, + queueRecordId: options?.queueRecordId, + }; + pendingBuilderActivationRef.current[sessionId] = pendingEntry; try { return await activation; } finally { - if (pendingBuilderActivationRef.current[sessionId] === activation) { + if (pendingBuilderActivationRef.current[sessionId] === pendingEntry) { delete pendingBuilderActivationRef.current[sessionId]; } } }, - [sessionId, stateSessionId], + [isQueuedAgentBuilderRecordAuthoritative, sessionId, stateSessionId], ); + useEffect(() => { + if (!queuedAgentBuilderSendNeedsPreparation || !sessionId) { + return; + } + const queueRecordId = queuedHead?.recordId; + if (!queueRecordId) { + return; + } + void ensureCurrentSessionIsAgentBuilder({ queueRecordId }).catch( + (error) => { + if (!isQueuedAgentBuilderRecordAuthoritative(queueRecordId)) { + return; + } + console.error("Failed to prepare queued agent builder:", error); + markAgentBuilderSessionPreparationFailed(sessionId); + }, + ); + }, [ + ensureCurrentSessionIsAgentBuilder, + isQueuedAgentBuilderRecordAuthoritative, + queuedAgentBuilderSendNeedsPreparation, + queuedHead?.recordId, + sessionId, + ]); + const captureSessionSelection = useCallback( (payload: QueuedMessagePayload): QueuedMessagePayload => { const requestedPersona = @@ -2442,24 +2497,17 @@ export function useChatSessionController({ ? selectedPersona.displayName : useAgentStore.getState().getPersonaById(personaId)?.displayName : undefined; - const enqueueMessage = ( - options = sendOptions, - targetSessionId = stateSessionId, - ) => { - const payload = captureSessionSelection({ - text, - persona: personaIntentFromComposer(personaId, personaName), - attachments, - sendOptions: options, - }); - const accepted = - targetSessionId === stateSessionId - ? enqueueCapturedMessage(payload) - : useChatStore - .getState() - .enqueueTransportReadyMessage(targetSessionId, payload); + const enqueueMessage = (options = sendOptions) => { + const accepted = enqueueCapturedMessage( + captureSessionSelection({ + text, + persona: personaIntentFromComposer(personaId, personaName), + attachments, + sendOptions: options, + }), + ); if (accepted && sessionId) { - recordDraftPreservingSubmission(targetSessionId, text); + recordDraftPreservingSubmission(sessionId, text); } return accepted; }; @@ -2474,6 +2522,38 @@ export function useChatSessionController({ return false; } + // Draft sessions are interactive before backend creation finishes. Admit + // an ordinary first send through the same first-send path used by ready + // sessions, then let the queue's readiness gate hold it until promotion + // replaces the renderer-local id with the backend session id. + if (session?.creationState === "pending") { + const payload = captureSessionSelection({ + text, + persona: personaIntentFromComposer(personaId, personaName), + attachments, + sendOptions, + }); + const hasQueuedMessages = + (useChatStore.getState().queuedMessageBySession[stateSessionId] + ?.length ?? 0) > 0; + const accepted = hasQueuedMessages + ? enqueueCapturedMessage(payload) + : acceptFirstSend(sessionId, payload, { + queueReady: true, + startupName: preselectedWorkspaceStartupName, + onNeedsName: onWorkspaceNameRequest, + }).accepted; + if (!accepted) { + return false; + } + recordDraftPreservingSubmission(sessionId, text); + onMessageAccepted?.(sessionId); + if (personaId && personaId !== selectedPersonaId) { + handlePersonaChange(personaId); + } + return true; + } + if ( (session?.intent !== "build-agent" || session.agentBuilderOpen === false) && @@ -2482,7 +2562,6 @@ export function useChatSessionController({ return (async () => { const builderSession = await ensureCurrentSessionIsAgentBuilder(); if (!builderSession) return false; - const builderSessionId = builderSession.id; const onBuilderWorkspaceNameRequest = onWorkspaceNameRequest ? (request: WorkspaceNameRequest) => onWorkspaceNameRequest({ @@ -2491,19 +2570,17 @@ export function useChatSessionController({ request.cancel(); const liveSession = useChatSessionStore .getState() - .getSession(builderSessionId); + .getSession(sessionId); if ( liveSession?.intent === "build-agent" && liveSession.targetAgentPath === builderSession.targetAgentPath ) { - useChatSessionStore - .getState() - .patchSession(builderSessionId, { - intent: undefined, - targetAgentPath: undefined, - targetAgentSlug: undefined, - }); + useChatSessionStore.getState().patchSession(sessionId, { + intent: undefined, + targetAgentPath: undefined, + targetAgentSlug: undefined, + }); if (builderSession.targetAgentPath) { void deletePersonaSource( builderSession.targetAgentPath, @@ -2523,14 +2600,14 @@ export function useChatSessionController({ sendOptions, ); if ( - (useChatStore.getState().queuedMessageBySession[builderSessionId] + (useChatStore.getState().queuedMessageBySession[stateSessionId] ?.length ?? 0) > 0 ) { - enqueueMessage(deferredSendOptions, builderSessionId); + enqueueMessage(deferredSendOptions); return true; } const firstSend = acceptFirstSend( - builderSessionId, + sessionId, captureSessionSelection({ text, persona: personaIntentFromComposer(personaId, personaName), @@ -2545,8 +2622,8 @@ export function useChatSessionController({ }, ); if (firstSend.accepted) { - recordDraftPreservingSubmission(builderSessionId, text); - onMessageAccepted?.(builderSessionId); + recordDraftPreservingSubmission(sessionId, text); + onMessageAccepted?.(sessionId); if (personaId && personaId !== selectedPersonaId) { handlePersonaChange(personaId); } @@ -2554,20 +2631,17 @@ export function useChatSessionController({ } if (firstSend.needsName || firstSend.occupied) return false; if (personaId && personaId !== selectedPersonaId) { - const accepted = enqueueMessage( - deferredSendOptions, - builderSessionId, - ); + const accepted = enqueueMessage(deferredSendOptions); if (accepted) { handlePersonaChange(personaId); } return accepted; } if (!workspaceContextReady) { - enqueueMessage(deferredSendOptions, builderSessionId); + enqueueMessage(deferredSendOptions); return true; } - return enqueueMessage(deferredSendOptions, builderSessionId); + return enqueueMessage(deferredSendOptions); })(); } @@ -2652,6 +2726,7 @@ export function useChatSessionController({ readOnly, recordDraftPreservingSubmission, session?.agentBuilderOpen, + session?.creationState, session?.intent, sessionId, selectedPersona, @@ -2845,6 +2920,7 @@ export function useChatSessionController({ if ( !sessionId || !skillWasJustSelected || + session?.creationState === "pending" || (session?.intent === "build-agent" && session.agentBuilderOpen !== false) ) { return; @@ -2852,24 +2928,30 @@ export function useChatSessionController({ void ensureCurrentSessionIsAgentBuilder({ requireSelectedSkill: true, - }).then((builderSession) => { - if (!builderSession) { - return; - } + }) + .then((builderSession) => { + if (!builderSession) { + return; + } - const chatState = useChatStore.getState(); - if ( - isAgentBuilderMentionOnlyDraft( - chatState.draftsBySession[stateSessionId] ?? "", - ) - ) { - chatState.clearDraft(stateSessionId); - } - }); + const chatState = useChatStore.getState(); + if ( + isAgentBuilderMentionOnlyDraft( + chatState.draftsBySession[stateSessionId] ?? "", + ) + ) { + chatState.clearDraft(stateSessionId); + } + }) + .catch((error) => { + console.error("Failed to prepare selected agent builder:", error); + markAgentBuilderSessionPreparationFailed(sessionId); + }); }, [ ensureCurrentSessionIsAgentBuilder, hasSelectedAgentBuilderSkill, session?.agentBuilderOpen, + session?.creationState, session?.intent, sessionId, stateSessionId, diff --git a/src/features/chat/lib/__tests__/queuedSessionSend.test.ts b/src/features/chat/lib/__tests__/queuedSessionSend.test.ts index bf4eb7e02..5e3a1e678 100644 --- a/src/features/chat/lib/__tests__/queuedSessionSend.test.ts +++ b/src/features/chat/lib/__tests__/queuedSessionSend.test.ts @@ -46,6 +46,20 @@ function seedSession(creationState?: "pending" | "failed"): void { }); } +function agentBuilderRecord(): QueuedMessageRecord & { + kind: "transport-ready"; +} { + return { + kind: "transport-ready", + recordId: "builder-record", + payload: { + text: "make a reviewer", + persona: { kind: "inherit" }, + sendOptions: { chips: [{ label: "agent-builder", type: "skill" }] }, + }, + }; +} + function queuedRecord(): QueuedMessageRecord & { kind: "transport-ready" } { return { kind: "transport-ready", @@ -194,6 +208,28 @@ describe("sendQueuedPromptToExistingSessionInBackground", () => { mocks.loadSessionMessages.mockResolvedValue(true); }); + it("rejects an Agent Builder send until the session owns a prepared draft target", async () => { + seedSession(); + useChatSessionStore.setState((state) => ({ + sessions: state.sessions.map((session) => ({ + ...session, + intent: "build-agent" as const, + agentBuilderOpen: true, + })), + })); + const beforeUserMessageCommitted = vi.fn(); + + const error = await sendQueuedPromptToExistingSessionInBackground( + SESSION_ID, + agentBuilderRecord(), + beforeUserMessageCommitted, + ).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(PreCommitSendRejectedError); + expect(mocks.loadSessionMessages).not.toHaveBeenCalled(); + expect(beforeUserMessageCommitted).not.toHaveBeenCalled(); + }); + it("rejects a send to a creating session without committing anything", async () => { seedSession("pending"); const beforeUserMessageCommitted = vi.fn(); diff --git a/src/features/chat/lib/agentBuilderQueueReadiness.ts b/src/features/chat/lib/agentBuilderQueueReadiness.ts new file mode 100644 index 000000000..c13eb18f4 --- /dev/null +++ b/src/features/chat/lib/agentBuilderQueueReadiness.ts @@ -0,0 +1,32 @@ +import { isAgentBuilderSkillSendOptions } from "@/features/chat/lib/agentBuilderSkill"; +import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; +import type { QueuedMessageRecord } from "@/features/chat/stores/chatStore"; + +/** + * Agent Builder queue records are dispatchable only after a foreground owner + * has created and adopted the final-session-owned draft target. The background + * drain must not bypass that preparation when no chat is mounted. + */ +export function getAgentBuilderQueuePreparedTargetPath( + record: QueuedMessageRecord & { kind: "transport-ready" }, + session: ChatSession | null | undefined, +): string | null | undefined { + if (!isAgentBuilderSkillSendOptions(record.payload.sendOptions)) { + return undefined; + } + if ( + session?.intent !== "build-agent" || + session.agentBuilderOpen === false || + !session.targetAgentPath + ) { + return null; + } + return session.targetAgentPath; +} + +export function isAgentBuilderQueuePreparationReady( + record: QueuedMessageRecord & { kind: "transport-ready" }, + session: ChatSession | null | undefined, +): boolean { + return getAgentBuilderQueuePreparedTargetPath(record, session) !== null; +} diff --git a/src/features/chat/lib/queuedSessionSend.ts b/src/features/chat/lib/queuedSessionSend.ts index 992e12cc4..cd8ba53a5 100644 --- a/src/features/chat/lib/queuedSessionSend.ts +++ b/src/features/chat/lib/queuedSessionSend.ts @@ -13,11 +13,14 @@ import { import { loadWorkspaceInstructionFiles } from "@/features/chat/api/workspaceContext"; import { sendPromptInBackground } from "@/features/chat/lib/backgroundSend"; +import { composeBuilderSendOptions } from "@/features/chat/hooks/useBuilderSendInterceptor"; +import { getAgentBuilderQueuePreparedTargetPath } from "@/features/chat/lib/agentBuilderQueueReadiness"; import { isFirstCommittedUserMessage } from "@/features/chat/lib/chatFirstMessage"; import { trackChatMessageSent, trackChatSessionStarted, } from "@/features/chat/lib/chatTelemetry"; +import { QueuedSessionNotReadyError } from "@/features/chat/lib/queuedMessageReadiness"; import { loadSessionMessages } from "@/features/chat/lib/sessionActivation"; import { SessionDispatchContentionError, @@ -259,6 +262,22 @@ export async function sendQueuedPromptToExistingSessionInBackground( beforeUserMessageCommitted?: () => void, onPromptDispatched?: () => void, ): Promise { + let preparedBuilderTargetPath: string | undefined; + const assertAgentBuilderPreparationReady = () => { + const targetPath = getAgentBuilderQueuePreparedTargetPath( + queuedMessage, + useChatSessionStore.getState().getSession(sessionId), + ); + if ( + targetPath === null || + (preparedBuilderTargetPath !== undefined && + targetPath !== preparedBuilderTargetPath) + ) { + throw new QueuedSessionNotReadyError(); + } + return targetPath; + }; + assertAgentBuilderPreparationReady(); const acquisition = await acquireExistingSessionForBackgroundSend(sessionId); if (acquisition.status === "contended") { throw new SessionDispatchContentionError(acquisition.waiter); @@ -275,7 +294,6 @@ export async function sendQueuedPromptToExistingSessionInBackground( const targetLease = acquisition; try { const { payload } = queuedMessage; - const sendOptions = payload.sendOptions ?? {}; const payloadPersonaIntent = payload.persona; const payloadPersona = payloadPersonaIntent.kind === "persona" @@ -338,6 +356,14 @@ export async function sendQueuedPromptToExistingSessionInBackground( formatAvailableSkillsCatalogPrompt(skills), ) : undefined; + const sessionBeforeSend = useChatSessionStore + .getState() + .getSession(sessionId); + preparedBuilderTargetPath = assertAgentBuilderPreparationReady(); + const sendOptions = composeBuilderSendOptions( + sessionBeforeSend, + payload.sendOptions ?? {}, + ); const personaSystemPrompt = sendOptions.capturedPersonaSystemPrompt ?? formatPersonaSystemPrompt(persona); @@ -408,7 +434,10 @@ export async function sendQueuedPromptToExistingSessionInBackground( executionSystemPrompt, }, payload.attachments, - beforeUserMessageCommitted, + () => { + assertAgentBuilderPreparationReady(); + beforeUserMessageCommitted?.(); + }, fireSendTelemetry, () => assertSessionExecutionTarget(sessionId, preparedExecutionTarget), onPromptDispatched, diff --git a/src/features/chat/ui/ChatView.tsx b/src/features/chat/ui/ChatView.tsx index 1c8af7cbd..e047fbf9f 100644 --- a/src/features/chat/ui/ChatView.tsx +++ b/src/features/chat/ui/ChatView.tsx @@ -355,8 +355,6 @@ export function ChatView({ : `${1 - builderFraction}fr ${builderFraction}fr`; const isAgentBuilderTargetFailed = isAgentBuilderOpen && effectiveSession?.targetAgentDraftState === "failed"; - const isAgentBuilderTargetPending = - isAgentBuilderOpen && !effectiveSession?.targetAgentPath; const hasVisibleRightRail = isAgentBuilderOpen || Boolean( @@ -676,8 +674,6 @@ export function ChatView({ effectiveSession.creationError ?? t("toolbar.sessionStartFailed"); } else if (isAgentBuilderTargetFailed) { sendDisabledReason = t("toolbar.agentBuilderPrepareFailed"); - } else if (isAgentBuilderTargetPending) { - sendDisabledReason = t("toolbar.agentBuilderPreparing"); } // The composer is owned by the timeline so it stays mounted across loading, @@ -791,7 +787,7 @@ export function ChatView({ sendDisabled: isReadOnly || effectiveSession?.creationState === "failed" || - isAgentBuilderTargetPending || + isAgentBuilderTargetFailed || controller.workspaceSetupInProgress, sendDisabledReason, queuedMessage: composerHandoffInProgress diff --git a/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx b/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx index 832966c2a..6861ce5e0 100644 --- a/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx +++ b/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx @@ -587,7 +587,7 @@ describe("ChatView MCP app messaging", () => { expect(timelineProps.showPlaceholder).toBe(false); }); - it("keeps the composer enabled while a blank draft session is pending", () => { + it("keeps Send available while a blank draft session is pending", () => { mocks.useChatSessionController.mockReturnValue({ ...mocks.useChatSessionController(), messages: [], @@ -606,45 +606,14 @@ describe("ChatView MCP app messaging", () => { const chatInputProps = mocks.chatInputSpy.mock.calls.at(-1)?.[0] as { composerActions?: { - onSend?: (text: string) => boolean; + onSend?: unknown; sendDisabled?: boolean; sendDisabledReason?: string; }; }; + expect(chatInputProps.composerActions?.onSend).toBe(mocks.handleSend); expect(chatInputProps.composerActions?.sendDisabled).toBe(false); expect(chatInputProps.composerActions?.sendDisabledReason).toBeUndefined(); - - act(() => { - expect( - chatInputProps.composerActions?.onSend?.("queue while starting"), - ).toBe(true); - }); - expect(mocks.handleSend).toHaveBeenCalledWith("queue while starting"); - }); - - it("keeps the composer blocked after draft session creation fails", () => { - render( - , - ); - - const chatInputProps = mocks.chatInputSpy.mock.calls.at(-1)?.[0] as { - composerActions?: { - sendDisabled?: boolean; - sendDisabledReason?: string; - }; - }; - expect(chatInputProps.composerActions?.sendDisabled).toBe(true); - expect(chatInputProps.composerActions?.sendDisabledReason).toBe( - "Session failed to start", - ); }); it("keeps the empty-state placeholder visible while a blank draft session is pending", () => { @@ -1169,6 +1138,32 @@ describe("ChatView MCP app messaging", () => { expect(document.querySelector(".agent-builder-column-enter")).toBeTruthy(); }); + it("keeps Send available while an agent builder draft target is preparing", () => { + const activeSession = { + id: "session-1", + title: "Build agent", + createdAt: "2026-05-27T00:00:00.000Z", + updatedAt: "2026-05-27T00:00:00.000Z", + messageCount: 0, + intent: "build-agent", + agentBuilderOpen: true, + targetAgentPath: null, + targetAgentSlug: null, + targetAgentDraftState: "preparing", + } satisfies ChatSession; + + render(); + + const chatInputProps = mocks.chatInputSpy.mock.calls.at(-1)?.[0] as { + composerActions?: { + sendDisabled?: boolean; + sendDisabledReason?: string; + }; + }; + expect(chatInputProps.composerActions?.sendDisabled).toBe(false); + expect(chatInputProps.composerActions?.sendDisabledReason).toBeUndefined(); + }); + it("uses failed draft copy when an agent builder draft target fails", () => { const activeSession = { id: "session-1",