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
58 changes: 29 additions & 29 deletions LAWS/CHAT.md
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
loganj marked this conversation as resolved.
- 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.
Comment thread
loganj marked this conversation as resolved.

## 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.
188 changes: 3 additions & 185 deletions src/app/AppShell.navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -151,30 +145,21 @@ function flushAfterNextPaintCallbacks() {
}
}

function appShellWithTheme(
children?: ReactNode,
options?: { backgroundQueueDrain?: boolean },
) {
function appShellWithTheme(children?: ReactNode) {
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,
options?: { backgroundQueueDrain?: boolean },
) {
return render(appShellWithTheme(children, options));
function renderAppShell(children?: ReactNode) {
return render(appShellWithTheme(children));
}

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

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