mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add propose_plan tool for markdown plan proposals (#23452)
Adds a `propose_plan` tool that presents a workspace markdown file as a dedicated plan card in the agent UI. The workflow is: the agent uses `write_file`/`edit_files` to build a plan file (e.g. `/home/coder/PLAN.md`), then calls `propose_plan(path)` to present it. The backend reads the file via `ReadFile` and the frontend renders it as an expanded markdown preview card. **Backend** (`coderd/x/chatd/chattool/proposeplan.go`): new tool registered as root-chat-only. Validates `.md` suffix, requires an absolute path, reads raw file content from the workspace agent. Includes 1 MiB size cap. **Frontend** (`site/src/components/ai-elements/tool/`): dedicated `ProposePlanTool` component with `ToolCollapsible` + `ScrollArea` + `Response` markdown renderer, expanded by default. Custom icon (`ClipboardListIcon`) and filename-based label. **System prompt** (`coderd/x/chatd/prompt.go`): added `<planning>` section guiding the agent to research → write plan file → iterate → call `propose_plan`.
This commit is contained in:
@@ -3036,6 +3036,14 @@ class ExperimentalApiMethods {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getChatFileText = async (fileId: string): Promise<string> => {
|
||||
const response = await this.axios.get(
|
||||
`/api/experimental/chats/files/${fileId}`,
|
||||
{ responseType: "text" },
|
||||
);
|
||||
return response.data as string;
|
||||
};
|
||||
|
||||
// Chat API methods
|
||||
getChats = async (req?: {
|
||||
after_id?: string;
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { API } from "api/api";
|
||||
import { expect, spyOn, within } from "storybook/test";
|
||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
import { Tool } from "./Tool";
|
||||
|
||||
const samplePlan = [
|
||||
"# Implementation Plan",
|
||||
"",
|
||||
"## Goal",
|
||||
"Refactor the authentication module to support OAuth2 providers.",
|
||||
"",
|
||||
"## Steps",
|
||||
"",
|
||||
"### 1. Database migrations",
|
||||
"- [ ] Add `oauth2_providers` table",
|
||||
"- [x] Update `users` table with provider column",
|
||||
"",
|
||||
"### 2. Backend",
|
||||
"```go",
|
||||
"type OAuth2Provider struct {",
|
||||
" ID uuid.UUID",
|
||||
" Name string",
|
||||
"}",
|
||||
"```",
|
||||
"",
|
||||
"### 3. API endpoints",
|
||||
"- `GET /api/v2/oauth2/providers`",
|
||||
"- `POST /api/v2/oauth2/callback`",
|
||||
"",
|
||||
"## Acceptance criteria",
|
||||
"1. Users can authenticate via OAuth2",
|
||||
"2. Existing password auth continues to work",
|
||||
"",
|
||||
"> **Note**: Based on [RFC 6749](https://tools.ietf.org/html/rfc6749).",
|
||||
].join("\n");
|
||||
|
||||
const meta: Meta<typeof Tool> = {
|
||||
title: "components/ai-elements/tool/ProposePlan",
|
||||
component: Tool,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="max-w-3xl rounded-lg border border-solid border-border-default bg-surface-primary p-4">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
args: { name: "propose_plan" },
|
||||
parameters: {
|
||||
reactRouter: reactRouterParameters({ routing: { path: "/" } }),
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Tool>;
|
||||
|
||||
export const Running: Story = {
|
||||
args: { status: "running", args: { path: "/home/coder/PLAN.md" } },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText(/Proposing PLAN\.md/)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const Completed: Story = {
|
||||
args: {
|
||||
status: "completed",
|
||||
args: { path: "/home/coder/PLAN.md" },
|
||||
result: {
|
||||
ok: true,
|
||||
path: "/home/coder/PLAN.md",
|
||||
kind: "plan",
|
||||
file_id: "test-file-id-completed",
|
||||
media_type: "text/markdown",
|
||||
},
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getChatFileText").mockResolvedValue(samplePlan);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(await canvas.findByText("Implementation Plan")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomPath: Story = {
|
||||
args: {
|
||||
status: "completed",
|
||||
args: { path: "/home/coder/docs/AUTH_PLAN.md" },
|
||||
result: {
|
||||
ok: true,
|
||||
path: "/home/coder/docs/AUTH_PLAN.md",
|
||||
kind: "plan",
|
||||
file_id: "test-file-id-custom-path",
|
||||
media_type: "text/markdown",
|
||||
},
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getChatFileText").mockResolvedValue(samplePlan);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(await canvas.findByText("Implementation Plan")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const ErrorState: Story = {
|
||||
args: {
|
||||
status: "completed",
|
||||
isError: true,
|
||||
args: { path: "/home/coder/PLAN.md" },
|
||||
result: "Failed to read file: file not found",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText(/Proposed PLAN\.md/)).toBeInTheDocument();
|
||||
expect(canvas.getByLabelText("Error")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyContent: Story = {
|
||||
args: {
|
||||
status: "completed",
|
||||
args: { path: "/home/coder/PLAN.md" },
|
||||
result: {
|
||||
ok: true,
|
||||
path: "/home/coder/PLAN.md",
|
||||
kind: "plan",
|
||||
file_id: "test-file-id-empty-content",
|
||||
media_type: "text/markdown",
|
||||
},
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getChatFileText").mockResolvedValue("");
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(await canvas.findByText("No plan content.")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const FileIDLoading: Story = {
|
||||
args: {
|
||||
status: "completed",
|
||||
args: { path: "/home/coder/PLAN.md" },
|
||||
result: {
|
||||
ok: true,
|
||||
path: "/home/coder/PLAN.md",
|
||||
kind: "plan",
|
||||
file_id: "test-file-id-loading",
|
||||
media_type: "text/markdown",
|
||||
},
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getChatFileText").mockImplementation(
|
||||
() => new Promise(() => {}),
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(await canvas.findByText(/Loading plan/)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const FileIDCompleted: Story = {
|
||||
args: {
|
||||
status: "completed",
|
||||
args: { path: "/home/coder/PLAN.md" },
|
||||
result: {
|
||||
ok: true,
|
||||
path: "/home/coder/PLAN.md",
|
||||
kind: "plan",
|
||||
file_id: "test-file-id-success",
|
||||
media_type: "text/markdown",
|
||||
},
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getChatFileText").mockResolvedValue(samplePlan);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(await canvas.findByText("Implementation Plan")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const FileIDFetchError: Story = {
|
||||
args: {
|
||||
status: "completed",
|
||||
args: { path: "/home/coder/PLAN.md" },
|
||||
result: {
|
||||
ok: true,
|
||||
path: "/home/coder/PLAN.md",
|
||||
kind: "plan",
|
||||
file_id: "test-file-id-error",
|
||||
media_type: "text/markdown",
|
||||
},
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getChatFileText").mockRejectedValue(
|
||||
new Error("Failed to load plan"),
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(await canvas.findByLabelText("Error")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import { API } from "api/api";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { CircleAlertIcon, LoaderIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { cn } from "utils/cn";
|
||||
import { Response } from "../response";
|
||||
import type { ToolStatus } from "./utils";
|
||||
|
||||
export const ProposePlanTool: React.FC<{
|
||||
content?: string;
|
||||
fileID?: string;
|
||||
path: string;
|
||||
status: ToolStatus;
|
||||
isError: boolean;
|
||||
errorMessage?: string;
|
||||
}> = ({
|
||||
content: inlineContent,
|
||||
fileID,
|
||||
path,
|
||||
status,
|
||||
isError,
|
||||
errorMessage,
|
||||
}) => {
|
||||
const hasInlineContent = (inlineContent?.trim().length ?? 0) > 0;
|
||||
const fileQuery = useQuery({
|
||||
queryKey: ["chatFile", fileID],
|
||||
queryFn: async () => {
|
||||
if (!fileID) {
|
||||
throw new Error("Missing file ID");
|
||||
}
|
||||
|
||||
return API.experimental.getChatFileText(fileID);
|
||||
},
|
||||
enabled: Boolean(fileID) && !hasInlineContent,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
|
||||
const fetchError = fileQuery.isError
|
||||
? fileQuery.error instanceof Error
|
||||
? fileQuery.error.message
|
||||
: "Failed to load plan"
|
||||
: undefined;
|
||||
const fetchLoading = fileQuery.isLoading;
|
||||
const displayContent = hasInlineContent
|
||||
? (inlineContent ?? "")
|
||||
: (fileQuery.data ?? "");
|
||||
const isRunning = status === "running";
|
||||
const filename = (path || "PLAN.md").split("/").pop() || "PLAN.md";
|
||||
const effectiveError = isError || Boolean(fetchError);
|
||||
const effectiveErrorMessage = errorMessage || fetchError;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-1.5 py-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
effectiveError
|
||||
? "text-content-destructive"
|
||||
: "text-content-secondary",
|
||||
)}
|
||||
>
|
||||
{isRunning ? `Proposing ${filename}…` : `Proposed ${filename}`}
|
||||
</span>
|
||||
{effectiveError && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CircleAlertIcon
|
||||
aria-label="Error"
|
||||
className="h-3.5 w-3.5 shrink-0 text-content-destructive"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{effectiveErrorMessage || "Failed to propose plan"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isRunning && (
|
||||
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
|
||||
)}
|
||||
</div>
|
||||
{displayContent ? (
|
||||
<Response>{displayContent}</Response>
|
||||
) : (
|
||||
!fetchLoading &&
|
||||
!effectiveError && (
|
||||
<p className="text-sm text-content-secondary italic">
|
||||
No plan content.
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
{fetchLoading && (
|
||||
<div className="flex items-center gap-1.5 py-2 text-sm text-content-secondary">
|
||||
<LoaderIcon className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
Loading plan…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "./ExecuteTool";
|
||||
import { ListTemplatesTool } from "./ListTemplatesTool";
|
||||
import { ProcessOutputTool } from "./ProcessOutputTool";
|
||||
import { ProposePlanTool } from "./ProposePlanTool";
|
||||
import { ReadFileTool } from "./ReadFileTool";
|
||||
import { ReadTemplateTool } from "./ReadTemplateTool";
|
||||
import { SubagentTool } from "./SubagentTool";
|
||||
@@ -367,6 +368,34 @@ const ChatSummarizedRenderer: FC<ToolRendererProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const ProposePlanRenderer: FC<ToolRendererProps> = ({
|
||||
args,
|
||||
status,
|
||||
result,
|
||||
isError,
|
||||
}) => {
|
||||
const parsedArgs = parseArgs(args);
|
||||
const path = parsedArgs ? asString(parsedArgs.path) || "PLAN.md" : "PLAN.md";
|
||||
const rec = asRecord(result);
|
||||
const content = rec && "content" in rec ? asString(rec.content) : undefined;
|
||||
const fileID = rec && "file_id" in rec ? asString(rec.file_id) : undefined;
|
||||
const errorMessage = isError
|
||||
? (rec ? asString(rec.error || rec.message) : undefined) ||
|
||||
(typeof result === "string" ? result : undefined)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ProposePlanTool
|
||||
content={content}
|
||||
fileID={fileID}
|
||||
path={path}
|
||||
status={status}
|
||||
isError={isError}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ComputerRenderer: FC<ToolRendererProps> = ({
|
||||
status,
|
||||
result,
|
||||
@@ -510,6 +539,7 @@ const toolRenderers: Record<string, FC<ToolRendererProps>> = {
|
||||
message_agent: SubagentRenderer,
|
||||
close_agent: SubagentRenderer,
|
||||
chat_summarized: ChatSummarizedRenderer,
|
||||
propose_plan: ProposePlanRenderer,
|
||||
computer: ComputerRenderer,
|
||||
};
|
||||
|
||||
@@ -536,7 +566,9 @@ export const Tool = memo(
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
name === "execute" || name === "process_output"
|
||||
name === "execute" ||
|
||||
name === "process_output" ||
|
||||
name === "propose_plan"
|
||||
? "w-full py-0.5"
|
||||
: "py-0.5",
|
||||
className,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BotIcon,
|
||||
ClipboardListIcon,
|
||||
FileIcon,
|
||||
FilePenIcon,
|
||||
MonitorIcon,
|
||||
@@ -31,6 +32,8 @@ export const ToolIcon: React.FC<{ name: string; isError: boolean }> = ({
|
||||
return <PlusCircleIcon className={base} />;
|
||||
case "chat_summarized":
|
||||
return <BotIcon className={base} />;
|
||||
case "propose_plan":
|
||||
return <ClipboardListIcon className={base} />;
|
||||
case "computer":
|
||||
return <MonitorIcon className={base} />;
|
||||
default:
|
||||
|
||||
@@ -160,6 +160,15 @@ export const ToolLabel: React.FC<{
|
||||
Screenshot
|
||||
</span>
|
||||
);
|
||||
case "propose_plan": {
|
||||
const path = parsed ? asString(parsed.path) || "PLAN.md" : "PLAN.md";
|
||||
const filename = path.split("/").pop() || "PLAN.md";
|
||||
return (
|
||||
<span className="truncate text-sm text-content-secondary">
|
||||
{filename}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<span className="truncate text-sm text-content-secondary">{name}</span>
|
||||
|
||||
Reference in New Issue
Block a user