Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9d6fcaad0 | |||
| ba45e3bee1 | |||
| 0894dcabd2 | |||
| ec3c5b0259 | |||
| b5b4cf9843 |
@@ -56,6 +56,7 @@ const (
|
||||
DefaultInFlightChatStaleAfter = 5 * time.Minute
|
||||
|
||||
homeInstructionLookupTimeout = 5 * time.Second
|
||||
planPathLookupTimeout = 5 * time.Second
|
||||
instructionCacheTTL = 5 * time.Minute
|
||||
workspaceDialValidationDelay = 5 * time.Second
|
||||
workspaceMCPDiscoveryTimeout = 5 * time.Second
|
||||
@@ -4470,6 +4471,37 @@ func (p *Server) runChat(
|
||||
}
|
||||
defer workspaceCtx.close()
|
||||
|
||||
planPathFn := func(ctx context.Context) (string, string, error) {
|
||||
conn, err := workspaceCtx.getWorkspaceConn(ctx)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
home, err := chattool.ResolveWorkspaceHome(ctx, conn)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return chattool.PlanPathForChat(home, chat.ID), home, nil
|
||||
}
|
||||
resolvePlanPathInstruction := func(resolveCtx context.Context) string {
|
||||
if chat.ParentChatID.Valid {
|
||||
return ""
|
||||
}
|
||||
|
||||
planCtx, cancel := context.WithTimeout(resolveCtx, planPathLookupTimeout)
|
||||
defer cancel()
|
||||
|
||||
if _, _, err := workspaceCtx.workspaceAgentIDForConn(planCtx); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
planPath, _, err := planPathFn(planCtx)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return formatPlanPathInstruction(planPath)
|
||||
}
|
||||
|
||||
// Connect to MCP servers in parallel with instruction
|
||||
// resolution. ConnectAll only depends on mcpConfigs and
|
||||
// mcpTokens which are available after g.Wait() above.
|
||||
@@ -4663,6 +4695,9 @@ func (p *Server) runChat(
|
||||
if instruction != "" {
|
||||
prompt = chatprompt.InsertSystem(prompt, instruction)
|
||||
}
|
||||
if planPathInstruction := resolvePlanPathInstruction(ctx); planPathInstruction != "" {
|
||||
prompt = chatprompt.InsertSystem(prompt, planPathInstruction)
|
||||
}
|
||||
if skillIndex := chattool.FormatSkillIndex(skills); skillIndex != "" {
|
||||
prompt = chatprompt.InsertSystem(prompt, skillIndex)
|
||||
}
|
||||
@@ -4975,9 +5010,11 @@ func (p *Server) runChat(
|
||||
}),
|
||||
chattool.WriteFile(chattool.WriteFileOptions{
|
||||
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
|
||||
PlanPath: planPathFn,
|
||||
}),
|
||||
chattool.EditFiles(chattool.EditFilesOptions{
|
||||
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
|
||||
PlanPath: planPathFn,
|
||||
}),
|
||||
chattool.Execute(chattool.ExecuteOptions{
|
||||
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
|
||||
@@ -5033,6 +5070,7 @@ func (p *Server) runChat(
|
||||
// Plan presentation tool.
|
||||
tools = append(tools, chattool.ProposePlan(chattool.ProposePlanOptions{
|
||||
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
|
||||
PlanPath: planPathFn,
|
||||
StoreFile: func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error) {
|
||||
workspaceCtx.chatStateMu.Lock()
|
||||
chatSnapshot := *workspaceCtx.currentChat
|
||||
@@ -5225,6 +5263,9 @@ func (p *Server) runChat(
|
||||
if instruction != "" {
|
||||
reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, instruction)
|
||||
}
|
||||
if planPathInstruction := resolvePlanPathInstruction(reloadCtx); planPathInstruction != "" {
|
||||
reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, planPathInstruction)
|
||||
}
|
||||
if skillIndex := chattool.FormatSkillIndex(skills); skillIndex != "" {
|
||||
reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, skillIndex)
|
||||
}
|
||||
@@ -5896,6 +5937,24 @@ func (p *Server) resolveUserPrompt(ctx context.Context, userID uuid.UUID) string
|
||||
return "<user-instructions>\n" + trimmed + "\n</user-instructions>"
|
||||
}
|
||||
|
||||
func formatPlanPathInstruction(planPath string) string {
|
||||
planPath = strings.TrimSpace(planPath)
|
||||
if planPath == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
_, _ = b.WriteString("<plan-file-path>\n")
|
||||
_, _ = b.WriteString("Your plan file path for this chat is: ")
|
||||
_, _ = b.WriteString(planPath)
|
||||
_, _ = b.WriteString("\n")
|
||||
_, _ = b.WriteString("Always use this exact path when creating or proposing plan files. Do not use ")
|
||||
_, _ = b.WriteString(chattool.LegacySharedPlanPath)
|
||||
_, _ = b.WriteString(".\n")
|
||||
_, _ = b.WriteString("</plan-file-path>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (p *Server) recoverStaleChats(ctx context.Context) {
|
||||
staleAfter := time.Now().Add(-p.inFlightChatStaleAfter)
|
||||
staleChats, err := p.db.GetStaleChats(ctx, staleAfter)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
type EditFilesOptions struct {
|
||||
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
|
||||
PlanPath func(context.Context) (chatPath string, home string, err error)
|
||||
}
|
||||
|
||||
type EditFilesArgs struct {
|
||||
@@ -29,7 +30,7 @@ func EditFiles(options EditFilesOptions) fantasy.AgentTool {
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
return executeEditFilesTool(ctx, conn, args)
|
||||
return executeEditFilesTool(ctx, conn, args, options.PlanPath)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -38,11 +39,31 @@ func executeEditFilesTool(
|
||||
ctx context.Context,
|
||||
conn workspacesdk.AgentConn,
|
||||
args EditFilesArgs,
|
||||
resolvePlanPath func(context.Context) (chatPath string, home string, err error),
|
||||
) (fantasy.ToolResponse, error) {
|
||||
if len(args.Files) == 0 {
|
||||
return fantasy.NewTextErrorResponse("files is required"), nil
|
||||
}
|
||||
|
||||
var (
|
||||
chatPath string
|
||||
home string
|
||||
planPathErr error
|
||||
planPathLoaded bool
|
||||
)
|
||||
for _, file := range args.Files {
|
||||
if resolvePlanPath == nil || !looksLikePlanFileName(file.Path) {
|
||||
continue
|
||||
}
|
||||
if !planPathLoaded {
|
||||
chatPath, home, planPathErr = resolvePlanPath(ctx)
|
||||
planPathLoaded = true
|
||||
}
|
||||
if resp, rejected := rejectSharedPlanPath(file.Path, home, chatPath, planPathErr); rejected {
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := conn.EditFiles(ctx, workspacesdk.FileEditRequest{Files: args.Files}); err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package chattool_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestEditFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("RejectsHomeRootPlanPathsWhenPlanPathIsConfigured", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
tool := chattool.EditFiles(chattool.EditFilesOptions{
|
||||
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
|
||||
return mockConn, nil
|
||||
},
|
||||
PlanPath: func(context.Context) (string, string, error) {
|
||||
return "/Users/dev/.coder/plans/PLAN-chat.md", "/Users/dev", nil
|
||||
},
|
||||
})
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "edit_files",
|
||||
Input: `{"files":[{"path":"/Users/dev/plan.md","edits":[{"search":"old","replace":"new"}]}]}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Equal(
|
||||
t,
|
||||
sharedPlanPathResolvedMessage("/Users/dev/.coder/plans/PLAN-chat.md"),
|
||||
resp.Content,
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("AllowsNonSharedPath", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
request := workspacesdk.FileEditRequest{Files: []workspacesdk.FileEdits{{
|
||||
Path: "/home/dev/my-plan.md",
|
||||
Edits: []workspacesdk.FileEdit{{
|
||||
Search: "old",
|
||||
Replace: "new",
|
||||
}},
|
||||
}}}
|
||||
mockConn.EXPECT().EditFiles(gomock.Any(), request).Return(nil)
|
||||
|
||||
planPathCalled := false
|
||||
tool := chattool.EditFiles(chattool.EditFilesOptions{
|
||||
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
|
||||
return mockConn, nil
|
||||
},
|
||||
PlanPath: func(context.Context) (string, string, error) {
|
||||
planPathCalled = true
|
||||
return "", "", xerrors.New("should not be called")
|
||||
},
|
||||
})
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "edit_files",
|
||||
Input: `{"files":[{"path":"/home/dev/my-plan.md","edits":[{"search":"old","replace":"new"}]}]}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
assert.False(t, planPathCalled)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package chattool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
const planFileNamePrefix = "PLAN-"
|
||||
|
||||
// LegacySharedPlanPath is the original shared plan file path used by
|
||||
// every chat in a workspace.
|
||||
const LegacySharedPlanPath = "/home/coder/PLAN.md"
|
||||
|
||||
// ResolveWorkspaceHome returns the workspace user's home directory.
|
||||
func ResolveWorkspaceHome(
|
||||
ctx context.Context,
|
||||
conn workspacesdk.AgentConn,
|
||||
) (string, error) {
|
||||
if conn == nil {
|
||||
return "", xerrors.New("workspace connection is required")
|
||||
}
|
||||
|
||||
resp, err := conn.LS(ctx, "", workspacesdk.LSRequest{
|
||||
Path: []string{},
|
||||
Relativity: workspacesdk.LSRelativityHome,
|
||||
})
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("resolve workspace home: %w", err)
|
||||
}
|
||||
|
||||
home := strings.TrimSpace(resp.AbsolutePathString)
|
||||
if home == "" {
|
||||
return "", xerrors.New("workspace home path is empty")
|
||||
}
|
||||
|
||||
return home, nil
|
||||
}
|
||||
|
||||
// PlanPathForChat returns the per-chat plan file path rooted in the
|
||||
// workspace home directory.
|
||||
func PlanPathForChat(home string, chatID uuid.UUID) string {
|
||||
return path.Join(
|
||||
home,
|
||||
".coder",
|
||||
"plans",
|
||||
planFileNamePrefix+chatID.String()+".md",
|
||||
)
|
||||
}
|
||||
|
||||
func looksLikePlanFileName(requestedPath string) bool {
|
||||
cleaned := path.Clean(requestedPath)
|
||||
return strings.EqualFold(path.Base(cleaned), "plan.md")
|
||||
}
|
||||
|
||||
// LooksLikeHomePlanFile reports whether requestedPath is a plan.md
|
||||
// variant (case-insensitive) sitting directly in the workspace home
|
||||
// directory.
|
||||
func LooksLikeHomePlanFile(requestedPath, home string) bool {
|
||||
cleaned := path.Clean(requestedPath)
|
||||
cleanedHome := path.Clean(home)
|
||||
|
||||
return looksLikePlanFileName(cleaned) &&
|
||||
path.Dir(cleaned) == cleanedHome
|
||||
}
|
||||
|
||||
// IsLegacySharedPlanPath reports whether requested is the exact legacy
|
||||
// shared plan file path.
|
||||
func IsLegacySharedPlanPath(requested string) bool {
|
||||
return requested == LegacySharedPlanPath
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package chattool_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestResolveWorkspaceHome(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
resp workspacesdk.LSResponse
|
||||
lsErr error
|
||||
want string
|
||||
wantErr bool
|
||||
errMatch string
|
||||
}{
|
||||
{
|
||||
name: "StandardLinuxHome",
|
||||
resp: workspacesdk.LSResponse{AbsolutePathString: "/home/coder"},
|
||||
want: "/home/coder",
|
||||
},
|
||||
{
|
||||
name: "NonStandardHome",
|
||||
resp: workspacesdk.LSResponse{AbsolutePathString: "/Users/dev"},
|
||||
want: "/Users/dev",
|
||||
},
|
||||
{
|
||||
name: "LSError",
|
||||
lsErr: xerrors.New("list failed"),
|
||||
wantErr: true,
|
||||
errMatch: "list failed",
|
||||
},
|
||||
{
|
||||
name: "EmptyAbsolutePathString",
|
||||
resp: workspacesdk.LSResponse{AbsolutePathString: ""},
|
||||
wantErr: true,
|
||||
errMatch: "workspace home path is empty",
|
||||
},
|
||||
{
|
||||
name: "WhitespaceOnlyAbsolutePathString",
|
||||
resp: workspacesdk.LSResponse{AbsolutePathString: " \t\n "},
|
||||
wantErr: true,
|
||||
errMatch: "workspace home path is empty",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range tests {
|
||||
testCase := testCase
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
conn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
conn.EXPECT().LS(
|
||||
gomock.Any(),
|
||||
"",
|
||||
workspacesdk.LSRequest{
|
||||
Path: []string{},
|
||||
Relativity: workspacesdk.LSRelativityHome,
|
||||
},
|
||||
).Return(testCase.resp, testCase.lsErr)
|
||||
|
||||
got, err := chattool.ResolveWorkspaceHome(context.Background(), conn)
|
||||
if testCase.wantErr {
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, testCase.errMatch)
|
||||
require.Empty(t, got)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testCase.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanPathForChat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("StandardHome", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
chatID := uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")
|
||||
|
||||
got := chattool.PlanPathForChat("/home/coder", chatID)
|
||||
|
||||
require.Equal(
|
||||
t,
|
||||
"/home/coder/.coder/plans/PLAN-123e4567-e89b-12d3-a456-426614174000.md",
|
||||
got,
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("NonStandardHome", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
chatID := uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
|
||||
|
||||
got := chattool.PlanPathForChat("/Users/dev", chatID)
|
||||
|
||||
require.Equal(
|
||||
t,
|
||||
"/Users/dev/.coder/plans/PLAN-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.md",
|
||||
got,
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("MatchesExpectedFormat", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
home := "/workspace/home"
|
||||
chatID := uuid.MustParse("f47ac10b-58cc-4372-a567-0e02b2c3d479")
|
||||
|
||||
got := chattool.PlanPathForChat(home, chatID)
|
||||
|
||||
require.True(t, strings.HasPrefix(got, home+"/.coder/plans/PLAN-"))
|
||||
require.True(t, strings.HasSuffix(got, chatID.String()+".md"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsLegacySharedPlanPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
requested string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "ExactMatch",
|
||||
requested: "/home/coder/PLAN.md",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "DifferentFilename",
|
||||
requested: "/home/coder/OTHER.md",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "DifferentDirectory",
|
||||
requested: "/home/dev/PLAN.md",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "PerChatPath",
|
||||
requested: "/home/coder/.coder/plans/PLAN-123e4567-e89b-12d3-a456-426614174000.md",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "EmptyString",
|
||||
requested: "",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "SubstringMatch",
|
||||
requested: "/home/coder/PLAN.md/extra",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range tests {
|
||||
testCase := testCase
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := chattool.IsLegacySharedPlanPath(testCase.requested)
|
||||
|
||||
require.Equal(t, testCase.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeHomePlanFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
requested string
|
||||
home string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "UppercaseHomeRootPlan",
|
||||
requested: "/home/coder/PLAN.md",
|
||||
home: "/home/coder",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "LowercaseHomeRootPlan",
|
||||
requested: "/home/coder/plan.md",
|
||||
home: "/home/coder",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "MixedCaseHomeRootPlan",
|
||||
requested: "/home/coder/Plan.md",
|
||||
home: "/home/coder",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "UppercaseExtension",
|
||||
requested: "/home/coder/PLAN.MD",
|
||||
home: "/home/coder",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "CustomHomeRootPlan",
|
||||
requested: "/Users/dev/plan.md",
|
||||
home: "/Users/dev",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "NestedPlanUnderHome",
|
||||
requested: "/home/coder/myproject/plan.md",
|
||||
home: "/home/coder",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "PerChatPlanPath",
|
||||
requested: "/home/coder/.coder/plans/PLAN-123e4567-e89b-12d3-a456-426614174000.md",
|
||||
home: "/home/coder",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "DifferentFilename",
|
||||
requested: "/home/coder/README.md",
|
||||
home: "/home/coder",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "DifferentExtension",
|
||||
requested: "/home/coder/plan.txt",
|
||||
home: "/home/coder",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "EmptyPath",
|
||||
requested: "",
|
||||
home: "/home/coder",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "DifferentHomeMismatch",
|
||||
requested: "/home/coder/plan.md",
|
||||
home: "/Users/dev",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range tests {
|
||||
testCase := testCase
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := chattool.LooksLikeHomePlanFile(testCase.requested, testCase.home)
|
||||
|
||||
require.Equal(t, testCase.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package chattool
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
func rejectSharedPlanPath(
|
||||
requestedPath string,
|
||||
home string,
|
||||
planPath string,
|
||||
planPathErr error,
|
||||
) (fantasy.ToolResponse, bool) {
|
||||
if !looksLikeLegacyHomePlanPath(requestedPath, home) {
|
||||
return fantasy.ToolResponse{}, false
|
||||
}
|
||||
|
||||
return fantasy.NewTextErrorResponse(sharedPlanPathMessage(planPath, planPathErr)), true
|
||||
}
|
||||
|
||||
func looksLikeLegacyHomePlanPath(requestedPath, home string) bool {
|
||||
if strings.TrimSpace(home) == "" {
|
||||
return IsLegacySharedPlanPath(requestedPath)
|
||||
}
|
||||
|
||||
return LooksLikeHomePlanFile(requestedPath, home)
|
||||
}
|
||||
|
||||
func sharedPlanPathMessage(planPath string, err error) string {
|
||||
if err == nil && strings.TrimSpace(planPath) != "" {
|
||||
return fmt.Sprintf(
|
||||
"the shared plan path %s is no longer supported; use the chat-specific plan path: %s",
|
||||
LegacySharedPlanPath,
|
||||
planPath,
|
||||
)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"the shared plan path %s is no longer supported; use the chat-specific plan path provided in your instructions",
|
||||
LegacySharedPlanPath,
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const maxProposePlanSize = 32 * 1024 // 32 KiB
|
||||
// ProposePlanOptions configures the propose_plan tool.
|
||||
type ProposePlanOptions struct {
|
||||
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
|
||||
PlanPath func(context.Context) (chatPath string, home string, err error)
|
||||
StoreFile func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error)
|
||||
}
|
||||
|
||||
@@ -31,8 +32,9 @@ 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.",
|
||||
"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 to the plan. Important: use the chat-specific plan path from your runtime instructions, not a generic path like PLAN.md in the home directory. "+
|
||||
"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
|
||||
@@ -44,7 +46,7 @@ func ProposePlan(options ProposePlanOptions) fantasy.AgentTool {
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
return executeProposePlanTool(ctx, conn, args, options.StoreFile)
|
||||
return executeProposePlanTool(ctx, conn, args, options.PlanPath, options.StoreFile)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -53,16 +55,24 @@ func executeProposePlanTool(
|
||||
ctx context.Context,
|
||||
conn workspacesdk.AgentConn,
|
||||
args ProposePlanArgs,
|
||||
resolvePlanPath func(context.Context) (chatPath string, home string, err error),
|
||||
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
|
||||
return fantasy.NewTextErrorResponse("path is required (use the chat-specific plan path from your instructions)"), nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".md") {
|
||||
return fantasy.NewTextErrorResponse("path must end with .md"), nil
|
||||
}
|
||||
|
||||
if resolvePlanPath != nil && looksLikePlanFileName(path) {
|
||||
chatPath, home, err := resolvePlanPath(ctx)
|
||||
if resp, rejected := rejectSharedPlanPath(path, home, chatPath, err); rejected {
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
rc, _, err := conn.ReadFile(ctx, path, 0, maxProposePlanSize+1)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
|
||||
@@ -135,7 +135,16 @@ func TestProposePlan(t *testing.T) {
|
||||
Return(io.NopCloser(strings.NewReader("# Plan\n\nContent")), "text/markdown", nil)
|
||||
|
||||
storeFile, stored := fakeStoreFile(t)
|
||||
tool := newProposePlanTool(t, mockConn, storeFile)
|
||||
planPathCalled := false
|
||||
tool := newProposePlanToolWithPlanPath(
|
||||
t,
|
||||
mockConn,
|
||||
storeFile,
|
||||
func(context.Context) (string, string, error) {
|
||||
planPathCalled = true
|
||||
return "", "", xerrors.New("should not be called")
|
||||
},
|
||||
)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
@@ -143,6 +152,7 @@ func TestProposePlan(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
assert.True(t, planPathCalled)
|
||||
|
||||
result := decodeProposePlanResponse(t, resp)
|
||||
assert.True(t, result.OK)
|
||||
@@ -154,6 +164,40 @@ func TestProposePlan(t *testing.T) {
|
||||
assert.NotContains(t, resp.Content, "content")
|
||||
})
|
||||
|
||||
t.Run("NestedPlanPathUnderHomeIsAllowed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
mockConn.EXPECT().
|
||||
ReadFile(gomock.Any(), "/home/coder/myproject/plan.md", int64(0), int64(32*1024+1)).
|
||||
Return(io.NopCloser(strings.NewReader("# Nested Plan")), "text/markdown", nil)
|
||||
|
||||
storeFile, stored := fakeStoreFile(t)
|
||||
planPathCalled := false
|
||||
tool := newProposePlanToolWithPlanPath(
|
||||
t,
|
||||
mockConn,
|
||||
storeFile,
|
||||
func(context.Context) (string, string, error) {
|
||||
planPathCalled = true
|
||||
return "/home/coder/.coder/plans/PLAN-chat.md", "/home/coder", nil
|
||||
},
|
||||
)
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"/home/coder/myproject/plan.md"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
assert.True(t, planPathCalled)
|
||||
|
||||
result := decodeProposePlanResponse(t, resp)
|
||||
assert.True(t, result.OK)
|
||||
assert.Equal(t, "/home/coder/myproject/plan.md", result.Path)
|
||||
assert.Equal(t, []byte("# Nested Plan"), *stored)
|
||||
})
|
||||
t.Run("FileNotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
@@ -218,6 +262,60 @@ func TestProposePlan(t *testing.T) {
|
||||
assert.Contains(t, resp.Content, "storage unavailable")
|
||||
})
|
||||
|
||||
t.Run("RejectsSharedPlanPathWithResolvedPath", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
tool := newProposePlanToolWithPlanPath(
|
||||
t,
|
||||
mockConn,
|
||||
storeFile,
|
||||
func(context.Context) (string, string, error) {
|
||||
return "/home/coder/.coder/plans/PLAN-chat.md", "/home/coder", nil
|
||||
},
|
||||
)
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"` + chattool.LegacySharedPlanPath + `"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Equal(
|
||||
t,
|
||||
sharedPlanPathResolvedMessage("/home/coder/.coder/plans/PLAN-chat.md"),
|
||||
resp.Content,
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("RejectsSharedPlanPathWhenResolverFails", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
tool := newProposePlanToolWithPlanPath(
|
||||
t,
|
||||
mockConn,
|
||||
storeFile,
|
||||
func(context.Context) (string, string, error) {
|
||||
return "", "", xerrors.New("workspace unavailable")
|
||||
},
|
||||
)
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "propose_plan",
|
||||
Input: `{"path":"` + chattool.LegacySharedPlanPath + `"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Equal(t, sharedPlanPathFallbackMessage(), resp.Content)
|
||||
})
|
||||
|
||||
t.Run("WorkspaceConnectionError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
storeFile, _ := fakeStoreFile(t)
|
||||
@@ -278,16 +376,37 @@ 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 newProposePlanToolWithPlanPath(t, mockConn, storeFile, nil)
|
||||
}
|
||||
|
||||
func newProposePlanToolWithPlanPath(
|
||||
t *testing.T,
|
||||
mockConn *agentconnmock.MockAgentConn,
|
||||
storeFile func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error),
|
||||
planPath func(context.Context) (string, string, error),
|
||||
) fantasy.AgentTool {
|
||||
t.Helper()
|
||||
return chattool.ProposePlan(chattool.ProposePlanOptions{
|
||||
GetWorkspaceConn: func(_ context.Context) (workspacesdk.AgentConn, error) {
|
||||
return mockConn, nil
|
||||
},
|
||||
PlanPath: planPath,
|
||||
StoreFile: storeFile,
|
||||
})
|
||||
}
|
||||
|
||||
func sharedPlanPathResolvedMessage(planPath string) string {
|
||||
return "the shared plan path " + chattool.LegacySharedPlanPath +
|
||||
" is no longer supported; use the chat-specific plan path: " + planPath
|
||||
}
|
||||
|
||||
func sharedPlanPathFallbackMessage() string {
|
||||
return "the shared plan path " + chattool.LegacySharedPlanPath +
|
||||
" is no longer supported; use the chat-specific plan path provided in your instructions"
|
||||
}
|
||||
|
||||
func fakeStoreFile(t *testing.T) (func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error), *[]byte) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
type WriteFileOptions struct {
|
||||
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
|
||||
PlanPath func(context.Context) (chatPath string, home string, err error)
|
||||
}
|
||||
|
||||
type WriteFileArgs struct {
|
||||
@@ -30,7 +31,7 @@ func WriteFile(options WriteFileOptions) fantasy.AgentTool {
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
return executeWriteFileTool(ctx, conn, args)
|
||||
return executeWriteFileTool(ctx, conn, args, options.PlanPath)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -39,11 +40,19 @@ func executeWriteFileTool(
|
||||
ctx context.Context,
|
||||
conn workspacesdk.AgentConn,
|
||||
args WriteFileArgs,
|
||||
resolvePlanPath func(context.Context) (chatPath string, home string, err error),
|
||||
) (fantasy.ToolResponse, error) {
|
||||
if args.Path == "" {
|
||||
return fantasy.NewTextErrorResponse("path is required"), nil
|
||||
}
|
||||
|
||||
if resolvePlanPath != nil && looksLikePlanFileName(args.Path) {
|
||||
chatPath, home, err := resolvePlanPath(ctx)
|
||||
if resp, rejected := rejectSharedPlanPath(args.Path, home, chatPath, err); rejected {
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := conn.WriteFile(ctx, args.Path, strings.NewReader(args.Content)); err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package chattool_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestWriteFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("RejectsHomeRootPlanVariantsWhenPlanPathIsConfigured", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
requested string
|
||||
home string
|
||||
}{
|
||||
{
|
||||
name: "ExactLegacyPath",
|
||||
requested: chattool.LegacySharedPlanPath,
|
||||
home: "/home/coder",
|
||||
},
|
||||
{
|
||||
name: "LowercasePlanAtHomeRoot",
|
||||
requested: "/home/coder/plan.md",
|
||||
home: "/home/coder",
|
||||
},
|
||||
{
|
||||
name: "MixedCasePlanAtHomeRoot",
|
||||
requested: "/home/coder/Plan.md",
|
||||
home: "/home/coder",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range tests {
|
||||
testCase := testCase
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
tool := chattool.WriteFile(chattool.WriteFileOptions{
|
||||
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
|
||||
return mockConn, nil
|
||||
},
|
||||
PlanPath: func(context.Context) (string, string, error) {
|
||||
return "/home/coder/.coder/plans/PLAN-chat.md", testCase.home, nil
|
||||
},
|
||||
})
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "write_file",
|
||||
Input: `{"path":"` + testCase.requested + `","content":"# Plan"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.IsError)
|
||||
assert.Equal(
|
||||
t,
|
||||
sharedPlanPathResolvedMessage("/home/coder/.coder/plans/PLAN-chat.md"),
|
||||
resp.Content,
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AllowsNonSharedPath", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
mockConn.EXPECT().
|
||||
WriteFile(gomock.Any(), "/home/dev/my-plan.md", gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, path string, reader io.Reader) error {
|
||||
data, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "/home/dev/my-plan.md", path)
|
||||
require.Equal(t, "# Plan", string(data))
|
||||
return nil
|
||||
})
|
||||
|
||||
planPathCalled := false
|
||||
tool := chattool.WriteFile(chattool.WriteFileOptions{
|
||||
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
|
||||
return mockConn, nil
|
||||
},
|
||||
PlanPath: func(context.Context) (string, string, error) {
|
||||
planPathCalled = true
|
||||
return "", "", xerrors.New("should not be called")
|
||||
},
|
||||
})
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "write_file",
|
||||
Input: `{"path":"/home/dev/my-plan.md","content":"# Plan"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
assert.False(t, planPathCalled)
|
||||
assert.Equal(t, `{"ok":true}`, strings.TrimSpace(resp.Content))
|
||||
})
|
||||
|
||||
t.Run("AllowsSharedPlanPathWhenPlanPathIsNil", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
mockConn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
mockConn.EXPECT().
|
||||
WriteFile(gomock.Any(), chattool.LegacySharedPlanPath, gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, _ string, reader io.Reader) error {
|
||||
data, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "# Plan", string(data))
|
||||
return nil
|
||||
})
|
||||
|
||||
tool := chattool.WriteFile(chattool.WriteFileOptions{
|
||||
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
|
||||
return mockConn, nil
|
||||
},
|
||||
})
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "write_file",
|
||||
Input: `{"path":"` + chattool.LegacySharedPlanPath + `","content":"# Plan"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
})
|
||||
}
|
||||
@@ -88,11 +88,11 @@ Propose a plan when:
|
||||
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).
|
||||
2. Use write_file to create a Markdown plan file at the chat-specific plan path provided in your instructions.
|
||||
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.
|
||||
4. Call propose_plan with the absolute plan file path from your instructions 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.
|
||||
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>`
|
||||
|
||||
+22
-14
@@ -35,6 +35,10 @@ const samplePlan = [
|
||||
"> **Note**: Based on [RFC 6749](https://tools.ietf.org/html/rfc6749).",
|
||||
].join("\n");
|
||||
|
||||
const defaultPlanPath =
|
||||
"/home/coder/.coder/plans/PLAN-a1b2c3d4-e5f6-7890-abcd-ef1234567890.md";
|
||||
const defaultPlanFilename = defaultPlanPath.split("/").pop() ?? "PLAN.md";
|
||||
|
||||
const meta: Meta<typeof Tool> = {
|
||||
title: "pages/AgentsPage/ChatElements/tools/ProposePlan",
|
||||
component: Tool,
|
||||
@@ -54,20 +58,22 @@ export default meta;
|
||||
type Story = StoryObj<typeof Tool>;
|
||||
|
||||
export const Running: Story = {
|
||||
args: { status: "running", args: { path: "/home/coder/PLAN.md" } },
|
||||
args: { status: "running", args: { path: defaultPlanPath } },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText(/Proposing PLAN\.md/)).toBeInTheDocument();
|
||||
expect(
|
||||
canvas.getByText(`Proposing ${defaultPlanFilename}…`),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const Completed: Story = {
|
||||
args: {
|
||||
status: "completed",
|
||||
args: { path: "/home/coder/PLAN.md" },
|
||||
args: { path: defaultPlanPath },
|
||||
result: {
|
||||
ok: true,
|
||||
path: "/home/coder/PLAN.md",
|
||||
path: defaultPlanPath,
|
||||
kind: "plan",
|
||||
file_id: "test-file-id-completed",
|
||||
media_type: "text/markdown",
|
||||
@@ -138,12 +144,14 @@ export const ErrorState: Story = {
|
||||
args: {
|
||||
status: "completed",
|
||||
isError: true,
|
||||
args: { path: "/home/coder/PLAN.md" },
|
||||
args: { path: defaultPlanPath },
|
||||
result: "Failed to read file: file not found",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText(/Proposed PLAN\.md/)).toBeInTheDocument();
|
||||
expect(
|
||||
canvas.getByText(`Proposed ${defaultPlanFilename}`),
|
||||
).toBeInTheDocument();
|
||||
expect(canvas.getByLabelText("Error")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -151,10 +159,10 @@ export const ErrorState: Story = {
|
||||
export const EmptyContent: Story = {
|
||||
args: {
|
||||
status: "completed",
|
||||
args: { path: "/home/coder/PLAN.md" },
|
||||
args: { path: defaultPlanPath },
|
||||
result: {
|
||||
ok: true,
|
||||
path: "/home/coder/PLAN.md",
|
||||
path: defaultPlanPath,
|
||||
kind: "plan",
|
||||
file_id: "test-file-id-empty-content",
|
||||
media_type: "text/markdown",
|
||||
@@ -172,10 +180,10 @@ export const EmptyContent: Story = {
|
||||
export const FileIDLoading: Story = {
|
||||
args: {
|
||||
status: "completed",
|
||||
args: { path: "/home/coder/PLAN.md" },
|
||||
args: { path: defaultPlanPath },
|
||||
result: {
|
||||
ok: true,
|
||||
path: "/home/coder/PLAN.md",
|
||||
path: defaultPlanPath,
|
||||
kind: "plan",
|
||||
file_id: "test-file-id-loading",
|
||||
media_type: "text/markdown",
|
||||
@@ -195,10 +203,10 @@ export const FileIDLoading: Story = {
|
||||
export const FileIDCompleted: Story = {
|
||||
args: {
|
||||
status: "completed",
|
||||
args: { path: "/home/coder/PLAN.md" },
|
||||
args: { path: defaultPlanPath },
|
||||
result: {
|
||||
ok: true,
|
||||
path: "/home/coder/PLAN.md",
|
||||
path: defaultPlanPath,
|
||||
kind: "plan",
|
||||
file_id: "test-file-id-success",
|
||||
media_type: "text/markdown",
|
||||
@@ -216,10 +224,10 @@ export const FileIDCompleted: Story = {
|
||||
export const FileIDFetchError: Story = {
|
||||
args: {
|
||||
status: "completed",
|
||||
args: { path: "/home/coder/PLAN.md" },
|
||||
args: { path: defaultPlanPath },
|
||||
result: {
|
||||
ok: true,
|
||||
path: "/home/coder/PLAN.md",
|
||||
path: defaultPlanPath,
|
||||
kind: "plan",
|
||||
file_id: "test-file-id-error",
|
||||
media_type: "text/markdown",
|
||||
|
||||
Reference in New Issue
Block a user