Skip to content

Commit 19e8662

Browse files
authored
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`.
1 parent 02356c6 commit 19e8662

File tree

12 files changed

+957
-3
lines changed

12 files changed

+957
-3
lines changed
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
---
2+
name: refine-plan
3+
description: Iteratively refine development plans using TDD methodology. Ensures plans are clear, actionable, and include red-green-refactor cycles with proper test coverage.
4+
---
5+
6+
# Refine Development Plan
7+
8+
## Overview
9+
10+
Good plans eliminate ambiguity through clear requirements, break work into clear phases, and always include refactoring to capture implementation insights.
11+
12+
## When to Use This Skill
13+
14+
| Symptom | Example |
15+
|-----------------------------|----------------------------------------|
16+
| Unclear acceptance criteria | No definition of "done" |
17+
| Vague implementation | Missing concrete steps or file changes |
18+
| Missing/undefined tests | Tests mentioned only as afterthought |
19+
| Absent refactor phase | No plan to improve code after it works |
20+
| Ambiguous requirements | Multiple interpretations possible |
21+
| Missing verification | No way to confirm the change works |
22+
23+
## Planning Principles
24+
25+
### 1. Plans Must Be Actionable and Unambiguous
26+
27+
Every step should be concrete enough that another agent could execute it without guessing.
28+
29+
- ❌ "Improve error handling" → ✓ "Add try-catch to API calls in user-service.ts, return 400 with error message"
30+
- ❌ "Update tests" → ✓ "Add test case to auth.test.ts: 'should reject expired tokens with 401'"
31+
32+
NEVER include thinking output or other stream-of-consciousness prose mid-plan.
33+
34+
### 2. Push Back on Unclear Requirements
35+
36+
When requirements are ambiguous, ask questions before proceeding.
37+
38+
### 3. Tests Define Requirements
39+
40+
Writing test cases forces disambiguation. Use test definition as a requirements clarification tool.
41+
42+
### 4. TDD is Non-Negotiable
43+
44+
All plans follow: **Red → Green → Refactor**. The refactor phase is MANDATORY.
45+
46+
## The TDD Workflow
47+
48+
### Red Phase: Write Failing Tests First
49+
50+
**Purpose:** Define success criteria through concrete test cases.
51+
52+
**What to test:**
53+
54+
- Happy path (normal usage), edge cases (boundaries, empty/null), error conditions (invalid input, failures), integration points
55+
56+
**Test types:**
57+
58+
- Unit tests: Individual functions in isolation (most tests should be these - fast, focused)
59+
- Integration tests: Component interactions (use for critical paths)
60+
- E2E tests: Complete workflows (use sparingly)
61+
62+
**Write descriptive test cases:**
63+
64+
**If you can't write the test, you don't understand the requirement and MUST ask for clarification.**
65+
66+
### Green Phase: Make Tests Pass
67+
68+
**Purpose:** Implement minimal working solution.
69+
70+
Focus on correctness first. Hardcode if needed. Add just enough logic. Resist urge to "improve" code. Run tests frequently.
71+
72+
### Refactor Phase: Improve the Implementation
73+
74+
**Purpose:** Apply insights gained during implementation.
75+
76+
**This phase is MANDATORY.** During implementation you'll discover better structure, repeated patterns, and simplification opportunities.
77+
78+
**When to Extract vs Keep Duplication:**
79+
80+
This is highly subjective, so use the following rules of thumb combined with good judgement:
81+
82+
1) Follow the "rule of three": if the exact 10+ lines are repeated verbatim 3+ times, extract it.
83+
2) The "wrong abstraction" is harder to fix than duplication.
84+
3) If extraction would harm readability, prefer duplication.
85+
86+
**Common refactorings:**
87+
88+
- Rename for clarity
89+
- Simplify complex conditionals
90+
- Extract repeated code (if meets criteria above)
91+
- Apply design patterns
92+
93+
**Constraints:**
94+
95+
- All tests must still pass after refactoring
96+
- Don't add new features (that's a new Red phase)
97+
98+
## Plan Refinement Process
99+
100+
### Step 1: Review Current Plan for Completeness
101+
102+
- [ ] Clear context explaining why
103+
- [ ] Specific, unambiguous requirements
104+
- [ ] Test cases defined before implementation
105+
- [ ] Step-by-step implementation approach
106+
- [ ] Explicit refactor phase
107+
- [ ] Verification steps
108+
109+
### Step 2: Identify Gaps
110+
111+
Look for missing tests, vague steps, no refactor phase, ambiguous requirements, missing verification.
112+
113+
### Step 3: Handle Unclear Requirements
114+
115+
If you can't write the plan without this information, ask the user. Otherwise, make reasonable assumptions and note them in the plan.
116+
117+
### Step 4: Define Test Cases
118+
119+
For each requirement, write concrete test cases. If you struggle to write test cases, you need more clarification.
120+
121+
### Step 5: Structure with Red-Green-Refactor
122+
123+
Organize the plan into three explicit phases.
124+
125+
### Step 6: Add Verification Steps
126+
127+
Specify how to confirm the change works (automated tests + manual checks).
128+
129+
## Tips for Success
130+
131+
1. **Start with tests:** If you can't write the test, you don't understand the requirement.
132+
2. **Be specific:** "Update API" is not a step. "Add error handling to POST /users endpoint" is.
133+
3. **Always refactor:** Even if code looks good, ask "How could this be clearer?"
134+
4. **Question everything:** Ambiguity is the enemy.
135+
5. **Think in phases:** Red → Green → Refactor.
136+
6. **Keep plans manageable:** If plan exceeds ~10 files or >5 phases, consider splitting.
137+
138+
---
139+
140+
**Remember:** A good plan makes implementation straightforward. A vague plan leads to confusion, rework, and bugs.

coderd/x/chatd/chatd.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3334,6 +3334,7 @@ func (p *Server) runChat(
33343334
// create workspaces or spawn further subagents — they should
33353335
// focus on completing their delegated task.
33363336
if !chat.ParentChatID.Valid {
3337+
// Workspace provisioning tools.
33373338
tools = append(tools,
33383339
chattool.ListTemplates(chattool.ListTemplatesOptions{
33393340
DB: p.db,
@@ -3361,6 +3362,37 @@ func (p *Server) runChat(
33613362
WorkspaceMu: &workspaceMu,
33623363
}),
33633364
)
3365+
// Plan presentation tool.
3366+
tools = append(tools, chattool.ProposePlan(chattool.ProposePlanOptions{
3367+
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
3368+
StoreFile: func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error) {
3369+
workspaceCtx.chatStateMu.Lock()
3370+
chatSnapshot := *workspaceCtx.currentChat
3371+
workspaceCtx.chatStateMu.Unlock()
3372+
3373+
if !chatSnapshot.WorkspaceID.Valid {
3374+
return uuid.Nil, xerrors.New("chat has no workspace")
3375+
}
3376+
3377+
ws, err := p.db.GetWorkspaceByID(ctx, chatSnapshot.WorkspaceID.UUID)
3378+
if err != nil {
3379+
return uuid.Nil, xerrors.Errorf("resolve workspace: %w", err)
3380+
}
3381+
3382+
row, err := p.db.InsertChatFile(ctx, database.InsertChatFileParams{
3383+
OwnerID: chatSnapshot.OwnerID,
3384+
OrganizationID: ws.OrganizationID,
3385+
Name: name,
3386+
Mimetype: mediaType,
3387+
Data: data,
3388+
})
3389+
if err != nil {
3390+
return uuid.Nil, xerrors.Errorf("insert chat file: %w", err)
3391+
}
3392+
3393+
return row.ID, nil
3394+
},
3395+
}))
33643396
tools = append(tools, p.subagentTools(ctx, func() database.Chat {
33653397
return chat
33663398
})...)

coderd/x/chatd/chatd_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ func TestSubagentChatExcludesWorkspaceProvisioningTools(t *testing.T) {
218218
require.GreaterOrEqual(t, len(recorded), 2,
219219
"expected at least 2 streamed LLM calls (root + subagent)")
220220

221-
workspaceTools := []string{"list_templates", "read_template", "create_workspace"}
221+
workspaceTools := []string{"propose_plan", "list_templates", "read_template", "create_workspace"}
222222
subagentTools := []string{"spawn_agent", "wait_agent", "message_agent", "close_agent"}
223223

224224
// Identify root and subagent calls. Root chat calls include
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package chattool
2+
3+
import (
4+
"context"
5+
"io"
6+
"path/filepath"
7+
"strings"
8+
9+
"charm.land/fantasy"
10+
"github.com/google/uuid"
11+
12+
"github.com/coder/coder/v2/codersdk/workspacesdk"
13+
)
14+
15+
const maxProposePlanSize = 32 * 1024 // 32 KiB
16+
17+
// ProposePlanOptions configures the propose_plan tool.
18+
type ProposePlanOptions struct {
19+
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
20+
StoreFile func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error)
21+
}
22+
23+
// ProposePlanArgs are the arguments for the propose_plan tool.
24+
type ProposePlanArgs struct {
25+
Path string `json:"path"`
26+
}
27+
28+
// ProposePlan returns a tool that presents a Markdown plan file from the
29+
// workspace for user review.
30+
func ProposePlan(options ProposePlanOptions) fantasy.AgentTool {
31+
return fantasy.NewAgentTool(
32+
"propose_plan",
33+
"Present a Markdown plan file from the workspace for user review. "+
34+
"The file must already exist with a .md extension — use write_file to create it or edit_files to refine it before calling this tool. "+
35+
"Pass the absolute file path (e.g. /home/coder/PLAN.md). The tool reads the content from the workspace.",
36+
func(ctx context.Context, args ProposePlanArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
37+
if options.GetWorkspaceConn == nil {
38+
return fantasy.NewTextErrorResponse("workspace connection resolver is not configured"), nil
39+
}
40+
if options.StoreFile == nil {
41+
return fantasy.NewTextErrorResponse("file storage is not configured"), nil
42+
}
43+
conn, err := options.GetWorkspaceConn(ctx)
44+
if err != nil {
45+
return fantasy.NewTextErrorResponse(err.Error()), nil
46+
}
47+
return executeProposePlanTool(ctx, conn, args, options.StoreFile)
48+
},
49+
)
50+
}
51+
52+
func executeProposePlanTool(
53+
ctx context.Context,
54+
conn workspacesdk.AgentConn,
55+
args ProposePlanArgs,
56+
storeFile func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error),
57+
) (fantasy.ToolResponse, error) {
58+
path := strings.TrimSpace(args.Path)
59+
if path == "" {
60+
return fantasy.NewTextErrorResponse("path is required (use an absolute path, e.g. /home/coder/PLAN.md)"), nil
61+
}
62+
if !strings.HasSuffix(path, ".md") {
63+
return fantasy.NewTextErrorResponse("path must end with .md"), nil
64+
}
65+
66+
rc, _, err := conn.ReadFile(ctx, path, 0, maxProposePlanSize+1)
67+
if err != nil {
68+
return fantasy.NewTextErrorResponse(err.Error()), nil
69+
}
70+
defer rc.Close()
71+
72+
data, err := io.ReadAll(rc)
73+
if err != nil {
74+
return fantasy.NewTextErrorResponse(err.Error()), nil
75+
}
76+
if int64(len(data)) > maxProposePlanSize {
77+
return fantasy.NewTextErrorResponse("plan file exceeds 32 KiB size limit"), nil
78+
}
79+
80+
fileID, err := storeFile(ctx, filepath.Base(path), "text/markdown", data)
81+
if err != nil {
82+
return fantasy.NewTextErrorResponse("failed to store plan file: " + err.Error()), nil
83+
}
84+
85+
return toolResponse(map[string]any{
86+
"ok": true,
87+
"path": path,
88+
"kind": "plan",
89+
"file_id": fileID.String(),
90+
"media_type": "text/markdown",
91+
}), nil
92+
}

0 commit comments

Comments
 (0)