From 206e2277ec97f5a7083345d4a9dd189e0af23a6e Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 18 Aug 2026 09:47:48 -0400 Subject: [PATCH] fix(chat): queue messages safely while sessions start Allow new chats to accept queued messages before backend creation finishes without dispatching them early. Define the queue's acceptance and dispatch contract, defer Agent Builder draft preparation until the final session ID exists, and discard preparation when its triggering queue record is removed or superseded. Signed-off-by: Logan Johnson Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: Chai Landau Co-authored-by: Morgan Martin <98432065+morgmart@users.noreply.github.com> Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- LAWS/CHAT.md | 59 ++- .../agents/lib/agentBuilderSession.ts | 6 +- .../useChatSessionController.test.ts | 498 ++++++++++++++++++ .../hooks/__tests__/useMessageQueue.test.ts | 69 +++ .../chat/hooks/useChatSessionController.ts | 179 ++++++- src/features/chat/ui/ChatView.tsx | 10 +- .../ui/__tests__/ChatView.mcpApp.test.tsx | 55 ++ 7 files changed, 814 insertions(+), 62 deletions(-) diff --git a/LAWS/CHAT.md b/LAWS/CHAT.md index 6bbd789d8..90f52f25f 100644 --- a/LAWS/CHAT.md +++ b/LAWS/CHAT.md @@ -1,39 +1,40 @@ -# 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 dispatch each message to that chat's session with the model and provider shown when the composer queued it. +- 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/features/agents/lib/agentBuilderSession.ts b/src/features/agents/lib/agentBuilderSession.ts index cf684fe07..277f55159 100644 --- a/src/features/agents/lib/agentBuilderSession.ts +++ b/src/features/agents/lib/agentBuilderSession.ts @@ -183,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; } @@ -220,7 +220,9 @@ async function prepareProvisionalDraftTarget( }); } -function markProvisionalDraftTargetFailed(initialSessionId: string): void { +export function markAgentBuilderSessionPreparationFailed( + initialSessionId: string, +): void { const session = findSessionByInitialId(initialSessionId); if ( !session || diff --git a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts index 95152412a..5dce95066 100644 --- a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts +++ b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts @@ -40,6 +40,8 @@ const mockUseChatHook = vi.fn(); const mockUseMessageQueue = vi.fn(); const mockPickerOpen = vi.fn(); const mockPreSeedDraftAgent = vi.fn(); +const mockClearBuilderSessionState = vi.fn(); +const mockMarkAgentBuilderSessionPreparationFailed = vi.fn(); const mockDeletePersonaSource = vi.fn(); const mockAcpCreateSession = vi.fn(); const mockAcpSessionArchive = vi.fn(); @@ -188,6 +190,10 @@ vi.mock("../useAutoCompactPreferences", () => ({ vi.mock("@/features/agents/lib/agentBuilderSession", () => ({ preSeedDraftAgent: (...args: unknown[]) => mockPreSeedDraftAgent(...args), + clearBuilderSessionState: (...args: unknown[]) => + mockClearBuilderSessionState(...args), + markAgentBuilderSessionPreparationFailed: (...args: unknown[]) => + mockMarkAgentBuilderSessionPreparationFailed(...args), })); vi.mock("@/shared/api/agents", () => ({ @@ -1006,6 +1012,450 @@ describe("useChatSessionController", () => { } }); + 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(() => { + 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", () => { useChatSessionStore.setState({ sessions: [ @@ -4377,6 +4827,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/__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..a4f590123 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 { + markAgentBuilderSessionPreparationFailed, + 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"; @@ -1980,9 +1983,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" && @@ -2040,11 +2044,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( @@ -2103,18 +2122,53 @@ 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 () => { @@ -2135,6 +2189,16 @@ export function useChatSessionController({ } 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.getSession(sessionId); const liveSkills = @@ -2184,18 +2248,47 @@ export function useChatSessionController({ 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 = @@ -2322,6 +2415,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) && @@ -2494,6 +2619,7 @@ export function useChatSessionController({ readOnly, recordDraftPreservingSubmission, session?.agentBuilderOpen, + session?.creationState, session?.intent, sessionId, selectedPersona, @@ -2656,6 +2782,7 @@ export function useChatSessionController({ if ( !sessionId || !skillWasJustSelected || + session?.creationState === "pending" || (session?.intent === "build-agent" && session.agentBuilderOpen !== false) ) { return; @@ -2663,24 +2790,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/ui/ChatView.tsx b/src/features/chat/ui/ChatView.tsx index 1eca851a2..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( @@ -671,15 +669,11 @@ 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"); } 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, @@ -792,8 +786,8 @@ export function ChatView({ controller.isCompactingContext, sendDisabled: isReadOnly || - effectiveSession?.creationState != null || - isAgentBuilderTargetPending || + effectiveSession?.creationState === "failed" || + 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 f6e3c5802..6861ce5e0 100644 --- a/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx +++ b/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx @@ -587,6 +587,35 @@ describe("ChatView MCP app messaging", () => { expect(timelineProps.showPlaceholder).toBe(false); }); + it("keeps Send available 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?: unknown; + sendDisabled?: boolean; + sendDisabledReason?: string; + }; + }; + expect(chatInputProps.composerActions?.onSend).toBe(mocks.handleSend); + expect(chatInputProps.composerActions?.sendDisabled).toBe(false); + expect(chatInputProps.composerActions?.sendDisabledReason).toBeUndefined(); + }); + it("keeps the empty-state placeholder visible while a blank draft session is pending", () => { mocks.useChatSessionController.mockReturnValue({ ...mocks.useChatSessionController(), @@ -1109,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",