feat: add plan mode with restricted tool boundary (#24236)

> This PR was authored by Mux on behalf of Mike.

## Summary
- add persistent plan mode for chats and the chat-specific plan file
flow
- add structured planning tools such as `ask_user_question` and
`propose_plan`
- keep `write_file` and `edit_files` constrained to the chat-specific
plan file during plan turns
- allow shell exploration in plan mode, including subagents, via
`execute` and `process_output`
- block implementation-oriented, provider-native, MCP, dynamic, and
computer-use tools during plan turns
- update the chat UI, tests, and docs for the new planning flow
This commit is contained in:
Michael Suchacz
2026-04-16 11:12:01 +02:00
committed by GitHub
parent e996f6d44b
commit 1cf0354f72
76 changed files with 6398 additions and 889 deletions
+36 -2
View File
@@ -3050,6 +3050,22 @@ export type CreateTaskFeedbackRequest = {
comment?: string;
};
export type ChatPlanModeOrClear = TypesGen.ChatPlanMode | "";
export type CreateChatMessageRequestWithClearablePlanMode = Omit<
TypesGen.CreateChatMessageRequest,
"plan_mode"
> & {
readonly plan_mode?: ChatPlanModeOrClear;
};
type UpdateChatRequestWithClearablePlanMode = Omit<
TypesGen.UpdateChatRequest,
"plan_mode"
> & {
readonly plan_mode?: ChatPlanModeOrClear;
};
// Experimental API methods call endpoints under the /api/experimental/ prefix.
// These endpoints are not stable and may change or be removed at any time.
//
@@ -3143,7 +3159,7 @@ class ExperimentalApiMethods {
updateChat = async (
chatId: string,
req: TypesGen.UpdateChatRequest,
req: UpdateChatRequestWithClearablePlanMode,
): Promise<void> => {
await this.axios.patch(`/api/experimental/chats/${chatId}`, req);
};
@@ -3157,7 +3173,7 @@ class ExperimentalApiMethods {
createChatMessage = async (
chatId: string,
req: TypesGen.CreateChatMessageRequest,
req: CreateChatMessageRequestWithClearablePlanMode,
): Promise<TypesGen.CreateChatMessageResponse> => {
const response = await this.axios.post<TypesGen.CreateChatMessageResponse>(
`/api/experimental/chats/${chatId}/messages`,
@@ -3242,6 +3258,24 @@ class ExperimentalApiMethods {
await this.axios.put("/api/experimental/chats/config/system-prompt", req);
};
getChatPlanModeInstructions =
async (): Promise<TypesGen.ChatPlanModeInstructionsResponse> => {
const response =
await this.axios.get<TypesGen.ChatPlanModeInstructionsResponse>(
"/api/experimental/chats/config/plan-mode-instructions",
);
return response.data;
};
updateChatPlanModeInstructions = async (
req: TypesGen.UpdateChatPlanModeInstructionsRequest,
): Promise<void> => {
await this.axios.put(
"/api/experimental/chats/config/plan-mode-instructions",
req,
);
};
getChatDesktopEnabled =
async (): Promise<TypesGen.ChatDesktopEnabledResponse> => {
const response =
+29
View File
@@ -26,6 +26,7 @@ import {
reorderPinnedChat,
unarchiveChat,
unpinChat,
updateChatPlanMode,
updateInfiniteChatsCache,
} from "./chats";
@@ -199,6 +200,34 @@ describe("invalidateChatListQueries", () => {
});
});
describe("updateChatPlanMode optimistic update", () => {
it("invalidates the chat list on error without a detail cache", async () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
seedInfiniteChats(queryClient, [makeChat(chatId)]);
const mutation = updateChatPlanMode(queryClient);
const context = await mutation.onMutate({
chatId,
planMode: "plan",
});
expect(context?.previousChat).toBeUndefined();
expect(readInfiniteChats(queryClient)?.[0].plan_mode).toBe("plan");
mutation.onError(
new Error("server error"),
{ chatId, planMode: "plan" },
context,
);
expect(
queryClient.getQueryState(infiniteChatsTestKey)?.isInvalidated,
"chat list should be invalidated when rollback lacks detail cache",
).toBe(true);
});
});
describe("archiveChat optimistic update", () => {
it("optimistically sets archived to true in the chats list", async () => {
const queryClient = createTestQueryClient();
+168 -2
View File
@@ -3,7 +3,11 @@ import type {
QueryClient,
UseInfiniteQueryOptions,
} from "react-query";
import { API } from "#/api/api";
import {
API,
type ChatPlanModeOrClear,
type CreateChatMessageRequestWithClearablePlanMode,
} from "#/api/api";
import type * as TypesGen from "#/api/typesGenerated";
import type { UsePaginatedQueryOptions } from "#/hooks/usePaginatedQuery";
import {
@@ -207,6 +211,19 @@ export const cancelChatListRefetches = (queryClient: QueryClient) => {
};
const DEFAULT_CHAT_PAGE_LIMIT = 50;
const nilUUID = "00000000-0000-0000-0000-000000000000";
type UpdateChatWorkspaceVariables = {
chatId: string;
workspaceId: string | null;
};
type UpdateChatPlanModeVariables = {
chatId: string;
planMode?: ChatPlanModeOrClear;
};
const clearPlanMode = "" satisfies ChatPlanModeOrClear;
export const infiniteChats = (opts?: { q?: string; archived?: boolean }) => {
const limit = DEFAULT_CHAT_PAGE_LIMIT;
@@ -387,6 +404,138 @@ export const unarchiveChat = (queryClient: QueryClient) => ({
},
});
export const updateChatPlanMode = (queryClient: QueryClient) => ({
mutationFn: ({ chatId, planMode }: UpdateChatPlanModeVariables) =>
API.experimental.updateChat(chatId, {
plan_mode: planMode ?? clearPlanMode,
}),
onMutate: async ({ chatId, planMode }: UpdateChatPlanModeVariables) => {
await queryClient.cancelQueries({
queryKey: chatsKey,
predicate: isChatListQuery,
});
await queryClient.cancelQueries({
queryKey: chatKey(chatId),
exact: true,
});
const previousChat = queryClient.getQueryData<TypesGen.Chat>(
chatKey(chatId),
);
const nextPlanMode = planMode === clearPlanMode ? undefined : planMode;
updateInfiniteChatsCache(queryClient, (chats) =>
chats.map((chat) =>
chat.id === chatId ? { ...chat, plan_mode: nextPlanMode } : chat,
),
);
if (previousChat) {
queryClient.setQueryData<TypesGen.Chat>(chatKey(chatId), {
...previousChat,
plan_mode: nextPlanMode,
});
}
return { previousChat };
},
onError: (
_error: unknown,
{ chatId }: UpdateChatPlanModeVariables,
context:
| {
previousChat?: TypesGen.Chat;
}
| undefined,
) => {
void invalidateChatListQueries(queryClient);
const previousChat = context?.previousChat;
if (!previousChat) {
return;
}
updateInfiniteChatsCache(queryClient, (chats) =>
chats.map((chat) =>
chat.id === chatId
? {
...chat,
plan_mode: previousChat.plan_mode,
}
: chat,
),
);
queryClient.setQueryData<TypesGen.Chat>(chatKey(chatId), previousChat);
},
});
export const updateChatWorkspace = (queryClient: QueryClient) => ({
mutationFn: ({ chatId, workspaceId }: UpdateChatWorkspaceVariables) =>
API.experimental.updateChat(chatId, {
workspace_id: workspaceId ?? nilUUID,
}),
onMutate: async ({ chatId, workspaceId }: UpdateChatWorkspaceVariables) => {
await queryClient.cancelQueries({
queryKey: chatsKey,
predicate: isChatListQuery,
});
await queryClient.cancelQueries({
queryKey: chatKey(chatId),
exact: true,
});
const previousChat = queryClient.getQueryData<TypesGen.Chat>(
chatKey(chatId),
);
updateInfiniteChatsCache(queryClient, (chats) =>
chats.map((chat) =>
chat.id === chatId
? { ...chat, workspace_id: workspaceId ?? undefined }
: chat,
),
);
if (previousChat) {
queryClient.setQueryData<TypesGen.Chat>(chatKey(chatId), {
...previousChat,
workspace_id: workspaceId ?? undefined,
});
}
return { previousChat };
},
onError: (
_error: unknown,
{ chatId }: UpdateChatWorkspaceVariables,
context:
| {
previousChat?: TypesGen.Chat;
}
| undefined,
) => {
void invalidateChatListQueries(queryClient);
const previousChat = context?.previousChat;
if (previousChat) {
updateInfiniteChatsCache(queryClient, (chats) =>
chats.map((chat) =>
chat.id === chatId
? {
...chat,
workspace_id: previousChat.workspace_id,
}
: chat,
),
);
queryClient.setQueryData<TypesGen.Chat>(chatKey(chatId), previousChat);
}
},
onSettled: async (
_data: unknown,
_error: unknown,
{ chatId }: UpdateChatWorkspaceVariables,
) => {
await invalidateChatListQueries(queryClient);
await queryClient.invalidateQueries({
queryKey: chatKey(chatId),
exact: true,
});
await queryClient.invalidateQueries({
queryKey: chatsByWorkspaceKeyPrefix,
});
},
});
export const pinChat = (queryClient: QueryClient) => ({
mutationFn: (chatId: string) =>
API.experimental.updateChat(chatId, { pin_order: 1 }),
@@ -596,7 +745,7 @@ export const createChatMessage = (
_queryClient: QueryClient,
chatId: string,
) => ({
mutationFn: (req: TypesGen.CreateChatMessageRequest) =>
mutationFn: (req: CreateChatMessageRequestWithClearablePlanMode) =>
API.experimental.createChatMessage(chatId, req),
// No onSuccess invalidation needed: the per-chat WebSocket delivers
// the response message via upsertDurableMessage, and the global
@@ -748,6 +897,23 @@ export const updateChatSystemPrompt = (queryClient: QueryClient) => ({
},
});
const chatPlanModeInstructionsKey = ["chat-plan-mode-instructions"] as const;
export const chatPlanModeInstructions = () => ({
queryKey: chatPlanModeInstructionsKey,
queryFn: () => API.experimental.getChatPlanModeInstructions(),
});
export const updateChatPlanModeInstructions = (queryClient: QueryClient) => ({
mutationFn: (req: TypesGen.UpdateChatPlanModeInstructionsRequest) =>
API.experimental.updateChatPlanModeInstructions(req),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: chatPlanModeInstructionsKey,
});
},
});
const chatDesktopEnabledKey = ["chat-desktop-enabled"] as const;
export const chatDesktopEnabled = () => ({
+36
View File
@@ -1236,6 +1236,7 @@ export interface Chat {
readonly last_model_config_id: string;
readonly title: string;
readonly status: ChatStatus;
readonly plan_mode?: ChatPlanMode;
readonly last_error: string | null;
readonly diff_status?: ChatDiffStatus;
readonly created_at: string;
@@ -2023,6 +2024,20 @@ export interface ChatModelsResponse {
readonly providers: readonly ChatModelProvider[];
}
// From codersdk/chats.go
export type ChatPlanMode = "plan";
// From codersdk/chats.go
/**
* ChatPlanModeInstructionsResponse is the response body for the
* plan mode instructions configuration endpoint.
*/
export interface ChatPlanModeInstructionsResponse {
readonly plan_mode_instructions: string;
}
export const ChatPlanModes: ChatPlanMode[] = ["plan"];
// From codersdk/chats.go
/**
* ChatProviderConfig is an admin-managed provider configuration.
@@ -2603,6 +2618,11 @@ export interface CreateChatMessageRequest {
readonly model_config_id?: string;
readonly mcp_server_ids?: string[];
readonly busy_behavior?: ChatBusyBehavior;
/**
* PlanMode switches the chat's persistent plan mode.
* nil: no change, ptr to "plan": enable, ptr to "": clear.
*/
readonly plan_mode?: ChatPlanMode;
}
// From codersdk/chats.go
@@ -2664,6 +2684,7 @@ export interface CreateChatRequest {
* subject to change.
*/
readonly unsafe_dynamic_tools?: readonly DynamicTool[];
readonly plan_mode?: ChatPlanMode;
}
// From codersdk/users.go
@@ -7568,6 +7589,15 @@ export interface UpdateChatModelConfigRequest {
readonly model_config?: ChatModelCallConfig;
}
// From codersdk/chats.go
/**
* UpdateChatPlanModeInstructionsRequest is the request body for
* updating the plan mode instructions configuration.
*/
export interface UpdateChatPlanModeInstructionsRequest {
readonly plan_mode_instructions: string;
}
// From codersdk/chats.go
/**
* UpdateChatProviderConfigRequest updates a chat provider config.
@@ -7589,6 +7619,7 @@ export interface UpdateChatProviderConfigRequest {
export interface UpdateChatRequest {
readonly title?: string;
readonly archived?: boolean;
readonly workspace_id?: string;
/**
* PinOrder controls the chat's pinned state and position.
* - nil: no change to pin state.
@@ -7602,6 +7633,11 @@ export interface UpdateChatRequest {
*/
readonly pin_order?: number;
readonly labels?: Record<string, string>;
/**
* PlanMode switches the chat's persistent plan mode.
* nil: no change, ptr to "plan": enable, ptr to "": clear.
*/
readonly plan_mode?: ChatPlanMode;
}
// From codersdk/chats.go