Files
coder/coderd/x/chatd/chattool/planpath_test.go
T
Michael Suchacz a554de372a fix: use per-chat plan file paths (#24268)
> This PR was authored by Mux on behalf of Mike.

Chats sharing one workspace (e.g. sibling subagents) all wrote to
`/home/coder/PLAN.md`, causing plan file collisions. This change derives
a unique plan path per chat from the workspace home directory and chat
ID.

## Changes

* `write_file`, `edit_files`, and `propose_plan` reject any `plan.md`
variant (case-insensitive) at the workspace home root, with a clear
error pointing to the chat-specific path.
* Root chats receive a `<plan-file-path>` block inlined in the main
system prompt with the concrete path.
* Prompt and tool descriptions no longer hardcode `/home/coder/PLAN.md`.
* Plan path handling is POSIX-only (forward-slash), relying on the
contract that workspace agent paths are normalized before reaching
chatd.
* Updated `ProposePlanTool.stories.tsx` to use per-chat path examples.
* Full test coverage for plan path detection, legacy-path rejection in
all three tools, inline prompt rendering, and fallback behavior.
2026-04-14 10:50:40 +02:00

220 lines
4.8 KiB
Go

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 {
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 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 {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
got := chattool.LooksLikeHomePlanFile(testCase.requested, testCase.home)
require.Equal(t, testCase.want, got)
})
}
}