Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 185 additions & 3 deletions src/app/AppShell.navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -144,21 +150,30 @@ function flushAfterNextPaintCallbacks() {
}
}

function appShellWithTheme(children?: ReactNode) {
function appShellWithTheme(
children?: ReactNode,
options?: { backgroundQueueDrain?: boolean },
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<AppShell>{children}</AppShell>
{options?.backgroundQueueDrain ? (
<BackgroundQueuedMessageDrain />
) : null}
</ThemeProvider>
</QueryClientProvider>
);
}

function renderAppShell(children?: ReactNode) {
return render(appShellWithTheme(children));
function renderAppShell(
children?: ReactNode,
options?: { backgroundQueueDrain?: boolean },
) {
return render(appShellWithTheme(children, options));
}

function managedWorktreeGitState(
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
}));

Expand Down Expand Up @@ -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: {},
Expand Down Expand Up @@ -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<Record<string, never>>();
Expand Down
8 changes: 8 additions & 0 deletions src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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, {
Expand Down
Loading