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
1 change: 0 additions & 1 deletion LAWS/CHAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
- The composer MUST queue every accepted message into the selected chat's queue, including before that chat's session is ready.
- The composer MUST queue accepted messages into the selected chat's queue in their acceptance order.
- The selected chat's queue MUST retain the message and persona intent most recently accepted from the composer or a user edit.
- A chat's queue MUST dispatch each message to that chat's session with the model and provider shown when the composer queued it.
- A chat's queue MUST NOT dispatch a message to that chat's session before every message ahead of it in the queue.
- A message MUST NOT be dispatched from the queue until its session is ready.
- A session MUST be ready for dispatch from its queue only when it can begin processing that queue's first message.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1188,6 +1188,48 @@ describe("useChatSessionController", () => {
});
});

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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(<DrainHarness />);
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");
Expand Down
18 changes: 17 additions & 1 deletion src/features/chat/hooks/useBackgroundQueuedMessageDrain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
80 changes: 70 additions & 10 deletions src/features/chat/hooks/useChatSessionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2227,18 +2227,53 @@ export function useChatSessionController({
isQueuePreparationReady,
);
const pendingBuilderActivationRef = useRef<
Record<string, Promise<ChatSession | null>>
Record<
string,
{
promise: Promise<ChatSession | null>;
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 () => {
Expand All @@ -2259,6 +2294,16 @@ export function useChatSessionController({
}

const target = await preSeedDraftAgent(sessionId);
if (
options?.queueRecordId &&
!isQueuedAgentBuilderRecordAuthoritative(options.queueRecordId)
) {
await deletePersonaSource(target.path).catch((error) => {
console.error("Failed to delete superseded agent draft:", error);
});
return null;
}

const liveChatSessions = useChatSessionStore.getState();
const liveSession = liveChatSessions.getSession(sessionId);
const liveSkills =
Expand Down Expand Up @@ -2308,29 +2353,44 @@ export function useChatSessionController({
return { ...currentSession, ...patch };
})();

pendingBuilderActivationRef.current[sessionId] = activation;
const pendingEntry = {
promise: activation,
queueRecordId: options?.queueRecordId,
};
pendingBuilderActivationRef.current[sessionId] = pendingEntry;
try {
return await activation;
} finally {
if (pendingBuilderActivationRef.current[sessionId] === activation) {
if (pendingBuilderActivationRef.current[sessionId] === pendingEntry) {
delete pendingBuilderActivationRef.current[sessionId];
}
}
},
[sessionId, stateSessionId],
[isQueuedAgentBuilderRecordAuthoritative, sessionId, stateSessionId],
);

useEffect(() => {
if (!queuedAgentBuilderSendNeedsPreparation || !sessionId) {
return;
}
void ensureCurrentSessionIsAgentBuilder().catch((error) => {
console.error("Failed to prepare queued agent builder:", error);
markAgentBuilderSessionPreparationFailed(sessionId);
});
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,
]);

Expand Down
36 changes: 36 additions & 0 deletions src/features/chat/lib/__tests__/queuedSessionSend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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();
Expand Down
32 changes: 32 additions & 0 deletions src/features/chat/lib/agentBuilderQueueReadiness.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading