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",