mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
+36
-2
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = () => ({
|
||||
|
||||
Generated
+36
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user