diff --git a/.agents/skills/refine-plan/SKILL.md b/.agents/skills/refine-plan/SKILL.md new file mode 100644 index 0000000000..818db5e424 --- /dev/null +++ b/.agents/skills/refine-plan/SKILL.md @@ -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. diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index ecb7589808..554bd71256 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -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 })...) diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 6db0282ab8..0aa9c3d408 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -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 diff --git a/coderd/x/chatd/chattool/proposeplan.go b/coderd/x/chatd/chattool/proposeplan.go new file mode 100644 index 0000000000..3704e53a43 --- /dev/null +++ b/coderd/x/chatd/chattool/proposeplan.go @@ -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 +} diff --git a/coderd/x/chatd/chattool/proposeplan_test.go b/coderd/x/chatd/chattool/proposeplan_test.go new file mode 100644 index 0000000000..1d8113d082 --- /dev/null +++ b/coderd/x/chatd/chattool/proposeplan_test.go @@ -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 +} diff --git a/coderd/x/chatd/prompt.go b/coderd/x/chatd/prompt.go index 9b8f6850c5..61b470ca28 100644 --- a/coderd/x/chatd/prompt.go +++ b/coderd/x/chatd/prompt.go @@ -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. @@ -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. -` + + + +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. +` diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 715c777936..b81c604071 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3036,6 +3036,14 @@ class ExperimentalApiMethods { return response.data; }; + getChatFileText = async (fileId: string): Promise => { + 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; diff --git a/site/src/components/ai-elements/tool/ProposePlanTool.stories.tsx b/site/src/components/ai-elements/tool/ProposePlanTool.stories.tsx new file mode 100644 index 0000000000..054712bc9c --- /dev/null +++ b/site/src/components/ai-elements/tool/ProposePlanTool.stories.tsx @@ -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 = { + title: "components/ai-elements/tool/ProposePlan", + component: Tool, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { name: "propose_plan" }, + parameters: { + reactRouter: reactRouterParameters({ routing: { path: "/" } }), + }, +}; +export default meta; +type Story = StoryObj; + +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(); + }, +}; diff --git a/site/src/components/ai-elements/tool/ProposePlanTool.tsx b/site/src/components/ai-elements/tool/ProposePlanTool.tsx new file mode 100644 index 0000000000..fae0def569 --- /dev/null +++ b/site/src/components/ai-elements/tool/ProposePlanTool.tsx @@ -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 ( +
+
+ + {isRunning ? `Proposing ${filename}…` : `Proposed ${filename}`} + + {effectiveError && ( + + + + + + {effectiveErrorMessage || "Failed to propose plan"} + + + )} + {isRunning && ( + + )} +
+ {displayContent ? ( + {displayContent} + ) : ( + !fetchLoading && + !effectiveError && ( +

+ No plan content. +

+ ) + )} + {fetchLoading && ( +
+ + Loading plan… +
+ )} +
+ ); +}; diff --git a/site/src/components/ai-elements/tool/Tool.tsx b/site/src/components/ai-elements/tool/Tool.tsx index e5e87a7ea4..9ac001842e 100644 --- a/site/src/components/ai-elements/tool/Tool.tsx +++ b/site/src/components/ai-elements/tool/Tool.tsx @@ -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 = ({ ); }; +const ProposePlanRenderer: FC = ({ + 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 ( + + ); +}; + const ComputerRenderer: FC = ({ status, result, @@ -510,6 +539,7 @@ const toolRenderers: Record> = { message_agent: SubagentRenderer, close_agent: SubagentRenderer, chat_summarized: ChatSummarizedRenderer, + propose_plan: ProposePlanRenderer, computer: ComputerRenderer, }; @@ -536,7 +566,9 @@ export const Tool = memo(
= ({ return ; case "chat_summarized": return ; + case "propose_plan": + return ; case "computer": return ; default: diff --git a/site/src/components/ai-elements/tool/ToolLabel.tsx b/site/src/components/ai-elements/tool/ToolLabel.tsx index e98b1703ec..0fbe03d7fe 100644 --- a/site/src/components/ai-elements/tool/ToolLabel.tsx +++ b/site/src/components/ai-elements/tool/ToolLabel.tsx @@ -160,6 +160,15 @@ export const ToolLabel: React.FC<{ Screenshot ); + case "propose_plan": { + const path = parsed ? asString(parsed.path) || "PLAN.md" : "PLAN.md"; + const filename = path.split("/").pop() || "PLAN.md"; + return ( + + {filename} + + ); + } default: return ( {name}