Skip to content
Draft
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
9 changes: 9 additions & 0 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3408,6 +3408,15 @@ class ExperimentalApiMethods {
await this.axios.patch(`/api/v2/chats/${chatId}`, req);
};

reconcileInvalidChatState = async (
chatId: string,
): Promise<TypesGen.Chat> => {
const response = await this.axios.post<TypesGen.Chat>(
`/api/v2/chats/${chatId}/reconcile-invalid`,
);
return response.data;
};

proposeChatTitle = async (chatId: string): Promise<{ title: string }> => {
const response = await this.axios.post<{ title: string }>(
`/api/v2/chats/${chatId}/title/propose`,
Expand Down
33 changes: 33 additions & 0 deletions site/src/api/queries/chats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import {
invalidateChatPrompts,
invalidateChatSearches,
invalidateChatsByWorkspace,
isChatInvalidStateError,
mcpServerConfigACL,
mcpServerConfigACLAvailable,
mcpServerConfigACLAvailableKey,
Expand Down Expand Up @@ -691,6 +692,38 @@ describe("updateChatTitle cache update", () => {
});
});

describe("isChatInvalidStateError", () => {
const makeAxiosError = (status: number, message: string) => ({
isAxiosError: true,
response: { status, data: { message } },
});

it("matches the invalid-state conflict returned by chat mutations", () => {
expect(
isChatInvalidStateError(
makeAxiosError(409, "Chat is in an invalid state."),
),
).toBe(true);
});

it("ignores other conflicts, other statuses, and non-API errors", () => {
expect(
isChatInvalidStateError(
makeAxiosError(409, "Chat is not in an invalid state."),
),
).toBe(false);
expect(
isChatInvalidStateError(
makeAxiosError(500, "Chat is in an invalid state."),
),
).toBe(false);
expect(
isChatInvalidStateError(new Error("Chat is in an invalid state.")),
).toBe(false);
expect(isChatInvalidStateError(undefined)).toBe(false);
});
});

describe("archiveChat optimistic update", () => {
it("optimistically sets archived to true in the chats list", async () => {
const queryClient = createTestQueryClient();
Expand Down
24 changes: 24 additions & 0 deletions site/src/api/queries/chats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type ChatPlanModeOrClear,
type CreateChatMessageRequestWithClearablePlanMode,
} from "#/api/api";
import { isApiError } from "#/api/errors";
import type * as TypesGen from "#/api/typesGenerated";
import { ChatListSources } from "#/api/typesGenerated";
import { authorizationKey } from "./authCheck";
Expand Down Expand Up @@ -1229,6 +1230,29 @@ export const chatPromptsQuery = (chatId: string) => ({
enabled: chatId !== "",
});

// Chat mutations reject an unrecoverable execution state with a 409 and this
// exact message. The response carries no machine-readable code, so the message
// is the only available discriminator.
const CHAT_INVALID_STATE_MESSAGE = "Chat is in an invalid state.";

/**
* Reports whether a failed chat mutation can be recovered by reconciling the
* chat's invalid execution state.
*/
export const isChatInvalidStateError = (error: unknown): boolean =>
isApiError(error) &&
error.response.status === 409 &&
error.response.data.message === CHAT_INVALID_STATE_MESSAGE;

export const reconcileInvalidChatState = (queryClient: QueryClient) => ({
mutationFn: (chatId: string) =>
API.experimental.reconcileInvalidChatState(chatId),
onSuccess: (chat: TypesGen.Chat) => {
patchChatEntity(queryClient, chat.id, () => chat);
void invalidateChatListQueries(queryClient);
},
});

export const archiveChat = (queryClient: QueryClient) => ({
mutationFn: (chatId: string) =>
API.experimental.updateChat(chatId, { archived: true }),
Expand Down
77 changes: 77 additions & 0 deletions site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,83 @@ export const DeleteConfirmationDialog: Story = {
},
};

const stuckChat = buildChat({
id: "chat-invalid-state",
title: "Stuck agent",
status: "waiting",
updated_at: todayTimestamp,
});

const chatInvalidStateError = {
isAxiosError: true,
response: {
status: 409,
data: { message: "Chat is in an invalid state." },
},
};

const mockStuckChatArchive = () => {
mockChats([stuckChat]);
spyOn(API.experimental, "updateChat")
.mockRejectedValueOnce(chatInvalidStateError)
.mockResolvedValue(undefined);
spyOn(API.experimental, "reconcileInvalidChatState").mockResolvedValue({
...stuckChat,
status: "error",
});
};

const openRepairDialogFromArchive = async () => {
const body = within(document.body);
await userEvent.click(
await body.findByLabelText("Open actions for Stuck agent"),
);
await userEvent.click(
await body.findByRole("menuitem", { name: "Archive agent" }),
);
return await body.findByRole("dialog");
};

export const RepairInvalidStateAndRetryArchive: Story = {
beforeEach: mockStuckChatArchive,
play: async () => {
const dialog = await openRepairDialogFromArchive();
await expect(
within(dialog).getByText("Repair agent state"),
).toBeInTheDocument();

await userEvent.click(
within(dialog).getByRole("button", { name: "Repair and retry" }),
);
await waitFor(() => {
expect(API.experimental.reconcileInvalidChatState).toHaveBeenCalledWith(
stuckChat.id,
);
});
await waitFor(() => {
expect(API.experimental.updateChat).toHaveBeenCalledTimes(2);
});
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
},
};

export const DismissInvalidStateRepair: Story = {
beforeEach: mockStuckChatArchive,
play: async () => {
const dialog = await openRepairDialogFromArchive();
await userEvent.click(
within(dialog).getByRole("button", { name: "Cancel" }),
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
expect(API.experimental.reconcileInvalidChatState).not.toHaveBeenCalled();
expect(API.experimental.updateChat).toHaveBeenCalledTimes(1);
},
};

export const WithAgentSelected: Story = {
beforeEach: () => {
mockChats([
Expand Down
83 changes: 83 additions & 0 deletions site/src/pages/AgentsPage/AgentsPageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@ import {
invalidateChatListQueries,
invalidateChatSearches,
invalidateChatsByWorkspace,
isChatInvalidStateError,
mergeWatchedChatIntoCaches,
pinChat,
prependToInfiniteChatsCache,
proposeChatTitle,
readInfiniteChatsCache,
reconcileInvalidChatState,
removeChatFromChatsByWorkspace,
reorderPinnedChat,
shouldInvalidateChatSearches,
Expand All @@ -52,6 +54,7 @@ import {
workspaceByIdKey,
} from "#/api/queries/workspaces";
import type * as TypesGen from "#/api/typesGenerated";
import { ConfirmDialog } from "#/components/Dialog/ConfirmDialog/ConfirmDialog";
import { DeleteDialog } from "#/components/Dialog/DeleteDialog/DeleteDialog";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import {
Expand Down Expand Up @@ -154,6 +157,18 @@ export const chatCostIdToInvalidate = (
return getChatCostTreeID(chat);
};

type InvalidStateRepairAction = "archive" | "unarchive";

const INVALID_STATE_REPAIR_DESCRIPTIONS: Record<
InvalidStateRepairAction,
string
> = {
archive:
"This agent is stuck in an invalid state, so it could not be archived. Repairing moves the agent into an error state and then retries archiving it.",
unarchive:
"This agent is stuck in an invalid state, so it could not be unarchived. Repairing moves the agent into an error state and then retries unarchiving it.",
};

const AgentsPageLayout: FC = () => {
useAgentsPWA();
const queryClient = useQueryClient();
Expand Down Expand Up @@ -278,6 +293,28 @@ const AgentsPageLayout: FC = () => {
});
};

const [pendingInvalidStateRepair, setPendingInvalidStateRepair] = useState<{
chatId: string;
action: InvalidStateRepairAction;
} | null>(null);
// A retry that fails with the same invalid state must not reopen the repair
// dialog, otherwise the user can loop through repair attempts forever.
const repairRetryChatIDRef = useRef<string | undefined>(undefined);
const offerInvalidStateRepair = (
error: unknown,
chatId: string,
action: InvalidStateRepairAction,
): boolean => {
if (
!isChatInvalidStateError(error) ||
repairRetryChatIDRef.current === chatId
) {
return false;
}
setPendingInvalidStateRepair({ chatId, action });
return true;
};

const archiveChatBase = archiveChat(queryClient);
const archiveAgentMutation = useMutation({
...archiveChatBase,
Expand All @@ -289,6 +326,9 @@ const AgentsPageLayout: FC = () => {
},
onError: (error, chatId, context) => {
archiveChatBase.onError(error, chatId, context);
if (offerInvalidStateRepair(error, chatId, "archive")) {
return;
}
toast.error(getErrorMessage(error, "Failed to archive agent."));
},
});
Expand Down Expand Up @@ -354,9 +394,38 @@ const AgentsPageLayout: FC = () => {
...unarchiveChatBase,
onError: (error, chatId, context) => {
unarchiveChatBase.onError(error, chatId, context);
if (offerInvalidStateRepair(error, chatId, "unarchive")) {
return;
}
toast.error(getErrorMessage(error, "Failed to unarchive agent."));
},
});
const repairInvalidStateMutation = useMutation(
reconcileInvalidChatState(queryClient),
);
const handleConfirmInvalidStateRepair = () => {
if (!pendingInvalidStateRepair) {
return;
}
const { chatId, action } = pendingInvalidStateRepair;
repairInvalidStateMutation.mutate(chatId, {
onSuccess: () => {
setPendingInvalidStateRepair(null);
repairRetryChatIDRef.current = chatId;
const retriedMutation =
action === "archive" ? archiveAgentMutation : unarchiveAgentMutation;
retriedMutation.mutate(chatId, {
onSettled: () => {
repairRetryChatIDRef.current = undefined;
},
});
},
onError: (error) => {
setPendingInvalidStateRepair(null);
toast.error(getErrorMessage(error, "Failed to repair agent state."));
},
});
};
const pinChatBase = pinChat(queryClient);
const pinAgentMutation = useMutation({
...pinChatBase,
Expand Down Expand Up @@ -822,6 +891,20 @@ const AgentsPageLayout: FC = () => {
verb="Archiving and deleting"
info="This will archive the agent and permanently delete the associated workspace and all its resources."
/>
<ConfirmDialog
open={pendingInvalidStateRepair !== null}
title="Repair agent state"
description={
INVALID_STATE_REPAIR_DESCRIPTIONS[
pendingInvalidStateRepair?.action ?? "archive"
]
}
confirmText="Repair and retry"
confirmLoading={repairInvalidStateMutation.isPending}
hideCancel={false}
onClose={() => setPendingInvalidStateRepair(null)}
onConfirm={handleConfirmInvalidStateRepair}
/>
</>
);
};
Expand Down
Loading