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:
140
.agents/skills/refine-plan/SKILL.md
Normal file
140
.agents/skills/refine-plan/SKILL.md
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
name: refine-plan
|
||||
description: Iteratively refine development plans using TDD methodology. Ensures plans are clear, actionable, and include red-green-refactor cycles with proper test coverage.
|
||||
---
|
||||
|
||||
# Refine Development Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Good plans eliminate ambiguity through clear requirements, break work into clear phases, and always include refactoring to capture implementation insights.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
| Symptom | Example |
|
||||
|-----------------------------|----------------------------------------|
|
||||
| Unclear acceptance criteria | No definition of "done" |
|
||||
| Vague implementation | Missing concrete steps or file changes |
|
||||
| Missing/undefined tests | Tests mentioned only as afterthought |
|
||||
| Absent refactor phase | No plan to improve code after it works |
|
||||
| Ambiguous requirements | Multiple interpretations possible |
|
||||
| Missing verification | No way to confirm the change works |
|
||||
|
||||
## Planning Principles
|
||||
|
||||
### 1. Plans Must Be Actionable and Unambiguous
|
||||
|
||||
Every step should be concrete enough that another agent could execute it without guessing.
|
||||
|
||||
- ❌ "Improve error handling" → ✓ "Add try-catch to API calls in user-service.ts, return 400 with error message"
|
||||
- ❌ "Update tests" → ✓ "Add test case to auth.test.ts: 'should reject expired tokens with 401'"
|
||||
|
||||
NEVER include thinking output or other stream-of-consciousness prose mid-plan.
|
||||
|
||||
### 2. Push Back on Unclear Requirements
|
||||
|
||||
When requirements are ambiguous, ask questions before proceeding.
|
||||
|
||||
### 3. Tests Define Requirements
|
||||
|
||||
Writing test cases forces disambiguation. Use test definition as a requirements clarification tool.
|
||||
|
||||
### 4. TDD is Non-Negotiable
|
||||
|
||||
All plans follow: **Red → Green → Refactor**. The refactor phase is MANDATORY.
|
||||
|
||||
## The TDD Workflow
|
||||
|
||||
### Red Phase: Write Failing Tests First
|
||||
|
||||
**Purpose:** Define success criteria through concrete test cases.
|
||||
|
||||
**What to test:**
|
||||
|
||||
- Happy path (normal usage), edge cases (boundaries, empty/null), error conditions (invalid input, failures), integration points
|
||||
|
||||
**Test types:**
|
||||
|
||||
- Unit tests: Individual functions in isolation (most tests should be these - fast, focused)
|
||||
- Integration tests: Component interactions (use for critical paths)
|
||||
- E2E tests: Complete workflows (use sparingly)
|
||||
|
||||
**Write descriptive test cases:**
|
||||
|
||||
**If you can't write the test, you don't understand the requirement and MUST ask for clarification.**
|
||||
|
||||
### Green Phase: Make Tests Pass
|
||||
|
||||
**Purpose:** Implement minimal working solution.
|
||||
|
||||
Focus on correctness first. Hardcode if needed. Add just enough logic. Resist urge to "improve" code. Run tests frequently.
|
||||
|
||||
### Refactor Phase: Improve the Implementation
|
||||
|
||||
**Purpose:** Apply insights gained during implementation.
|
||||
|
||||
**This phase is MANDATORY.** During implementation you'll discover better structure, repeated patterns, and simplification opportunities.
|
||||
|
||||
**When to Extract vs Keep Duplication:**
|
||||
|
||||
This is highly subjective, so use the following rules of thumb combined with good judgement:
|
||||
|
||||
1) Follow the "rule of three": if the exact 10+ lines are repeated verbatim 3+ times, extract it.
|
||||
2) The "wrong abstraction" is harder to fix than duplication.
|
||||
3) If extraction would harm readability, prefer duplication.
|
||||
|
||||
**Common refactorings:**
|
||||
|
||||
- Rename for clarity
|
||||
- Simplify complex conditionals
|
||||
- Extract repeated code (if meets criteria above)
|
||||
- Apply design patterns
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- All tests must still pass after refactoring
|
||||
- Don't add new features (that's a new Red phase)
|
||||
|
||||
## Plan Refinement Process
|
||||
|
||||
### Step 1: Review Current Plan for Completeness
|
||||
|
||||
- [ ] Clear context explaining why
|
||||
- [ ] Specific, unambiguous requirements
|
||||
- [ ] Test cases defined before implementation
|
||||
- [ ] Step-by-step implementation approach
|
||||
- [ ] Explicit refactor phase
|
||||
- [ ] Verification steps
|
||||
|
||||
### Step 2: Identify Gaps
|
||||
|
||||
Look for missing tests, vague steps, no refactor phase, ambiguous requirements, missing verification.
|
||||
|
||||
### Step 3: Handle Unclear Requirements
|
||||
|
||||
If you can't write the plan without this information, ask the user. Otherwise, make reasonable assumptions and note them in the plan.
|
||||
|
||||
### Step 4: Define Test Cases
|
||||
|
||||
For each requirement, write concrete test cases. If you struggle to write test cases, you need more clarification.
|
||||
|
||||
### Step 5: Structure with Red-Green-Refactor
|
||||
|
||||
Organize the plan into three explicit phases.
|
||||
|
||||
### Step 6: Add Verification Steps
|
||||
|
||||
Specify how to confirm the change works (automated tests + manual checks).
|
||||
|
||||
## Tips for Success
|
||||
|
||||
1. **Start with tests:** If you can't write the test, you don't understand the requirement.
|
||||
2. **Be specific:** "Update API" is not a step. "Add error handling to POST /users endpoint" is.
|
||||
3. **Always refactor:** Even if code looks good, ask "How could this be clearer?"
|
||||
4. **Question everything:** Ambiguity is the enemy.
|
||||
5. **Think in phases:** Red → Green → Refactor.
|
||||
6. **Keep plans manageable:** If plan exceeds ~10 files or >5 phases, consider splitting.
|
||||
|
||||
---
|
||||
|
||||
**Remember:** A good plan makes implementation straightforward. A vague plan leads to confusion, rework, and bugs.
|
||||
@@ -3334,6 +3334,7 @@ func (p *Server) runChat(
|
||||
// create workspaces or spawn further subagents — they should
|
||||
// focus on completing their delegated task.
|
||||
if !chat.ParentChatID.Valid {
|
||||
// Workspace provisioning tools.
|
||||
tools = append(tools,
|
||||
chattool.ListTemplates(chattool.ListTemplatesOptions{
|
||||
DB: p.db,
|
||||
@@ -3361,6 +3362,37 @@ func (p *Server) runChat(
|
||||
WorkspaceMu: &workspaceMu,
|
||||
}),
|
||||
)
|
||||
// Plan presentation tool.
|
||||
tools = append(tools, chattool.ProposePlan(chattool.ProposePlanOptions{
|
||||
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
|
||||
StoreFile: func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error) {
|
||||
workspaceCtx.chatStateMu.Lock()
|
||||
chatSnapshot := *workspaceCtx.currentChat
|
||||
workspaceCtx.chatStateMu.Unlock()
|
||||
|
||||
if !chatSnapshot.WorkspaceID.Valid {
|
||||
return uuid.Nil, xerrors.New("chat has no workspace")
|
||||
}
|
||||
|
||||
ws, err := p.db.GetWorkspaceByID(ctx, chatSnapshot.WorkspaceID.UUID)
|
||||
if err != nil {
|
||||
return uuid.Nil, xerrors.Errorf("resolve workspace: %w", err)
|
||||
}
|
||||
|
||||
row, err := p.db.InsertChatFile(ctx, database.InsertChatFileParams{
|
||||
OwnerID: chatSnapshot.OwnerID,
|
||||
OrganizationID: ws.OrganizationID,
|
||||
Name: name,
|
||||
Mimetype: mediaType,
|
||||
Data: data,
|
||||
})
|
||||
if err != nil {
|
||||
return uuid.Nil, xerrors.Errorf("insert chat file: %w", err)
|
||||
}
|
||||
|
||||
return row.ID, nil
|
||||
},
|
||||
}))
|
||||
tools = append(tools, p.subagentTools(ctx, func() database.Chat {
|
||||
return chat
|
||||
})...)
|
||||
|
||||
@@ -218,7 +218,7 @@ func TestSubagentChatExcludesWorkspaceProvisioningTools(t *testing.T) {
|
||||
require.GreaterOrEqual(t, len(recorded), 2,
|
||||
"expected at least 2 streamed LLM calls (root + subagent)")
|
||||
|
||||
workspaceTools := []string{"list_templates", "read_template", "create_workspace"}
|
||||
workspaceTools := []string{"propose_plan", "list_templates", "read_template", "create_workspace"}
|
||||
subagentTools := []string{"spawn_agent", "wait_agent", "message_agent", "close_agent"}
|
||||
|
||||
// Identify root and subagent calls. Root chat calls include
|
||||
|
||||
92
coderd/x/chatd/chattool/proposeplan.go
Normal file
92
coderd/x/chatd/chattool/proposeplan.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package chattool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
const maxProposePlanSize = 32 * 1024 // 32 KiB
|
||||
|
||||
// ProposePlanOptions configures the propose_plan tool.
|
||||
type ProposePlanOptions struct {
|
||||
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
|
||||
StoreFile func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error)
|
||||
}
|
||||
|
||||
// ProposePlanArgs are the arguments for the propose_plan tool.
|
||||
type ProposePlanArgs struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// ProposePlan returns a tool that presents a Markdown plan file from the
|
||||
// workspace for user review.
|
||||
func ProposePlan(options ProposePlanOptions) fantasy.AgentTool {
|
||||
return fantasy.NewAgentTool(
|
||||
"propose_plan",
|
||||
"Present a Markdown plan file from the workspace for user review. "+
|
||||
"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. "+
|
||||
"Pass the absolute file path (e.g. /home/coder/PLAN.md). The tool reads the content from the workspace.",
|
||||
func(ctx context.Context, args ProposePlanArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
if options.GetWorkspaceConn == nil {
|
||||
return fantasy.NewTextErrorResponse("workspace connection resolver is not configured"), nil
|
||||
}
|
||||
if options.StoreFile == nil {
|
||||
return fantasy.NewTextErrorResponse("file storage is not configured"), nil
|
||||
}
|
||||
conn, err := options.GetWorkspaceConn(ctx)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
return executeProposePlanTool(ctx, conn, args, options.StoreFile)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func executeProposePlanTool(
|
||||
ctx context.Context,
|
||||
conn workspacesdk.AgentConn,
|
||||
args ProposePlanArgs,
|
||||
storeFile func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error),
|
||||
) (fantasy.ToolResponse, error) {
|
||||
path := strings.TrimSpace(args.Path)
|
||||
if path == "" {
|
||||
return fantasy.NewTextErrorResponse("path is required (use an absolute path, e.g. /home/coder/PLAN.md)"), nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".md") {
|
||||
return fantasy.NewTextErrorResponse("path must end with .md"), nil
|
||||
}
|
||||
|
||||
rc, _, err := conn.ReadFile(ctx, path, 0, maxProposePlanSize+1)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
data, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
if int64(len(data)) > maxProposePlanSize {
|
||||
return fantasy.NewTextErrorResponse("plan file exceeds 32 KiB size limit"), nil
|
||||
}
|
||||
|
||||
fileID, err := storeFile(ctx, filepath.Base(path), "text/markdown", data)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to store plan file: " + err.Error()), nil
|
||||
}
|
||||
|
||||
return toolResponse(map[string]any{
|
||||
"ok": true,
|
||||
"path": path,
|
||||
"kind": "plan",
|
||||
"file_id": fileID.String(),
|
||||
"media_type": "text/markdown",
|
||||
}), nil
|
||||
}
|
||||
309
coderd/x/chatd/chattool/proposeplan_test.go
Normal file
309
coderd/x/chatd/chattool/proposeplan_test.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package chattool_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/iotest"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock"
|
||||
)
|
||||
|
||||
type proposePlanResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Path string `json:"path"`
|
||||
Kind string `json:"kind"`
|
||||
FileID string `json:"file_id"`
|
||||
MediaType string `json:"media_type"`
|
||||
}
|
||||
|
||||
func TestProposePlan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("EmptyPathReturnsError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
tool := newProposePlanTool(t, mockConn, storeFile)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":""}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "path is required")
|
||||
})
|
||||
|
||||
t.Run("WhitespaceOnlyPathReturnsError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
tool := newProposePlanTool(t, mockConn, storeFile)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":" "}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "path is required")
|
||||
})
|
||||
|
||||
t.Run("NonMdPathReturnsError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
tool := newProposePlanTool(t, mockConn, storeFile)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/plan.txt"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "path must end with .md")
|
||||
})
|
||||
|
||||
t.Run("OversizedFileRejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
largeContent := strings.Repeat("x", 32*1024+1)
|
||||
|
||||
mockConn.EXPECT().
|
||||
ReadFile(gomock.Any(), "/home/coder/PLAN.md", int64(0), int64(32*1024+1)).
|
||||
Return(io.NopCloser(strings.NewReader(largeContent)), "text/markdown", nil)
|
||||
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
tool := newProposePlanTool(t, mockConn, storeFile)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/PLAN.md"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "plan file exceeds 32 KiB size limit")
|
||||
})
|
||||
|
||||
t.Run("ExactBoundaryFileSucceeds", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
content := strings.Repeat("x", 32*1024)
|
||||
|
||||
mockConn.EXPECT().
|
||||
ReadFile(gomock.Any(), "/home/coder/PLAN.md", int64(0), int64(32*1024+1)).
|
||||
Return(io.NopCloser(strings.NewReader(content)), "text/markdown", nil)
|
||||
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
tool := newProposePlanTool(t, mockConn, storeFile)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/PLAN.md"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
})
|
||||
|
||||
t.Run("ValidPlanReadsFile", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
mockConn.EXPECT().
|
||||
ReadFile(gomock.Any(), "/home/coder/docs/PLAN.md", int64(0), int64(32*1024+1)).
|
||||
Return(io.NopCloser(strings.NewReader("# Plan\n\nContent")), "text/markdown", nil)
|
||||
|
||||
storeFile, stored := fakeStoreFile(t)
|
||||
tool := newProposePlanTool(t, mockConn, storeFile)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/docs/PLAN.md"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
|
||||
result := decodeProposePlanResponse(t, resp)
|
||||
assert.True(t, result.OK)
|
||||
assert.Equal(t, "/home/coder/docs/PLAN.md", result.Path)
|
||||
assert.Equal(t, "plan", result.Kind)
|
||||
assert.Equal(t, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", result.FileID)
|
||||
assert.Equal(t, "text/markdown", result.MediaType)
|
||||
assert.Equal(t, []byte("# Plan\n\nContent"), *stored)
|
||||
assert.NotContains(t, resp.Content, "content")
|
||||
})
|
||||
|
||||
t.Run("FileNotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
mockConn.EXPECT().
|
||||
ReadFile(gomock.Any(), "/home/coder/PLAN.md", int64(0), int64(32*1024+1)).
|
||||
Return(nil, "", xerrors.New("file not found"))
|
||||
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
tool := newProposePlanTool(t, mockConn, storeFile)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/PLAN.md"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "file not found")
|
||||
})
|
||||
|
||||
t.Run("ReadAllError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
mockConn.EXPECT().
|
||||
ReadFile(gomock.Any(), "/home/coder/PLAN.md", int64(0), int64(32*1024+1)).
|
||||
Return(io.NopCloser(iotest.ErrReader(xerrors.New("connection reset"))), "text/markdown", nil)
|
||||
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
tool := newProposePlanTool(t, mockConn, storeFile)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/PLAN.md"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "connection reset")
|
||||
})
|
||||
|
||||
t.Run("StoreFileError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
mockConn.EXPECT().
|
||||
ReadFile(gomock.Any(), "/home/coder/PLAN.md", int64(0), int64(32*1024+1)).
|
||||
Return(io.NopCloser(strings.NewReader("# Plan")), "text/markdown", nil)
|
||||
|
||||
tool := newProposePlanTool(t, mockConn, func(_ context.Context, _ string, _ string, _ []byte) (uuid.UUID, error) {
|
||||
return uuid.Nil, xerrors.New("storage unavailable")
|
||||
})
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/PLAN.md"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "storage unavailable")
|
||||
})
|
||||
|
||||
t.Run("WorkspaceConnectionError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
tool := chattool.ProposePlan(chattool.ProposePlanOptions{
|
||||
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
|
||||
return nil, xerrors.New("connection failed")
|
||||
},
|
||||
StoreFile: storeFile,
|
||||
})
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/PLAN.md"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "connection failed")
|
||||
})
|
||||
|
||||
t.Run("NilWorkspaceResolver", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool := chattool.ProposePlan(chattool.ProposePlanOptions{})
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/PLAN.md"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "workspace connection resolver is not configured")
|
||||
})
|
||||
|
||||
t.Run("NilStoreFile", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
tool := chattool.ProposePlan(chattool.ProposePlanOptions{
|
||||
GetWorkspaceConn: func(_ context.Context) (workspacesdk.AgentConn, error) {
|
||||
return mockConn, nil
|
||||
},
|
||||
})
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/PLAN.md"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "file storage is not configured")
|
||||
})
|
||||
}
|
||||
|
||||
func newProposePlanTool(
|
||||
t *testing.T,
|
||||
mockConn *agentconnmock.MockAgentConn,
|
||||
storeFile func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error),
|
||||
) fantasy.AgentTool {
|
||||
t.Helper()
|
||||
return chattool.ProposePlan(chattool.ProposePlanOptions{
|
||||
GetWorkspaceConn: func(_ context.Context) (workspacesdk.AgentConn, error) {
|
||||
return mockConn, nil
|
||||
},
|
||||
StoreFile: storeFile,
|
||||
})
|
||||
}
|
||||
|
||||
func fakeStoreFile(t *testing.T) (func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error), *[]byte) {
|
||||
t.Helper()
|
||||
|
||||
var stored []byte
|
||||
return func(_ context.Context, name string, mediaType string, data []byte) (uuid.UUID, error) {
|
||||
assert.NotEmpty(t, name)
|
||||
assert.Equal(t, "text/markdown", mediaType)
|
||||
stored = append([]byte(nil), data...)
|
||||
return uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), nil
|
||||
}, &stored
|
||||
}
|
||||
|
||||
func decodeProposePlanResponse(t *testing.T, resp fantasy.ToolResponse) proposePlanResponse {
|
||||
t.Helper()
|
||||
|
||||
var result proposePlanResponse
|
||||
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
|
||||
return result
|
||||
}
|
||||
@@ -13,6 +13,7 @@ You MUST execute AS MANY TOOLS to help the user accomplish their task.
|
||||
You are COMFORTABLE with vague tasks - using your tools to collect the most relevant answer possible.
|
||||
If a user asks how something works, no matter how vague, you MUST use your tools to collect the most relevant answer possible.
|
||||
DO NOT ask the user for clarification - just use your tools.
|
||||
If a task is too ambiguous to implement with confidence, or the user asks for a plan, write a plan before implementing. Use propose_plan to present it for review.
|
||||
</behavior>
|
||||
|
||||
<personality>
|
||||
@@ -70,4 +71,21 @@ When a user asks for help with a task or there is ambiguity on the objective, al
|
||||
- What problems they're trying to solve
|
||||
|
||||
Don't assume what needs to be done - collaborate to define the scope together.
|
||||
</collaboration>`
|
||||
</collaboration>
|
||||
|
||||
<planning>
|
||||
Propose a plan when:
|
||||
- The task is too ambiguous to implement with confidence.
|
||||
- The user asks for a plan.
|
||||
|
||||
If no workspace is attached to this chat yet, create and start one first using create_workspace and start_workspace.
|
||||
Once a workspace is available:
|
||||
1. Use spawn_agent and wait_agent to research the codebase and gather context as needed.
|
||||
2. Use write_file to create a Markdown plan file in the workspace (e.g. /home/coder/PLAN.md).
|
||||
3. Iterate on the plan with edit_files if needed.
|
||||
4. Call propose_plan with the absolute file path to present the plan to the user.
|
||||
5. Wait for the user to review and approve the plan before starting implementation.
|
||||
|
||||
The propose_plan tool reads the file from the workspace — do not pass content directly.
|
||||
Write the file first, then present it. All file paths must be absolute.
|
||||
</planning>`
|
||||
|
||||
@@ -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;
|
||||
|
||||
206
site/src/components/ai-elements/tool/ProposePlanTool.stories.tsx
Normal file
206
site/src/components/ai-elements/tool/ProposePlanTool.stories.tsx
Normal file
@@ -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();
|
||||
},
|
||||
};
|
||||
105
site/src/components/ai-elements/tool/ProposePlanTool.tsx
Normal file
105
site/src/components/ai-elements/tool/ProposePlanTool.tsx
Normal file
@@ -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