mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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.
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user