diff --git a/src/app/AppShell.navigation.test.tsx b/src/app/AppShell.navigation.test.tsx index 5ab22bd53..6e3471374 100644 --- a/src/app/AppShell.navigation.test.tsx +++ b/src/app/AppShell.navigation.test.tsx @@ -15,12 +15,14 @@ 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"; @@ -94,6 +96,10 @@ 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()); @@ -144,7 +150,10 @@ function flushAfterNextPaintCallbacks() { } } -function appShellWithTheme(children?: ReactNode) { +function appShellWithTheme( + children?: ReactNode, + options?: { backgroundQueueDrain?: boolean }, +) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); @@ -152,13 +161,19 @@ function appShellWithTheme(children?: ReactNode) { {children} + {options?.backgroundQueueDrain ? ( + + ) : null} ); } -function renderAppShell(children?: ReactNode) { - return render(appShellWithTheme(children)); +function renderAppShell( + children?: ReactNode, + options?: { backgroundQueueDrain?: boolean }, +) { + return render(appShellWithTheme(children, options)); } function managedWorktreeGitState( @@ -449,6 +464,11 @@ 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), @@ -478,6 +498,7 @@ 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), })); @@ -1009,6 +1030,11 @@ 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: {}, @@ -3379,6 +3405,162 @@ 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 cfd3431df..b44e8cb5a 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -113,6 +113,7 @@ 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, @@ -1973,6 +1974,13 @@ 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 49211ceb6..da0ffc934 100644 --- a/src/features/agents/lib/__tests__/agentBuilderSession.test.ts +++ b/src/features/agents/lib/__tests__/agentBuilderSession.test.ts @@ -29,6 +29,7 @@ const mocks = vi.hoisted(() => ({ createPersonaSource: vi.fn(), deletePersonaSource: vi.fn(), promotePersonaSource: vi.fn(), + updatePersonaSource: vi.fn(), listPersonaSources: vi.fn(), readAgentSourceFile: vi.fn(), })); @@ -89,6 +90,7 @@ vi.mock("@/shared/api/agents", () => ({ createPersonaSource: mocks.createPersonaSource, deletePersonaSource: mocks.deletePersonaSource, promotePersonaSource: mocks.promotePersonaSource, + updatePersonaSource: mocks.updatePersonaSource, listPersonaSources: mocks.listPersonaSources, readAgentSourceFile: mocks.readAgentSourceFile, })); @@ -103,6 +105,7 @@ import { discardDraftAgentSession, hasAgentBuilderSessionUserContent, isEmptyDraftAgentSession, + migratePendingDraftAgent, promoteDraft, recoverDraftAgent, reconcileAgentBuilderSessions, @@ -169,6 +172,17 @@ 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( @@ -824,6 +838,83 @@ 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 cf684fe07..4dd8983d9 100644 --- a/src/features/agents/lib/agentBuilderSession.ts +++ b/src/features/agents/lib/agentBuilderSession.ts @@ -22,13 +22,17 @@ 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, @@ -369,6 +373,50 @@ 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 95152412a..5ae47caaa 100644 --- a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts +++ b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts @@ -37,9 +37,19 @@ const mockToastError = vi.fn(); const mockUseChatSendMessage = vi.fn(); const mockUseChatSteerMessage = 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 mockDeletePersonaSource = vi.fn(); const mockAcpCreateSession = vi.fn(); const mockAcpSessionArchive = vi.fn(); @@ -155,6 +165,7 @@ vi.mock("../useChat", () => ({ systemPromptOverride, personaInfo, ); + mockUseChatOptionsBySession.set(sessionId, options ?? {}); const optionsWithSessionId = { ...options, __sessionId: sessionId }; return { messages: [], @@ -188,6 +199,8 @@ vi.mock("../useAutoCompactPreferences", () => ({ vi.mock("@/features/agents/lib/agentBuilderSession", () => ({ preSeedDraftAgent: (...args: unknown[]) => mockPreSeedDraftAgent(...args), + migratePendingDraftAgent: (...args: unknown[]) => + mockMigratePendingDraftAgent(...args), })); vi.mock("@/shared/api/agents", () => ({ @@ -278,6 +291,29 @@ 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, @@ -382,6 +418,7 @@ describe("useChatSessionController", () => { beforeEach(() => { resetSessionTargetCoordinatorsForTests(); vi.clearAllMocks(); + mockUseChatOptionsBySession.clear(); delete modelFixtures["legacy-v1-model"]; resetManagedModelSelectionRepairCacheForTests(); useRuntimeConfigStore.setState({ @@ -517,6 +554,7 @@ 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 = []; @@ -795,6 +833,84 @@ 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 { @@ -2365,6 +2481,216 @@ 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" }, diff --git a/src/features/chat/hooks/__tests__/useMessageQueue.test.ts b/src/features/chat/hooks/__tests__/useMessageQueue.test.ts index 85796a86a..7936a1f28 100644 --- a/src/features/chat/hooks/__tests__/useMessageQueue.test.ts +++ b/src/features/chat/hooks/__tests__/useMessageQueue.test.ts @@ -59,6 +59,75 @@ describe("useMessageQueue", () => { }); }); + it("admits under a pending draft id, then dispatches once after promotion", async () => { + useChatSessionStore.setState({ + sessions: [ + { + id: "draft-session", + clientSessionId: "draft-session", + title: "Chat", + executionTarget: { harnessId: "goose" }, + creationState: "pending", + createdAt: "2026-04-20T00:00:00.000Z", + updatedAt: "2026-04-20T00:00:00.000Z", + messageCount: 0, + }, + ], + }); + const dispatch = vi.fn().mockReturnValue(true); + const { result, rerender } = renderHook( + ({ sessionId, ready }: { sessionId: string; ready: boolean }) => + useMessageQueue( + sessionId, + ready ? "idle" : "thinking", + (text, persona, attachments, options) => + dispatch(sessionId, text, persona, attachments, options), + false, + false, + ready, + ), + { + initialProps: { sessionId: "draft-session", ready: false }, + }, + ); + + act(() => { + expect(result.current.enqueue("send when ready")).toBe(true); + }); + + expect( + useChatStore.getState().queuedMessageBySession["draft-session"]?.[0], + ).toMatchObject({ + kind: "transport-ready", + payload: { text: "send when ready" }, + }); + expect(dispatch).not.toHaveBeenCalled(); + + act(() => { + useChatStore + .getState() + .promoteSessionId("draft-session", "backend-session"); + useChatSessionStore + .getState() + .promoteDraftSession("draft-session", "backend-session"); + }); + expect(dispatch).not.toHaveBeenCalled(); + expect( + useChatStore.getState().queuedMessageBySession["backend-session"]?.[0], + ).toMatchObject({ payload: { text: "send when ready" } }); + + rerender({ sessionId: "backend-session", ready: true }); + + await waitFor(() => expect(dispatch).toHaveBeenCalledOnce()); + expect(dispatch.mock.calls[0]?.slice(0, 2)).toEqual([ + "backend-session", + "send when ready", + ]); + expect( + useChatStore.getState().queuedMessageBySession["backend-session"], + ).toBeUndefined(); + }); + it("drains an exact head once when its session gains a target", async () => { const sendMessage = vi.fn().mockReturnValue(true); useChatSessionStore diff --git a/src/features/chat/hooks/useChatSessionController.ts b/src/features/chat/hooks/useChatSessionController.ts index 70a5c9e5c..7fa28608b 100644 --- a/src/features/chat/hooks/useChatSessionController.ts +++ b/src/features/chat/hooks/useChatSessionController.ts @@ -79,7 +79,10 @@ import { composeBuilderSendOptions } from "./useBuilderSendInterceptor"; import { moveSessionToProject } from "../stores/chatSessionOperations"; import { acpSetSessionConfigOption } from "@/shared/api/acp"; import { updateSessionProject } from "@/shared/api/acpApi"; -import { preSeedDraftAgent } from "@/features/agents/lib/agentBuilderSession"; +import { + migratePendingDraftAgent, + preSeedDraftAgent, +} from "@/features/agents/lib/agentBuilderSession"; import { personaExecutionTarget } from "@/features/agents/lib/personaExecutionTarget"; import { deletePersonaSource } from "@/shared/api/agents"; import type { Persona } from "@/shared/types/agents"; @@ -1724,9 +1727,23 @@ export function useChatSessionController({ submittedDraftGeneration, ); } - const wasSubmittedWithoutDraftOwnership = + let 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 && @@ -2119,7 +2136,11 @@ export function useChatSessionController({ const activation = (async () => { const chatSessions = useChatSessionStore.getState(); - const currentSession = chatSessions.getSession(sessionId); + const currentSession = chatSessions.sessions.find( + (candidate) => + candidate.id === sessionId || + candidate.clientSessionId === sessionId, + ); if (!currentSession) { return null; } @@ -2128,7 +2149,9 @@ export function useChatSessionController({ currentSession.targetAgentPath ) { if (currentSession.agentBuilderOpen !== true) { - chatSessions.patchSession(sessionId, { agentBuilderOpen: true }); + chatSessions.patchSession(currentSession.id, { + agentBuilderOpen: true, + }); return { ...currentSession, agentBuilderOpen: true }; } return currentSession; @@ -2136,14 +2159,21 @@ export function useChatSessionController({ const target = await preSeedDraftAgent(sessionId); const liveChatSessions = useChatSessionStore.getState(); - const liveSession = liveChatSessions.getSession(sessionId); + const liveSession = liveChatSessions.sessions.find( + (candidate) => + candidate.id === sessionId || + candidate.clientSessionId === sessionId, + ); + const liveSessionId = liveSession?.id; const liveSkills = - useChatStore.getState().skillDraftsBySession[stateSessionId] ?? - EMPTY_SKILL_DRAFTS; + useChatStore.getState().skillDraftsBySession[ + liveSessionId ?? stateSessionId + ] ?? EMPTY_SKILL_DRAFTS; if ( !liveSession || liveSession.archivedAt || + liveSession.creationState === "failed" || (options?.requireSelectedSkill && !hasAgentBuilderSkillDraft(liveSkills)) ) { @@ -2153,6 +2183,14 @@ export function useChatSessionController({ return null; } + if (liveSession.id !== sessionId) { + await migratePendingDraftAgent( + sessionId, + liveSession.id, + target.path, + ); + } + if ( liveSession.intent === "build-agent" && liveSession.targetAgentPath @@ -2170,18 +2208,18 @@ export function useChatSessionController({ targetAgentSlug: target.slug, }; - liveChatSessions.patchSession(sessionId, patch); + liveChatSessions.patchSession(liveSession.id, patch); const chatStateNow = useChatStore.getState(); const currentSkills = - chatStateNow.skillDraftsBySession[stateSessionId] ?? + chatStateNow.skillDraftsBySession[liveSession.id] ?? EMPTY_SKILL_DRAFTS; chatStateNow.setSkillDrafts( - stateSessionId, + liveSession.id, ensureAgentBuilderSkillDraft(currentSkills), ); - return { ...currentSession, ...patch }; + return { ...liveSession, ...patch }; })(); pendingBuilderActivationRef.current[sessionId] = activation; @@ -2297,17 +2335,24 @@ export function useChatSessionController({ ? selectedPersona.displayName : useAgentStore.getState().getPersonaById(personaId)?.displayName : undefined; - const enqueueMessage = (options = sendOptions) => { - const accepted = enqueueCapturedMessage( - captureSessionSelection({ - text, - persona: personaIntentFromComposer(personaId, personaName), - attachments, - sendOptions: options, - }), - ); + 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); if (accepted && sessionId) { - recordDraftPreservingSubmission(sessionId, text); + recordDraftPreservingSubmission(targetSessionId, text); } return accepted; }; @@ -2330,6 +2375,7 @@ export function useChatSessionController({ return (async () => { const builderSession = await ensureCurrentSessionIsAgentBuilder(); if (!builderSession) return false; + const builderSessionId = builderSession.id; const onBuilderWorkspaceNameRequest = onWorkspaceNameRequest ? (request: WorkspaceNameRequest) => onWorkspaceNameRequest({ @@ -2338,17 +2384,19 @@ export function useChatSessionController({ request.cancel(); const liveSession = useChatSessionStore .getState() - .getSession(sessionId); + .getSession(builderSessionId); if ( liveSession?.intent === "build-agent" && liveSession.targetAgentPath === builderSession.targetAgentPath ) { - useChatSessionStore.getState().patchSession(sessionId, { - intent: undefined, - targetAgentPath: undefined, - targetAgentSlug: undefined, - }); + useChatSessionStore + .getState() + .patchSession(builderSessionId, { + intent: undefined, + targetAgentPath: undefined, + targetAgentSlug: undefined, + }); if (builderSession.targetAgentPath) { void deletePersonaSource( builderSession.targetAgentPath, @@ -2368,14 +2416,14 @@ export function useChatSessionController({ sendOptions, ); if ( - (useChatStore.getState().queuedMessageBySession[stateSessionId] + (useChatStore.getState().queuedMessageBySession[builderSessionId] ?.length ?? 0) > 0 ) { - enqueueMessage(deferredSendOptions); + enqueueMessage(deferredSendOptions, builderSessionId); return true; } const firstSend = acceptFirstSend( - sessionId, + builderSessionId, captureSessionSelection({ text, persona: personaIntentFromComposer(personaId, personaName), @@ -2390,8 +2438,8 @@ export function useChatSessionController({ }, ); if (firstSend.accepted) { - recordDraftPreservingSubmission(sessionId, text); - onMessageAccepted?.(sessionId); + recordDraftPreservingSubmission(builderSessionId, text); + onMessageAccepted?.(builderSessionId); if (personaId && personaId !== selectedPersonaId) { handlePersonaChange(personaId); } @@ -2399,17 +2447,20 @@ export function useChatSessionController({ } if (firstSend.needsName || firstSend.occupied) return false; if (personaId && personaId !== selectedPersonaId) { - const accepted = enqueueMessage(deferredSendOptions); + const accepted = enqueueMessage( + deferredSendOptions, + builderSessionId, + ); if (accepted) { handlePersonaChange(personaId); } return accepted; } if (!workspaceContextReady) { - enqueueMessage(deferredSendOptions); + enqueueMessage(deferredSendOptions, builderSessionId); return true; } - return enqueueMessage(deferredSendOptions); + return enqueueMessage(deferredSendOptions, builderSessionId); })(); } diff --git a/src/features/chat/ui/ChatView.tsx b/src/features/chat/ui/ChatView.tsx index 1eca851a2..1c8af7cbd 100644 --- a/src/features/chat/ui/ChatView.tsx +++ b/src/features/chat/ui/ChatView.tsx @@ -671,8 +671,6 @@ export function ChatView({ let sendDisabledReason: string | undefined; if (readOnlyStatus) { sendDisabledReason = readOnlyStatus; - } else if (effectiveSession?.creationState === "pending") { - sendDisabledReason = t("toolbar.sessionStarting"); } else if (effectiveSession?.creationState === "failed") { sendDisabledReason = effectiveSession.creationError ?? t("toolbar.sessionStartFailed"); @@ -792,7 +790,7 @@ export function ChatView({ controller.isCompactingContext, sendDisabled: isReadOnly || - effectiveSession?.creationState != null || + effectiveSession?.creationState === "failed" || isAgentBuilderTargetPending || controller.workspaceSetupInProgress, sendDisabledReason, diff --git a/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx b/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx index f6e3c5802..832966c2a 100644 --- a/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx +++ b/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx @@ -587,6 +587,66 @@ describe("ChatView MCP app messaging", () => { expect(timelineProps.showPlaceholder).toBe(false); }); + it("keeps the composer enabled while a blank draft session is pending", () => { + mocks.useChatSessionController.mockReturnValue({ + ...mocks.useChatSessionController(), + messages: [], + }); + + render( + , + ); + + const chatInputProps = mocks.chatInputSpy.mock.calls.at(-1)?.[0] as { + composerActions?: { + onSend?: (text: string) => boolean; + sendDisabled?: boolean; + sendDisabledReason?: string; + }; + }; + 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", () => { mocks.useChatSessionController.mockReturnValue({ ...mocks.useChatSessionController(),