diff --git a/src/app/AppShell.navigation.test.tsx b/src/app/AppShell.navigation.test.tsx
index 5ab22bd53..6e3471374 100644
--- a/src/app/AppShell.navigation.test.tsx
+++ b/src/app/AppShell.navigation.test.tsx
@@ -15,12 +15,14 @@ import { i18n } from "@/shared/i18n";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import { getAppNavigationController } from "@/features/berdctl/navigation";
+import { placeholderAgentName } from "@/features/agents/lib/agentBuilderIdentity";
import { resetAgentBuilderSourceLifecycleForTests } from "@/features/agents/lib/agentBuilderSourceLifecycle";
import { useChatStore } from "@/features/chat/stores/chatStore";
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
import { ensureReplayBuffer } from "@/features/chat/hooks/replayBuffer";
import { createUserMessage } from "@/shared/types/messages";
import { useSessionWindowStore } from "@/features/chat/stores/sessionWindowStore";
+import { BackgroundQueuedMessageDrain } from "@/features/chat/ui/BackgroundQueuedMessageDrain";
import type { ChatSession } from "@/features/chat/stores/chatSessionStore";
import type { Message } from "@/shared/types/messages";
import type { GitState } from "@/shared/types/git";
@@ -94,6 +96,10 @@ const mockCreatePersonaSource = vi.hoisted(() => vi.fn());
const mockListPersonaSources = vi.hoisted(() => vi.fn());
const mockReadAgentSourceFile = vi.hoisted(() => vi.fn());
const mockDeletePersonaSource = vi.hoisted(() => vi.fn());
+const mockUpdatePersonaSource = vi.hoisted(() => vi.fn());
+const mockSendQueuedPromptToExistingSessionInBackground = vi.hoisted(() =>
+ vi.fn(),
+);
const mockListPersonas = vi.hoisted(() => vi.fn());
const mockRepairBundledAgent = vi.hoisted(() => vi.fn());
const mockAutomationBuilderSave = vi.hoisted(() => vi.fn());
@@ -144,7 +150,10 @@ function flushAfterNextPaintCallbacks() {
}
}
-function appShellWithTheme(children?: ReactNode) {
+function appShellWithTheme(
+ children?: ReactNode,
+ options?: { backgroundQueueDrain?: boolean },
+) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
@@ -152,13 +161,19 @@ function appShellWithTheme(children?: ReactNode) {
{children}
+ {options?.backgroundQueueDrain ? (
+
+ ) : null}
);
}
-function renderAppShell(children?: ReactNode) {
- return render(appShellWithTheme(children));
+function renderAppShell(
+ children?: ReactNode,
+ options?: { backgroundQueueDrain?: boolean },
+) {
+ return render(appShellWithTheme(children, options));
}
function managedWorktreeGitState(
@@ -449,6 +464,11 @@ vi.mock("@/shared/api/acpApi", () => ({
updateSessionProject: vi.fn().mockResolvedValue(undefined),
}));
+vi.mock("@/features/chat/lib/queuedSessionSend", () => ({
+ sendQueuedPromptToExistingSessionInBackground: (...args: unknown[]) =>
+ mockSendQueuedPromptToExistingSessionInBackground(...args),
+}));
+
vi.mock("@/shared/api/git", () => ({
countBranchCommitsNotInBase: (...args: unknown[]) =>
gitMocks.countBranchCommitsNotInBase(...args),
@@ -478,6 +498,7 @@ vi.mock("@/shared/api/agents", () => ({
repairBundledAgent: (...args: unknown[]) => mockRepairBundledAgent(...args),
readAgentSourceFile: (...args: unknown[]) => mockReadAgentSourceFile(...args),
deletePersonaSource: (...args: unknown[]) => mockDeletePersonaSource(...args),
+ updatePersonaSource: (...args: unknown[]) => mockUpdatePersonaSource(...args),
promotePersonaSource: vi.fn().mockResolvedValue(null),
}));
@@ -1009,6 +1030,11 @@ describe("AppShell global navigation", () => {
mockReadAgentSourceFile.mockRejectedValue(new Error("not found"));
mockDeletePersonaSource.mockReset();
mockDeletePersonaSource.mockResolvedValue(undefined);
+ mockUpdatePersonaSource.mockReset();
+ mockSendQueuedPromptToExistingSessionInBackground.mockReset();
+ mockSendQueuedPromptToExistingSessionInBackground.mockResolvedValue(
+ undefined,
+ );
mockAutomationBuilderSave.mockReset();
useChatStore.setState({
messagesBySession: {},
@@ -3379,6 +3405,162 @@ describe("AppShell global navigation", () => {
});
});
+ it("holds a queued builder prompt until draft identity migration completes", async () => {
+ const pendingSession = deferred<{ sessionId: string }>();
+ const pendingMigration = deferred<{
+ type: "agent";
+ path: string;
+ name: string;
+ description: string;
+ content: string;
+ global: boolean;
+ writable: boolean;
+ properties: { draft: true; builderSessionId: string };
+ }>();
+ mockAcpCreateSession.mockReturnValueOnce(pendingSession.promise);
+ const user = userEvent.setup();
+ renderAppShell(undefined, { backgroundQueueDrain: true });
+
+ await user.click(screen.getByRole("button", { name: "Sidebar new chat" }));
+ await waitFor(() => expect(mockAcpCreateSession).toHaveBeenCalled());
+
+ const draftSessionId = useChatSessionStore.getState().activeSessionId ?? "";
+ const targetAgentPath =
+ "/Users/test/.agents/agents/pending-builder-draft.md";
+ const localDraft = {
+ type: "agent" as const,
+ path: targetAgentPath,
+ name: placeholderAgentName(draftSessionId),
+ description: "Draft",
+ content: "Draft in progress.",
+ global: true,
+ writable: true,
+ properties: { draft: true, builderSessionId: draftSessionId },
+ };
+ useChatSessionStore.getState().patchSession(draftSessionId, {
+ intent: "build-agent",
+ agentBuilderOpen: true,
+ targetAgentPath,
+ targetAgentSlug: "pending-builder-draft",
+ });
+ mockListPersonaSources.mockResolvedValue([localDraft]);
+ mockReadAgentSourceFile.mockResolvedValue(localDraft);
+ mockUpdatePersonaSource.mockReturnValueOnce(pendingMigration.promise);
+ useChatStore.getState().enqueueTransportReadyMessage(draftSessionId, {
+ text: "make a reviewer",
+ persona: { kind: "inherit" },
+ sendOptions: {
+ assistantPrompt: `Use agent-builder at ${targetAgentPath}.`,
+ },
+ });
+ useChatSessionStore.setState({ hasHydratedSessions: true });
+
+ act(() => pendingSession.resolve({ sessionId: "created-session" }));
+
+ await waitFor(() => {
+ expect(mockUpdatePersonaSource).toHaveBeenCalledWith(targetAgentPath, {
+ name: placeholderAgentName("created-session"),
+ properties: {
+ draft: true,
+ builderSessionId: "created-session",
+ },
+ });
+ });
+ expect(
+ useChatSessionStore.getState().getSession(draftSessionId),
+ ).toMatchObject({ creationState: "pending" });
+ expect(
+ useChatStore.getState().queuedMessageBySession[draftSessionId]?.[0],
+ ).toMatchObject({ payload: { text: "make a reviewer" } });
+ expect(
+ mockSendQueuedPromptToExistingSessionInBackground,
+ ).not.toHaveBeenCalled();
+
+ act(() => {
+ pendingMigration.resolve({
+ ...localDraft,
+ name: placeholderAgentName("created-session"),
+ properties: {
+ draft: true,
+ builderSessionId: "created-session",
+ },
+ });
+ });
+
+ await waitFor(() => {
+ expect(
+ mockSendQueuedPromptToExistingSessionInBackground,
+ ).toHaveBeenCalledTimes(1);
+ });
+ expect(
+ mockSendQueuedPromptToExistingSessionInBackground.mock.calls[0]?.[0],
+ ).toBe("created-session");
+ expect(useChatSessionStore.getState().activeSessionId).toBe(
+ "created-session",
+ );
+ });
+
+ it("fails creation without dispatch when builder draft migration fails", async () => {
+ const pendingSession = deferred<{ sessionId: string }>();
+ mockAcpCreateSession.mockReturnValueOnce(pendingSession.promise);
+ const user = userEvent.setup();
+ renderAppShell(undefined, { backgroundQueueDrain: true });
+
+ await user.click(screen.getByRole("button", { name: "Sidebar new chat" }));
+ await waitFor(() => expect(mockAcpCreateSession).toHaveBeenCalled());
+
+ const draftSessionId = useChatSessionStore.getState().activeSessionId ?? "";
+ const targetAgentPath =
+ "/Users/test/.agents/agents/pending-builder-draft.md";
+ const localDraft = {
+ type: "agent" as const,
+ path: targetAgentPath,
+ name: placeholderAgentName(draftSessionId),
+ description: "Draft",
+ content: "Draft in progress.",
+ global: true,
+ writable: true,
+ properties: { draft: true, builderSessionId: draftSessionId },
+ };
+ useChatSessionStore.getState().patchSession(draftSessionId, {
+ intent: "build-agent",
+ agentBuilderOpen: true,
+ targetAgentPath,
+ targetAgentSlug: "pending-builder-draft",
+ });
+ mockListPersonaSources.mockResolvedValue([localDraft]);
+ mockReadAgentSourceFile.mockResolvedValue(localDraft);
+ mockUpdatePersonaSource.mockRejectedValueOnce(
+ new Error("draft identity update failed"),
+ );
+ useChatStore.getState().enqueueTransportReadyMessage(draftSessionId, {
+ text: "make a reviewer",
+ persona: { kind: "inherit" },
+ });
+ useChatSessionStore.setState({ hasHydratedSessions: true });
+
+ act(() => pendingSession.resolve({ sessionId: "created-session" }));
+
+ await waitFor(() => {
+ expect(mockAcpArchiveSession).toHaveBeenCalledWith("created-session");
+ expect(
+ useChatSessionStore.getState().getSession(draftSessionId),
+ ).toMatchObject({
+ creationState: "failed",
+ creationError: "draft identity update failed",
+ });
+ });
+ expect(
+ useChatStore.getState().queuedMessageBySession[draftSessionId]?.[0],
+ ).toMatchObject({ payload: { text: "make a reviewer" } });
+ expect(
+ useChatStore.getState().queuedMessageBySession["created-session"],
+ ).toBeUndefined();
+ expect(
+ mockSendQueuedPromptToExistingSessionInBackground,
+ ).not.toHaveBeenCalled();
+ });
+
it("applies the latest pending draft selection before promotion", async () => {
const pendingSession = deferred<{ sessionId: string }>();
const pendingPrepare = deferred>();
diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx
index cfd3431df..b44e8cb5a 100644
--- a/src/app/AppShell.tsx
+++ b/src/app/AppShell.tsx
@@ -113,6 +113,7 @@ import {
setSettingsSectionUrl,
} from "./lib/settingsSectionUrl";
import { useAgentBuilderCoordinator } from "@/features/agents/hooks/useAgentBuilderCoordinator";
+import { migratePendingDraftAgent } from "@/features/agents/lib/agentBuilderSession";
import {
type ArchiveCleanupPolicy,
MUTATION_DEADLINE_MARGIN_MS,
@@ -1973,6 +1974,13 @@ export function AppShell({
pendingSelectionIntent.requestId,
);
}
+ if (latestSessionPatch.targetAgentPath) {
+ await migratePendingDraftAgent(
+ session.id,
+ sessionId,
+ latestSessionPatch.targetAgentPath,
+ );
+ }
promoteChatSessionId(session.id, sessionId);
transferSessionTargetOwnership(session.id, sessionId);
promoteDraftSession(session.id, sessionId, {
diff --git a/src/features/agents/lib/__tests__/agentBuilderSession.test.ts b/src/features/agents/lib/__tests__/agentBuilderSession.test.ts
index 49211ceb6..da0ffc934 100644
--- a/src/features/agents/lib/__tests__/agentBuilderSession.test.ts
+++ b/src/features/agents/lib/__tests__/agentBuilderSession.test.ts
@@ -29,6 +29,7 @@ const mocks = vi.hoisted(() => ({
createPersonaSource: vi.fn(),
deletePersonaSource: vi.fn(),
promotePersonaSource: vi.fn(),
+ updatePersonaSource: vi.fn(),
listPersonaSources: vi.fn(),
readAgentSourceFile: vi.fn(),
}));
@@ -89,6 +90,7 @@ vi.mock("@/shared/api/agents", () => ({
createPersonaSource: mocks.createPersonaSource,
deletePersonaSource: mocks.deletePersonaSource,
promotePersonaSource: mocks.promotePersonaSource,
+ updatePersonaSource: mocks.updatePersonaSource,
listPersonaSources: mocks.listPersonaSources,
readAgentSourceFile: mocks.readAgentSourceFile,
}));
@@ -103,6 +105,7 @@ import {
discardDraftAgentSession,
hasAgentBuilderSessionUserContent,
isEmptyDraftAgentSession,
+ migratePendingDraftAgent,
promoteDraft,
recoverDraftAgent,
reconcileAgentBuilderSessions,
@@ -169,6 +172,17 @@ describe("agentBuilderSession", () => {
mocks.createPersonaSource.mockReset();
mocks.deletePersonaSource.mockReset();
mocks.promotePersonaSource.mockReset();
+ mocks.updatePersonaSource.mockReset();
+ mocks.updatePersonaSource.mockImplementation(async (path, patch) => {
+ const existing = await mocks.readAgentSourceFile(path, undefined);
+ return {
+ ...existing,
+ ...patch,
+ properties: patch.properties
+ ? { ...(existing?.properties ?? {}), ...patch.properties }
+ : existing?.properties,
+ };
+ });
mocks.listPersonaSources.mockReset();
mocks.readAgentSourceFile.mockReset();
mocks.readAgentSourceFile.mockImplementation(
@@ -824,6 +838,83 @@ describe("agentBuilderSession", () => {
expect(mocks.createPersonaSource).toHaveBeenCalled();
});
+ it("migrates a pending builder draft to the promoted session id", async () => {
+ const localDraft = {
+ ...draftSource,
+ path: "/Users/x/.agents/agents/draft-local-session.md",
+ name: "Untitled agent local-sessio",
+ properties: { draft: true, builderSessionId: "local-session" },
+ };
+ chatState.sessions = [
+ {
+ id: "backend-session",
+ clientSessionId: "local-session",
+ intent: "build-agent",
+ targetAgentPath: localDraft.path,
+ },
+ ];
+ mocks.listPersonaSources.mockResolvedValue([localDraft]);
+ mocks.readAgentSourceFile.mockResolvedValue(localDraft);
+
+ await migratePendingDraftAgent("local-session", "backend-session");
+
+ expect(mocks.deletePersonaSource).not.toHaveBeenCalled();
+ expect(mocks.createPersonaSource).not.toHaveBeenCalled();
+ expect(mocks.patchSession).not.toHaveBeenCalled();
+ expect(mocks.promotePersonaSource).not.toHaveBeenCalled();
+ expect(mocks.updatePersonaSource).toHaveBeenCalledWith(localDraft.path, {
+ name: "Untitled agent backend-sess",
+ properties: {
+ draft: true,
+ builderSessionId: "backend-session",
+ },
+ });
+ });
+
+ it("rejects migration when the draft path belongs to another session", async () => {
+ const collidingDraft = {
+ ...draftSource,
+ path: "/Users/x/.agents/agents/colliding-draft.md",
+ name: "Collision",
+ properties: { draft: true, builderSessionId: "other-session" },
+ };
+ mocks.listPersonaSources.mockResolvedValue([collidingDraft]);
+ mocks.readAgentSourceFile.mockResolvedValue(collidingDraft);
+
+ await expect(
+ migratePendingDraftAgent(
+ "local-session",
+ "backend-session",
+ collidingDraft.path,
+ ),
+ ).rejects.toThrow(
+ "Pending Agent Builder draft belongs to a different session.",
+ );
+
+ expect(mocks.updatePersonaSource).not.toHaveBeenCalled();
+ });
+
+ it("does not rebind an existing non-draft agent target", async () => {
+ const existingAgent = {
+ ...draftSource,
+ path: "/Users/x/.agents/agents/reviewer.md",
+ name: "Reviewer",
+ properties: { draft: false },
+ };
+ mocks.listPersonaSources.mockResolvedValue([existingAgent]);
+ mocks.readAgentSourceFile.mockResolvedValue(existingAgent);
+
+ await expect(
+ migratePendingDraftAgent(
+ "local-session",
+ "backend-session",
+ existingAgent.path,
+ ),
+ ).resolves.toBeUndefined();
+
+ expect(mocks.updatePersonaSource).not.toHaveBeenCalled();
+ });
+
it("startup reconciliation patches loaded sessions from draft frontmatter", async () => {
chatState.sessions = [{ id: "sess-1" }];
mocks.listPersonaSources.mockResolvedValue([draftSource]);
diff --git a/src/features/agents/lib/agentBuilderSession.ts b/src/features/agents/lib/agentBuilderSession.ts
index cf684fe07..4dd8983d9 100644
--- a/src/features/agents/lib/agentBuilderSession.ts
+++ b/src/features/agents/lib/agentBuilderSession.ts
@@ -22,13 +22,17 @@ import {
listAgentBuilderSources,
promoteAgentBuilderDraftSource,
readFreshAgentSource,
+ updateAgentBuilderSource,
type DraftAgentDefaults,
+ type PersonaSourcePatch,
} from "./agentBuilderSourceLifecycle";
import type { AgentSourceEntry } from "@/shared/api/agents";
import {
deriveSlug,
fileStem,
isEmptyPlaceholderDraft,
+ isPlaceholderDraftForSession,
+ placeholderAgentName,
} from "./agentBuilderIdentity";
export {
deriveSlug,
@@ -369,6 +373,50 @@ export async function preSeedDraftAgent(
});
}
+export async function migratePendingDraftAgent(
+ draftSessionId: string,
+ backendSessionId: string,
+ targetPath?: string | null,
+): Promise {
+ const resolvedTargetPath =
+ targetPath ??
+ useChatSessionStore.getState().getSession(backendSessionId)
+ ?.targetAgentPath;
+ if (!resolvedTargetPath) {
+ return;
+ }
+
+ const source = await findAgentBuilderSource(
+ draftSessionId,
+ resolvedTargetPath,
+ );
+ if (!source) {
+ throw new Error(
+ "Pending Agent Builder target could not be verified before session promotion.",
+ );
+ }
+ if (source.properties?.draft !== true) {
+ return;
+ }
+ if (source.properties.builderSessionId !== draftSessionId) {
+ throw new Error(
+ "Pending Agent Builder draft belongs to a different session.",
+ );
+ }
+
+ const patch: PersonaSourcePatch = {
+ properties: {
+ ...source.properties,
+ builderSessionId: backendSessionId,
+ },
+ };
+ if (isPlaceholderDraftForSession(source, draftSessionId)) {
+ patch.name = placeholderAgentName(backendSessionId);
+ }
+
+ await updateAgentBuilderSource(source.path, patch);
+}
+
export async function recoverDraftAgent(
sessionId: string,
stalePath?: string | null,
diff --git a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts
index 95152412a..5ae47caaa 100644
--- a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts
+++ b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts
@@ -37,9 +37,19 @@ const mockToastError = vi.fn();
const mockUseChatSendMessage = vi.fn();
const mockUseChatSteerMessage = vi.fn();
const mockUseChatHook = vi.fn();
+const mockUseChatOptionsBySession = new Map<
+ string,
+ {
+ onMessageAccepted?: (
+ sessionId: string,
+ text: string,
+ ) => boolean | undefined;
+ }
+>();
const mockUseMessageQueue = vi.fn();
const mockPickerOpen = vi.fn();
const mockPreSeedDraftAgent = vi.fn();
+const mockMigratePendingDraftAgent = vi.fn();
const mockDeletePersonaSource = vi.fn();
const mockAcpCreateSession = vi.fn();
const mockAcpSessionArchive = vi.fn();
@@ -155,6 +165,7 @@ vi.mock("../useChat", () => ({
systemPromptOverride,
personaInfo,
);
+ mockUseChatOptionsBySession.set(sessionId, options ?? {});
const optionsWithSessionId = { ...options, __sessionId: sessionId };
return {
messages: [],
@@ -188,6 +199,8 @@ vi.mock("../useAutoCompactPreferences", () => ({
vi.mock("@/features/agents/lib/agentBuilderSession", () => ({
preSeedDraftAgent: (...args: unknown[]) => mockPreSeedDraftAgent(...args),
+ migratePendingDraftAgent: (...args: unknown[]) =>
+ mockMigratePendingDraftAgent(...args),
}));
vi.mock("@/shared/api/agents", () => ({
@@ -278,6 +291,29 @@ function latestMessageQueueArgs() {
];
}
+function mockQueueAdmissionWithoutDrain(): void {
+ mockUseMessageQueue.mockImplementation((sessionId: string) => ({
+ queuedMessage: null,
+ queuedRecords: [],
+ enqueue: (
+ text: string,
+ personaId?: string,
+ attachments?: ChatAttachmentDraft[],
+ sendOptions?: ChatSendOptions,
+ personaName?: string,
+ ) =>
+ useChatStore.getState().enqueueTransportReadyMessage(sessionId, {
+ text,
+ persona: personaId
+ ? { kind: "persona", id: personaId, name: personaName }
+ : { kind: "inherit" },
+ attachments,
+ sendOptions,
+ }),
+ dismiss: vi.fn(),
+ }));
+}
+
function expectSessionPreparation({
sessionId,
modelProviderId,
@@ -382,6 +418,7 @@ describe("useChatSessionController", () => {
beforeEach(() => {
resetSessionTargetCoordinatorsForTests();
vi.clearAllMocks();
+ mockUseChatOptionsBySession.clear();
delete modelFixtures["legacy-v1-model"];
resetManagedModelSelectionRepairCacheForTests();
useRuntimeConfigStore.setState({
@@ -517,6 +554,7 @@ describe("useChatSessionController", () => {
path: "/Users/x/.agents/agents/draft-from-chat.md",
slug: "draft-from-chat",
});
+ mockMigratePendingDraftAgent.mockResolvedValue(undefined);
mockPickerState.selectedAgentId = "goose";
mockPickerState.pickerAgents = [{ id: "goose", label: "Goose" }];
mockPickerState.availableModels = [];
@@ -795,6 +833,84 @@ describe("useChatSessionController", () => {
expect(sendResult).toBe(true);
});
+ it("accepts a composer message into a pending draft session queue", () => {
+ mockQueueAdmissionWithoutDrain();
+ useChatSessionStore.setState({
+ sessions: [
+ sessionFixture({
+ id: "draft-session",
+ clientSessionId: "draft-session",
+ creationState: "pending",
+ executionTarget: {
+ harnessId: "goose",
+ modelProviderId: "openai",
+ modelId: "gpt-4o",
+ modelName: "GPT-4o",
+ },
+ }),
+ ],
+ });
+
+ const { result } = renderHook(() =>
+ useChatSessionController({ sessionId: "draft-session" }),
+ );
+
+ act(() => {
+ expect(result.current.handleSend("send when ready")).toBe(true);
+ });
+
+ expect(mockUseChatSendMessage).not.toHaveBeenCalled();
+ expect(
+ useChatStore.getState().queuedMessageBySession["draft-session"]?.[0],
+ ).toMatchObject({
+ kind: "transport-ready",
+ payload: {
+ text: "send when ready",
+ persona: { kind: "inherit" },
+ },
+ });
+ });
+
+ it("preserves a newer draft when a pending send drains during promotion", () => {
+ mockQueueAdmissionWithoutDrain();
+ useChatSessionStore.setState({
+ sessions: [
+ sessionFixture({
+ id: "draft-session",
+ clientSessionId: "draft-session",
+ creationState: "pending",
+ executionTarget: {
+ harnessId: "goose",
+ modelProviderId: "openai",
+ modelId: "gpt-4o",
+ modelName: "GPT-4o",
+ },
+ }),
+ ],
+ });
+
+ const { result } = renderHook(() =>
+ useChatSessionController({ sessionId: "draft-session" }),
+ );
+ const acceptMessage =
+ mockUseChatOptionsBySession.get("draft-session")?.onMessageAccepted;
+ let shouldClearDraft: boolean | undefined;
+
+ act(() => {
+ expect(result.current.handleSend("send when ready")).toBe(true);
+ result.current.handleDraftChange("newer draft");
+ useChatStore
+ .getState()
+ .promoteSessionId("draft-session", "backend-session");
+ useChatSessionStore
+ .getState()
+ .promoteDraftSession("draft-session", "backend-session");
+ shouldClearDraft = acceptMessage?.("backend-session", "send when ready");
+ });
+
+ expect(shouldClearDraft).toBe(false);
+ });
+
it("preserves a newer draft when an older send is accepted later", async () => {
vi.useFakeTimers();
try {
@@ -2365,6 +2481,216 @@ describe("useChatSessionController", () => {
expect(sendOptions?.assistantPrompt).toContain("draft-from-chat.md");
});
+ it("queues agent-builder activation while draft session creation is pending", async () => {
+ mockQueueAdmissionWithoutDrain();
+ useChatSessionStore.setState({
+ sessions: [
+ sessionFixture({
+ id: "draft-session",
+ clientSessionId: "draft-session",
+ creationState: "pending",
+ executionTarget: {
+ harnessId: "goose",
+ modelProviderId: "openai",
+ modelId: "gpt-4o",
+ modelName: "GPT-4o",
+ },
+ }),
+ ],
+ });
+ const { result } = renderHook(() =>
+ useChatSessionController({ sessionId: "draft-session" }),
+ );
+
+ await act(async () => {
+ expect(
+ await result.current.handleSend(
+ "make a reviewer",
+ undefined,
+ undefined,
+ {
+ chips: [{ label: "agent-builder", type: "skill" }],
+ assistantPrompt: "Use agent-builder.",
+ },
+ ),
+ ).toBe(true);
+ });
+
+ expect(mockPreSeedDraftAgent).toHaveBeenCalledWith("draft-session");
+ expect(mockUseChatSendMessage).not.toHaveBeenCalled();
+ expect(
+ useChatSessionStore.getState().getSession("draft-session"),
+ ).toMatchObject({
+ intent: "build-agent",
+ agentBuilderOpen: true,
+ targetAgentPath: "/Users/x/.agents/agents/draft-from-chat.md",
+ });
+ expect(
+ useChatStore.getState().queuedMessageBySession["draft-session"]?.[0],
+ ).toMatchObject({
+ kind: "transport-ready",
+ payload: {
+ text: "make a reviewer",
+ sendOptions: {
+ assistantPrompt: expect.stringContaining("draft-from-chat.md"),
+ },
+ },
+ });
+ });
+
+ it("queues an in-flight builder activation against the promoted session", async () => {
+ mockQueueAdmissionWithoutDrain();
+ const pendingDraft = deferred<{ path: string; slug: string }>();
+ const pendingMigration = deferred();
+ mockPreSeedDraftAgent.mockReturnValueOnce(pendingDraft.promise);
+ mockMigratePendingDraftAgent.mockReturnValueOnce(pendingMigration.promise);
+ useChatSessionStore.setState({
+ sessions: [
+ sessionFixture({
+ id: "draft-session",
+ clientSessionId: "draft-session",
+ creationState: "pending",
+ executionTarget: {
+ harnessId: "goose",
+ modelProviderId: "openai",
+ modelId: "gpt-4o",
+ modelName: "GPT-4o",
+ },
+ }),
+ ],
+ });
+ const { result } = renderHook(() =>
+ useChatSessionController({ sessionId: "draft-session" }),
+ );
+
+ const sendResult = result.current.handleSend(
+ "make a reviewer",
+ undefined,
+ undefined,
+ {
+ chips: [{ label: "agent-builder", type: "skill" }],
+ assistantPrompt: "Use agent-builder.",
+ },
+ );
+ expect(sendResult).toBeInstanceOf(Promise);
+ await waitFor(() => {
+ expect(mockPreSeedDraftAgent).toHaveBeenCalledWith("draft-session");
+ });
+
+ act(() => {
+ useChatStore
+ .getState()
+ .promoteSessionId("draft-session", "backend-session");
+ useChatSessionStore
+ .getState()
+ .promoteDraftSession("draft-session", "backend-session");
+ });
+ await act(async () => {
+ pendingDraft.resolve({
+ path: "/Users/x/.agents/agents/draft-from-chat.md",
+ slug: "draft-from-chat",
+ });
+ await pendingDraft.promise;
+ });
+
+ await waitFor(() => {
+ expect(mockMigratePendingDraftAgent).toHaveBeenCalledWith(
+ "draft-session",
+ "backend-session",
+ "/Users/x/.agents/agents/draft-from-chat.md",
+ );
+ });
+ expect(useChatStore.getState().queuedMessageBySession).toEqual({});
+
+ let accepted: boolean | undefined;
+ await act(async () => {
+ pendingMigration.resolve();
+ accepted = await sendResult;
+ });
+
+ expect(accepted).toBe(true);
+ expect(mockDeletePersonaSource).not.toHaveBeenCalled();
+ expect(
+ useChatSessionStore.getState().getSession("backend-session"),
+ ).toMatchObject({
+ intent: "build-agent",
+ agentBuilderOpen: true,
+ targetAgentPath: "/Users/x/.agents/agents/draft-from-chat.md",
+ });
+ expect(
+ useChatStore.getState().queuedMessageBySession["draft-session"],
+ ).toBeUndefined();
+ expect(
+ useChatStore.getState().queuedMessageBySession["backend-session"]?.[0],
+ ).toMatchObject({
+ kind: "transport-ready",
+ payload: {
+ text: "make a reviewer",
+ sendOptions: {
+ assistantPrompt: expect.stringContaining("draft-from-chat.md"),
+ },
+ },
+ });
+ });
+
+ it("does not queue an in-flight builder activation after creation fails", async () => {
+ mockQueueAdmissionWithoutDrain();
+ const pendingDraft = deferred<{ path: string; slug: string }>();
+ mockPreSeedDraftAgent.mockReturnValueOnce(pendingDraft.promise);
+ useChatSessionStore.setState({
+ sessions: [
+ sessionFixture({
+ id: "draft-session",
+ clientSessionId: "draft-session",
+ creationState: "pending",
+ executionTarget: {
+ harnessId: "goose",
+ modelProviderId: "openai",
+ modelId: "gpt-4o",
+ modelName: "GPT-4o",
+ },
+ }),
+ ],
+ });
+ const { result } = renderHook(() =>
+ useChatSessionController({ sessionId: "draft-session" }),
+ );
+
+ const sendResult = result.current.handleSend(
+ "make a reviewer",
+ undefined,
+ undefined,
+ {
+ chips: [{ label: "agent-builder", type: "skill" }],
+ assistantPrompt: "Use agent-builder.",
+ },
+ );
+ await waitFor(() => {
+ expect(mockPreSeedDraftAgent).toHaveBeenCalledWith("draft-session");
+ });
+ act(() => {
+ useChatSessionStore
+ .getState()
+ .markSessionCreationFailed("draft-session", "creation failed");
+ });
+
+ let accepted: boolean | undefined;
+ await act(async () => {
+ pendingDraft.resolve({
+ path: "/Users/x/.agents/agents/draft-from-chat.md",
+ slug: "draft-from-chat",
+ });
+ accepted = await sendResult;
+ });
+
+ expect(accepted).toBe(false);
+ expect(mockDeletePersonaSource).toHaveBeenCalledWith(
+ "/Users/x/.agents/agents/draft-from-chat.md",
+ );
+ expect(mockMigratePendingDraftAgent).not.toHaveBeenCalled();
+ expect(useChatStore.getState().queuedMessageBySession).toEqual({});
+ });
+
it("activates builder mode before appending an agent-builder send", async () => {
useChatStore.getState().enqueueTransportReadyMessage("session-1", {
persona: { kind: "inherit" },
diff --git a/src/features/chat/hooks/__tests__/useMessageQueue.test.ts b/src/features/chat/hooks/__tests__/useMessageQueue.test.ts
index 85796a86a..7936a1f28 100644
--- a/src/features/chat/hooks/__tests__/useMessageQueue.test.ts
+++ b/src/features/chat/hooks/__tests__/useMessageQueue.test.ts
@@ -59,6 +59,75 @@ describe("useMessageQueue", () => {
});
});
+ it("admits under a pending draft id, then dispatches once after promotion", async () => {
+ useChatSessionStore.setState({
+ sessions: [
+ {
+ id: "draft-session",
+ clientSessionId: "draft-session",
+ title: "Chat",
+ executionTarget: { harnessId: "goose" },
+ creationState: "pending",
+ createdAt: "2026-04-20T00:00:00.000Z",
+ updatedAt: "2026-04-20T00:00:00.000Z",
+ messageCount: 0,
+ },
+ ],
+ });
+ const dispatch = vi.fn().mockReturnValue(true);
+ const { result, rerender } = renderHook(
+ ({ sessionId, ready }: { sessionId: string; ready: boolean }) =>
+ useMessageQueue(
+ sessionId,
+ ready ? "idle" : "thinking",
+ (text, persona, attachments, options) =>
+ dispatch(sessionId, text, persona, attachments, options),
+ false,
+ false,
+ ready,
+ ),
+ {
+ initialProps: { sessionId: "draft-session", ready: false },
+ },
+ );
+
+ act(() => {
+ expect(result.current.enqueue("send when ready")).toBe(true);
+ });
+
+ expect(
+ useChatStore.getState().queuedMessageBySession["draft-session"]?.[0],
+ ).toMatchObject({
+ kind: "transport-ready",
+ payload: { text: "send when ready" },
+ });
+ expect(dispatch).not.toHaveBeenCalled();
+
+ act(() => {
+ useChatStore
+ .getState()
+ .promoteSessionId("draft-session", "backend-session");
+ useChatSessionStore
+ .getState()
+ .promoteDraftSession("draft-session", "backend-session");
+ });
+ expect(dispatch).not.toHaveBeenCalled();
+ expect(
+ useChatStore.getState().queuedMessageBySession["backend-session"]?.[0],
+ ).toMatchObject({ payload: { text: "send when ready" } });
+
+ rerender({ sessionId: "backend-session", ready: true });
+
+ await waitFor(() => expect(dispatch).toHaveBeenCalledOnce());
+ expect(dispatch.mock.calls[0]?.slice(0, 2)).toEqual([
+ "backend-session",
+ "send when ready",
+ ]);
+ expect(
+ useChatStore.getState().queuedMessageBySession["backend-session"],
+ ).toBeUndefined();
+ });
+
it("drains an exact head once when its session gains a target", async () => {
const sendMessage = vi.fn().mockReturnValue(true);
useChatSessionStore
diff --git a/src/features/chat/hooks/useChatSessionController.ts b/src/features/chat/hooks/useChatSessionController.ts
index 70a5c9e5c..7fa28608b 100644
--- a/src/features/chat/hooks/useChatSessionController.ts
+++ b/src/features/chat/hooks/useChatSessionController.ts
@@ -79,7 +79,10 @@ import { composeBuilderSendOptions } from "./useBuilderSendInterceptor";
import { moveSessionToProject } from "../stores/chatSessionOperations";
import { acpSetSessionConfigOption } from "@/shared/api/acp";
import { updateSessionProject } from "@/shared/api/acpApi";
-import { preSeedDraftAgent } from "@/features/agents/lib/agentBuilderSession";
+import {
+ migratePendingDraftAgent,
+ preSeedDraftAgent,
+} from "@/features/agents/lib/agentBuilderSession";
import { personaExecutionTarget } from "@/features/agents/lib/personaExecutionTarget";
import { deletePersonaSource } from "@/shared/api/agents";
import type { Persona } from "@/shared/types/agents";
@@ -1724,9 +1727,23 @@ export function useChatSessionController({
submittedDraftGeneration,
);
}
- const wasSubmittedWithoutDraftOwnership =
+ let wasSubmittedWithoutDraftOwnership =
submittedDraftGeneration === null &&
takeDraftPreservingSubmission(acceptedSessionId, submittedText);
+ if (
+ submittedDraftGeneration === null &&
+ !wasSubmittedWithoutDraftOwnership
+ ) {
+ const clientSessionId = useChatSessionStore
+ .getState()
+ .getSession(acceptedSessionId)?.clientSessionId;
+ if (clientSessionId && clientSessionId !== acceptedSessionId) {
+ wasSubmittedWithoutDraftOwnership = takeDraftPreservingSubmission(
+ clientSessionId,
+ submittedText,
+ );
+ }
+ }
const draftSnapshot = getDraftSnapshot(acceptedSessionId);
const hasNewerDraftEdit =
submittedDraftGeneration !== null &&
@@ -2119,7 +2136,11 @@ export function useChatSessionController({
const activation = (async () => {
const chatSessions = useChatSessionStore.getState();
- const currentSession = chatSessions.getSession(sessionId);
+ const currentSession = chatSessions.sessions.find(
+ (candidate) =>
+ candidate.id === sessionId ||
+ candidate.clientSessionId === sessionId,
+ );
if (!currentSession) {
return null;
}
@@ -2128,7 +2149,9 @@ export function useChatSessionController({
currentSession.targetAgentPath
) {
if (currentSession.agentBuilderOpen !== true) {
- chatSessions.patchSession(sessionId, { agentBuilderOpen: true });
+ chatSessions.patchSession(currentSession.id, {
+ agentBuilderOpen: true,
+ });
return { ...currentSession, agentBuilderOpen: true };
}
return currentSession;
@@ -2136,14 +2159,21 @@ export function useChatSessionController({
const target = await preSeedDraftAgent(sessionId);
const liveChatSessions = useChatSessionStore.getState();
- const liveSession = liveChatSessions.getSession(sessionId);
+ const liveSession = liveChatSessions.sessions.find(
+ (candidate) =>
+ candidate.id === sessionId ||
+ candidate.clientSessionId === sessionId,
+ );
+ const liveSessionId = liveSession?.id;
const liveSkills =
- useChatStore.getState().skillDraftsBySession[stateSessionId] ??
- EMPTY_SKILL_DRAFTS;
+ useChatStore.getState().skillDraftsBySession[
+ liveSessionId ?? stateSessionId
+ ] ?? EMPTY_SKILL_DRAFTS;
if (
!liveSession ||
liveSession.archivedAt ||
+ liveSession.creationState === "failed" ||
(options?.requireSelectedSkill &&
!hasAgentBuilderSkillDraft(liveSkills))
) {
@@ -2153,6 +2183,14 @@ export function useChatSessionController({
return null;
}
+ if (liveSession.id !== sessionId) {
+ await migratePendingDraftAgent(
+ sessionId,
+ liveSession.id,
+ target.path,
+ );
+ }
+
if (
liveSession.intent === "build-agent" &&
liveSession.targetAgentPath
@@ -2170,18 +2208,18 @@ export function useChatSessionController({
targetAgentSlug: target.slug,
};
- liveChatSessions.patchSession(sessionId, patch);
+ liveChatSessions.patchSession(liveSession.id, patch);
const chatStateNow = useChatStore.getState();
const currentSkills =
- chatStateNow.skillDraftsBySession[stateSessionId] ??
+ chatStateNow.skillDraftsBySession[liveSession.id] ??
EMPTY_SKILL_DRAFTS;
chatStateNow.setSkillDrafts(
- stateSessionId,
+ liveSession.id,
ensureAgentBuilderSkillDraft(currentSkills),
);
- return { ...currentSession, ...patch };
+ return { ...liveSession, ...patch };
})();
pendingBuilderActivationRef.current[sessionId] = activation;
@@ -2297,17 +2335,24 @@ export function useChatSessionController({
? selectedPersona.displayName
: useAgentStore.getState().getPersonaById(personaId)?.displayName
: undefined;
- const enqueueMessage = (options = sendOptions) => {
- const accepted = enqueueCapturedMessage(
- captureSessionSelection({
- text,
- persona: personaIntentFromComposer(personaId, personaName),
- attachments,
- sendOptions: options,
- }),
- );
+ const enqueueMessage = (
+ options = sendOptions,
+ targetSessionId = stateSessionId,
+ ) => {
+ const payload = captureSessionSelection({
+ text,
+ persona: personaIntentFromComposer(personaId, personaName),
+ attachments,
+ sendOptions: options,
+ });
+ const accepted =
+ targetSessionId === stateSessionId
+ ? enqueueCapturedMessage(payload)
+ : useChatStore
+ .getState()
+ .enqueueTransportReadyMessage(targetSessionId, payload);
if (accepted && sessionId) {
- recordDraftPreservingSubmission(sessionId, text);
+ recordDraftPreservingSubmission(targetSessionId, text);
}
return accepted;
};
@@ -2330,6 +2375,7 @@ export function useChatSessionController({
return (async () => {
const builderSession = await ensureCurrentSessionIsAgentBuilder();
if (!builderSession) return false;
+ const builderSessionId = builderSession.id;
const onBuilderWorkspaceNameRequest = onWorkspaceNameRequest
? (request: WorkspaceNameRequest) =>
onWorkspaceNameRequest({
@@ -2338,17 +2384,19 @@ export function useChatSessionController({
request.cancel();
const liveSession = useChatSessionStore
.getState()
- .getSession(sessionId);
+ .getSession(builderSessionId);
if (
liveSession?.intent === "build-agent" &&
liveSession.targetAgentPath ===
builderSession.targetAgentPath
) {
- useChatSessionStore.getState().patchSession(sessionId, {
- intent: undefined,
- targetAgentPath: undefined,
- targetAgentSlug: undefined,
- });
+ useChatSessionStore
+ .getState()
+ .patchSession(builderSessionId, {
+ intent: undefined,
+ targetAgentPath: undefined,
+ targetAgentSlug: undefined,
+ });
if (builderSession.targetAgentPath) {
void deletePersonaSource(
builderSession.targetAgentPath,
@@ -2368,14 +2416,14 @@ export function useChatSessionController({
sendOptions,
);
if (
- (useChatStore.getState().queuedMessageBySession[stateSessionId]
+ (useChatStore.getState().queuedMessageBySession[builderSessionId]
?.length ?? 0) > 0
) {
- enqueueMessage(deferredSendOptions);
+ enqueueMessage(deferredSendOptions, builderSessionId);
return true;
}
const firstSend = acceptFirstSend(
- sessionId,
+ builderSessionId,
captureSessionSelection({
text,
persona: personaIntentFromComposer(personaId, personaName),
@@ -2390,8 +2438,8 @@ export function useChatSessionController({
},
);
if (firstSend.accepted) {
- recordDraftPreservingSubmission(sessionId, text);
- onMessageAccepted?.(sessionId);
+ recordDraftPreservingSubmission(builderSessionId, text);
+ onMessageAccepted?.(builderSessionId);
if (personaId && personaId !== selectedPersonaId) {
handlePersonaChange(personaId);
}
@@ -2399,17 +2447,20 @@ export function useChatSessionController({
}
if (firstSend.needsName || firstSend.occupied) return false;
if (personaId && personaId !== selectedPersonaId) {
- const accepted = enqueueMessage(deferredSendOptions);
+ const accepted = enqueueMessage(
+ deferredSendOptions,
+ builderSessionId,
+ );
if (accepted) {
handlePersonaChange(personaId);
}
return accepted;
}
if (!workspaceContextReady) {
- enqueueMessage(deferredSendOptions);
+ enqueueMessage(deferredSendOptions, builderSessionId);
return true;
}
- return enqueueMessage(deferredSendOptions);
+ return enqueueMessage(deferredSendOptions, builderSessionId);
})();
}
diff --git a/src/features/chat/ui/ChatView.tsx b/src/features/chat/ui/ChatView.tsx
index 1eca851a2..1c8af7cbd 100644
--- a/src/features/chat/ui/ChatView.tsx
+++ b/src/features/chat/ui/ChatView.tsx
@@ -671,8 +671,6 @@ export function ChatView({
let sendDisabledReason: string | undefined;
if (readOnlyStatus) {
sendDisabledReason = readOnlyStatus;
- } else if (effectiveSession?.creationState === "pending") {
- sendDisabledReason = t("toolbar.sessionStarting");
} else if (effectiveSession?.creationState === "failed") {
sendDisabledReason =
effectiveSession.creationError ?? t("toolbar.sessionStartFailed");
@@ -792,7 +790,7 @@ export function ChatView({
controller.isCompactingContext,
sendDisabled:
isReadOnly ||
- effectiveSession?.creationState != null ||
+ effectiveSession?.creationState === "failed" ||
isAgentBuilderTargetPending ||
controller.workspaceSetupInProgress,
sendDisabledReason,
diff --git a/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx b/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx
index f6e3c5802..832966c2a 100644
--- a/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx
+++ b/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx
@@ -587,6 +587,66 @@ describe("ChatView MCP app messaging", () => {
expect(timelineProps.showPlaceholder).toBe(false);
});
+ it("keeps the composer enabled while a blank draft session is pending", () => {
+ mocks.useChatSessionController.mockReturnValue({
+ ...mocks.useChatSessionController(),
+ messages: [],
+ });
+
+ render(
+ ,
+ );
+
+ const chatInputProps = mocks.chatInputSpy.mock.calls.at(-1)?.[0] as {
+ composerActions?: {
+ onSend?: (text: string) => boolean;
+ sendDisabled?: boolean;
+ sendDisabledReason?: string;
+ };
+ };
+ expect(chatInputProps.composerActions?.sendDisabled).toBe(false);
+ expect(chatInputProps.composerActions?.sendDisabledReason).toBeUndefined();
+
+ act(() => {
+ expect(
+ chatInputProps.composerActions?.onSend?.("queue while starting"),
+ ).toBe(true);
+ });
+ expect(mocks.handleSend).toHaveBeenCalledWith("queue while starting");
+ });
+
+ it("keeps the composer blocked after draft session creation fails", () => {
+ render(
+ ,
+ );
+
+ const chatInputProps = mocks.chatInputSpy.mock.calls.at(-1)?.[0] as {
+ composerActions?: {
+ sendDisabled?: boolean;
+ sendDisabledReason?: string;
+ };
+ };
+ expect(chatInputProps.composerActions?.sendDisabled).toBe(true);
+ expect(chatInputProps.composerActions?.sendDisabledReason).toBe(
+ "Session failed to start",
+ );
+ });
+
it("keeps the empty-state placeholder visible while a blank draft session is pending", () => {
mocks.useChatSessionController.mockReturnValue({
...mocks.useChatSessionController(),