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