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:
Michael Suchacz
2026-03-24 15:06:22 +01:00
committed by GitHub
parent 02356c61f6
commit 19e86628da
12 changed files with 957 additions and 3 deletions
+8
View File
@@ -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>
);
};
+33 -1
View File
@@ -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>