feat: add plan mode with restricted tool boundary (#24236)

> This PR was authored by Mux on behalf of Mike.

## Summary
- add persistent plan mode for chats and the chat-specific plan file
flow
- add structured planning tools such as `ask_user_question` and
`propose_plan`
- keep `write_file` and `edit_files` constrained to the chat-specific
plan file during plan turns
- allow shell exploration in plan mode, including subagents, via
`execute` and `process_output`
- block implementation-oriented, provider-native, MCP, dynamic, and
computer-use tools during plan turns
- update the chat UI, tests, and docs for the new planning flow
This commit is contained in:
Michael Suchacz
2026-04-16 11:12:01 +02:00
committed by GitHub
parent e996f6d44b
commit 1cf0354f72
76 changed files with 6398 additions and 889 deletions
+2
View File
@@ -1181,6 +1181,8 @@ func New(options *Options) *API {
r.Route("/config", func(r chi.Router) {
r.Get("/system-prompt", api.getChatSystemPrompt)
r.Put("/system-prompt", api.putChatSystemPrompt)
r.Get("/plan-mode-instructions", api.getChatPlanModeInstructions)
r.Put("/plan-mode-instructions", api.putChatPlanModeInstructions)
r.Get("/desktop-enabled", api.getChatDesktopEnabled)
r.Put("/desktop-enabled", api.putChatDesktopEnabled)
r.Get("/user-prompt", api.getUserChatCustomPrompt)
+3
View File
@@ -1601,6 +1601,9 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database
if c.LastError.Valid {
chat.LastError = &c.LastError.String
}
if c.PlanMode.Valid {
chat.PlanMode = codersdk.ChatPlanMode(c.PlanMode.ChatPlanMode)
}
if c.ParentChatID.Valid {
parentChatID := c.ParentChatID.UUID
chat.ParentChatID = &parentChatID
+1
View File
@@ -817,6 +817,7 @@ func TestChat_AllFieldsPopulated(t *testing.T) {
UpdatedAt: now,
Archived: true,
PinOrder: 1,
PlanMode: database.NullChatPlanMode{ChatPlanMode: database.ChatPlanModePlan, Valid: true},
MCPServerIDs: []uuid.UUID{uuid.New()},
Labels: database.StringMap{"env": "prod"},
LastInjectedContext: pqtype.NullRawMessage{
+25
View File
@@ -2798,6 +2798,13 @@ func (q *querier) GetChatModelConfigsForTelemetry(ctx context.Context) ([]databa
return q.db.GetChatModelConfigsForTelemetry(ctx)
}
func (q *querier) GetChatPlanModeInstructions(ctx context.Context) (string, error) {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
return "", err
}
return q.db.GetChatPlanModeInstructions(ctx)
}
func (q *querier) GetChatProviderByID(ctx context.Context, id uuid.UUID) (database.ChatProvider, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
return database.ChatProvider{}, err
@@ -6090,6 +6097,17 @@ func (q *querier) UpdateChatPinOrder(ctx context.Context, arg database.UpdateCha
return q.db.UpdateChatPinOrder(ctx, arg)
}
func (q *querier) UpdateChatPlanModeByID(ctx context.Context, arg database.UpdateChatPlanModeByIDParams) (database.Chat, error) {
chat, err := q.db.GetChatByID(ctx, arg.ID)
if err != nil {
return database.Chat{}, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return database.Chat{}, err
}
return q.db.UpdateChatPlanModeByID(ctx, arg)
}
func (q *querier) UpdateChatProvider(ctx context.Context, arg database.UpdateChatProviderParams) (database.ChatProvider, error) {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
return database.ChatProvider{}, err
@@ -7267,6 +7285,13 @@ func (q *querier) UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, incl
return q.db.UpsertChatIncludeDefaultSystemPrompt(ctx, includeDefaultSystemPrompt)
}
func (q *querier) UpsertChatPlanModeInstructions(ctx context.Context, value string) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
return err
}
return q.db.UpsertChatPlanModeInstructions(ctx, value)
}
func (q *querier) UpsertChatRetentionDays(ctx context.Context, retentionDays int32) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
return err
+18
View File
@@ -834,6 +834,10 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().GetChatDesktopEnabled(gomock.Any()).Return(false, nil).AnyTimes()
check.Args().Asserts()
}))
s.Run("GetChatPlanModeInstructions", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetChatPlanModeInstructions(gomock.Any()).Return("", nil).AnyTimes()
check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
}))
s.Run("GetChatTemplateAllowlist", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetChatTemplateAllowlist(gomock.Any()).Return("", nil).AnyTimes()
check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead)
@@ -949,6 +953,16 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().UpdateChatLastModelConfigByID(gomock.Any(), arg).Return(chat, nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat)
}))
s.Run("UpdateChatPlanModeByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.UpdateChatPlanModeByIDParams{
ID: chat.ID,
PlanMode: database.NullChatPlanMode{ChatPlanMode: database.ChatPlanModePlan, Valid: true},
}
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().UpdateChatPlanModeByID(gomock.Any(), arg).Return(chat, nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat)
}))
s.Run("UpdateChatStatusPreserveUpdatedAt", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.UpdateChatStatusPreserveUpdatedAtParams{
@@ -1115,6 +1129,10 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().UpsertChatDesktopEnabled(gomock.Any(), false).Return(nil).AnyTimes()
check.Args(false).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
}))
s.Run("UpsertChatPlanModeInstructions", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().UpsertChatPlanModeInstructions(gomock.Any(), "").Return(nil).AnyTimes()
check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
}))
s.Run("UpsertChatTemplateAllowlist", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().UpsertChatTemplateAllowlist(gomock.Any(), "").Return(nil).AnyTimes()
check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
+24
View File
@@ -1312,6 +1312,14 @@ func (m queryMetricsStore) GetChatModelConfigsForTelemetry(ctx context.Context)
return r0, r1
}
func (m queryMetricsStore) GetChatPlanModeInstructions(ctx context.Context) (string, error) {
start := time.Now()
r0, r1 := m.s.GetChatPlanModeInstructions(ctx)
m.queryLatencies.WithLabelValues("GetChatPlanModeInstructions").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatPlanModeInstructions").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatProviderByID(ctx context.Context, id uuid.UUID) (database.ChatProvider, error) {
start := time.Now()
r0, r1 := m.s.GetChatProviderByID(ctx, id)
@@ -4376,6 +4384,14 @@ func (m queryMetricsStore) UpdateChatPinOrder(ctx context.Context, arg database.
return r0
}
func (m queryMetricsStore) UpdateChatPlanModeByID(ctx context.Context, arg database.UpdateChatPlanModeByIDParams) (database.Chat, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatPlanModeByID(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatPlanModeByID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatPlanModeByID").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateChatProvider(ctx context.Context, arg database.UpdateChatProviderParams) (database.ChatProvider, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatProvider(ctx, arg)
@@ -5184,6 +5200,14 @@ func (m queryMetricsStore) UpsertChatIncludeDefaultSystemPrompt(ctx context.Cont
return r0
}
func (m queryMetricsStore) UpsertChatPlanModeInstructions(ctx context.Context, value string) error {
start := time.Now()
r0 := m.s.UpsertChatPlanModeInstructions(ctx, value)
m.queryLatencies.WithLabelValues("UpsertChatPlanModeInstructions").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatPlanModeInstructions").Inc()
return r0
}
func (m queryMetricsStore) UpsertChatRetentionDays(ctx context.Context, retentionDays int32) error {
start := time.Now()
r0 := m.s.UpsertChatRetentionDays(ctx, retentionDays)
+44
View File
@@ -2417,6 +2417,21 @@ func (mr *MockStoreMockRecorder) GetChatModelConfigsForTelemetry(ctx any) *gomoc
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatModelConfigsForTelemetry", reflect.TypeOf((*MockStore)(nil).GetChatModelConfigsForTelemetry), ctx)
}
// GetChatPlanModeInstructions mocks base method.
func (m *MockStore) GetChatPlanModeInstructions(ctx context.Context) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatPlanModeInstructions", ctx)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatPlanModeInstructions indicates an expected call of GetChatPlanModeInstructions.
func (mr *MockStoreMockRecorder) GetChatPlanModeInstructions(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatPlanModeInstructions", reflect.TypeOf((*MockStore)(nil).GetChatPlanModeInstructions), ctx)
}
// GetChatProviderByID mocks base method.
func (m *MockStore) GetChatProviderByID(ctx context.Context, id uuid.UUID) (database.ChatProvider, error) {
m.ctrl.T.Helper()
@@ -8282,6 +8297,21 @@ func (mr *MockStoreMockRecorder) UpdateChatPinOrder(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatPinOrder", reflect.TypeOf((*MockStore)(nil).UpdateChatPinOrder), ctx, arg)
}
// UpdateChatPlanModeByID mocks base method.
func (m *MockStore) UpdateChatPlanModeByID(ctx context.Context, arg database.UpdateChatPlanModeByIDParams) (database.Chat, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateChatPlanModeByID", ctx, arg)
ret0, _ := ret[0].(database.Chat)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateChatPlanModeByID indicates an expected call of UpdateChatPlanModeByID.
func (mr *MockStoreMockRecorder) UpdateChatPlanModeByID(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatPlanModeByID", reflect.TypeOf((*MockStore)(nil).UpdateChatPlanModeByID), ctx, arg)
}
// UpdateChatProvider mocks base method.
func (m *MockStore) UpdateChatProvider(ctx context.Context, arg database.UpdateChatProviderParams) (database.ChatProvider, error) {
m.ctrl.T.Helper()
@@ -9741,6 +9771,20 @@ func (mr *MockStoreMockRecorder) UpsertChatIncludeDefaultSystemPrompt(ctx, inclu
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatIncludeDefaultSystemPrompt", reflect.TypeOf((*MockStore)(nil).UpsertChatIncludeDefaultSystemPrompt), ctx, includeDefaultSystemPrompt)
}
// UpsertChatPlanModeInstructions mocks base method.
func (m *MockStore) UpsertChatPlanModeInstructions(ctx context.Context, value string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertChatPlanModeInstructions", ctx, value)
ret0, _ := ret[0].(error)
return ret0
}
// UpsertChatPlanModeInstructions indicates an expected call of UpsertChatPlanModeInstructions.
func (mr *MockStoreMockRecorder) UpsertChatPlanModeInstructions(ctx, value any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatPlanModeInstructions", reflect.TypeOf((*MockStore)(nil).UpsertChatPlanModeInstructions), ctx, value)
}
// UpsertChatRetentionDays mocks base method.
func (m *MockStore) UpsertChatRetentionDays(ctx context.Context, retentionDays int32) error {
m.ctrl.T.Helper()
+6 -1
View File
@@ -287,6 +287,10 @@ CREATE TYPE chat_mode AS ENUM (
'computer_use'
);
CREATE TYPE chat_plan_mode AS ENUM (
'plan'
);
CREATE TYPE chat_status AS ENUM (
'waiting',
'pending',
@@ -1470,7 +1474,8 @@ CREATE TABLE chats (
last_read_message_id bigint,
last_injected_context jsonb,
dynamic_tools jsonb,
organization_id uuid NOT NULL
organization_id uuid NOT NULL,
plan_mode chat_plan_mode
);
CREATE TABLE connection_logs (
@@ -0,0 +1,2 @@
ALTER TABLE chats DROP COLUMN plan_mode;
DROP TYPE chat_plan_mode;
@@ -0,0 +1,2 @@
CREATE TYPE chat_plan_mode AS ENUM ('plan');
ALTER TABLE chats ADD COLUMN plan_mode chat_plan_mode;
+1
View File
@@ -800,6 +800,7 @@ func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams,
&i.Chat.LastInjectedContext,
&i.Chat.DynamicTools,
&i.Chat.OrganizationID,
&i.Chat.PlanMode,
&i.HasUnread); err != nil {
return nil, err
}
+56
View File
@@ -1287,6 +1287,61 @@ func AllChatModeValues() []ChatMode {
}
}
type ChatPlanMode string
const (
ChatPlanModePlan ChatPlanMode = "plan"
)
func (e *ChatPlanMode) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = ChatPlanMode(s)
case string:
*e = ChatPlanMode(s)
default:
return fmt.Errorf("unsupported scan type for ChatPlanMode: %T", src)
}
return nil
}
type NullChatPlanMode struct {
ChatPlanMode ChatPlanMode `json:"chat_plan_mode"`
Valid bool `json:"valid"` // Valid is true if ChatPlanMode is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullChatPlanMode) Scan(value interface{}) error {
if value == nil {
ns.ChatPlanMode, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.ChatPlanMode.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullChatPlanMode) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.ChatPlanMode), nil
}
func (e ChatPlanMode) Valid() bool {
switch e {
case ChatPlanModePlan:
return true
}
return false
}
func AllChatPlanModeValues() []ChatPlanMode {
return []ChatPlanMode{
ChatPlanModePlan,
}
}
type ChatStatus string
const (
@@ -4247,6 +4302,7 @@ type Chat struct {
LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"`
DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
}
type ChatDebugRun struct {
+3
View File
@@ -306,6 +306,7 @@ type sqlcQuerier interface {
GetChatModelConfigs(ctx context.Context) ([]ChatModelConfig, error)
// Returns all model configurations for telemetry snapshot collection.
GetChatModelConfigsForTelemetry(ctx context.Context) ([]GetChatModelConfigsForTelemetryRow, error)
GetChatPlanModeInstructions(ctx context.Context) (string, error)
GetChatProviderByID(ctx context.Context, id uuid.UUID) (ChatProvider, error)
GetChatProviderByProvider(ctx context.Context, provider string) (ChatProvider, error)
GetChatProviders(ctx context.Context) ([]ChatProvider, error)
@@ -982,6 +983,7 @@ type sqlcQuerier interface {
UpdateChatMessageByID(ctx context.Context, arg UpdateChatMessageByIDParams) (ChatMessage, error)
UpdateChatModelConfig(ctx context.Context, arg UpdateChatModelConfigParams) (ChatModelConfig, error)
UpdateChatPinOrder(ctx context.Context, arg UpdateChatPinOrderParams) error
UpdateChatPlanModeByID(ctx context.Context, arg UpdateChatPlanModeByIDParams) (Chat, error)
UpdateChatProvider(ctx context.Context, arg UpdateChatProviderParams) (ChatProvider, error)
UpdateChatStatus(ctx context.Context, arg UpdateChatStatusParams) (Chat, error)
UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg UpdateChatStatusPreserveUpdatedAtParams) (Chat, error)
@@ -1098,6 +1100,7 @@ type sqlcQuerier interface {
UpsertChatDiffStatus(ctx context.Context, arg UpsertChatDiffStatusParams) (ChatDiffStatus, error)
UpsertChatDiffStatusReference(ctx context.Context, arg UpsertChatDiffStatusReferenceParams) (ChatDiffStatus, error)
UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, includeDefaultSystemPrompt bool) error
UpsertChatPlanModeInstructions(ctx context.Context, value string) error
UpsertChatRetentionDays(ctx context.Context, retentionDays int32) error
UpsertChatSystemPrompt(ctx context.Context, value string) error
UpsertChatTemplateAllowlist(ctx context.Context, templateAllowlist string) error
+121 -25
View File
@@ -4816,7 +4816,7 @@ WHERE
$3::int
)
RETURNING
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
`
type AcquireChatsParams struct {
@@ -4862,6 +4862,7 @@ func (q *sqlQuerier) AcquireChats(ctx context.Context, arg AcquireChatsParams) (
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
); err != nil {
return nil, err
}
@@ -5000,9 +5001,9 @@ WITH chats AS (
UPDATE chats
SET archived = true, pin_order = 0, updated_at = NOW()
WHERE id = $1::uuid OR root_chat_id = $1::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
FROM chats
ORDER BY (id = $1::uuid) DESC, created_at ASC, id ASC
`
@@ -5042,6 +5043,7 @@ func (q *sqlQuerier) ArchiveChatByID(ctx context.Context, id uuid.UUID) ([]Chat,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
); err != nil {
return nil, err
}
@@ -5191,7 +5193,7 @@ func (q *sqlQuerier) DeleteOldChats(ctx context.Context, arg DeleteOldChatsParam
}
const getActiveChatsByAgentID = `-- name: GetActiveChatsByAgentID :many
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
FROM chats
WHERE agent_id = $1::uuid
AND archived = false
@@ -5237,6 +5239,7 @@ func (q *sqlQuerier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.U
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
); err != nil {
return nil, err
}
@@ -5253,7 +5256,7 @@ func (q *sqlQuerier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.U
const getChatByID = `-- name: GetChatByID :one
SELECT
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
FROM
chats
WHERE
@@ -5289,12 +5292,13 @@ func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
)
return i, err
}
const getChatByIDForUpdate = `-- name: GetChatByIDForUpdate :one
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id FROM chats WHERE id = $1::uuid FOR UPDATE
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode FROM chats WHERE id = $1::uuid FOR UPDATE
`
func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Chat, error) {
@@ -5326,6 +5330,7 @@ func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Ch
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
)
return i, err
}
@@ -6380,7 +6385,7 @@ func (q *sqlQuerier) GetChatUsageLimitUserOverride(ctx context.Context, userID u
const getChats = `-- name: GetChats :many
SELECT
chats.id, chats.owner_id, chats.workspace_id, chats.title, chats.status, chats.worker_id, chats.started_at, chats.heartbeat_at, chats.created_at, chats.updated_at, chats.parent_chat_id, chats.root_chat_id, chats.last_model_config_id, chats.archived, chats.last_error, chats.mode, chats.mcp_server_ids, chats.labels, chats.build_id, chats.agent_id, chats.pin_order, chats.last_read_message_id, chats.last_injected_context, chats.dynamic_tools, chats.organization_id,
chats.id, chats.owner_id, chats.workspace_id, chats.title, chats.status, chats.worker_id, chats.started_at, chats.heartbeat_at, chats.created_at, chats.updated_at, chats.parent_chat_id, chats.root_chat_id, chats.last_model_config_id, chats.archived, chats.last_error, chats.mode, chats.mcp_server_ids, chats.labels, chats.build_id, chats.agent_id, chats.pin_order, chats.last_read_message_id, chats.last_injected_context, chats.dynamic_tools, chats.organization_id, chats.plan_mode,
EXISTS (
SELECT 1 FROM chat_messages cm
WHERE cm.chat_id = chats.id
@@ -6494,6 +6499,7 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha
&i.Chat.LastInjectedContext,
&i.Chat.DynamicTools,
&i.Chat.OrganizationID,
&i.Chat.PlanMode,
&i.HasUnread,
); err != nil {
return nil, err
@@ -6510,7 +6516,7 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha
}
const getChatsByWorkspaceIDs = `-- name: GetChatsByWorkspaceIDs :many
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
FROM chats
WHERE archived = false
AND workspace_id = ANY($1::uuid[])
@@ -6552,6 +6558,7 @@ func (q *sqlQuerier) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
); err != nil {
return nil, err
}
@@ -6679,7 +6686,7 @@ func (q *sqlQuerier) GetLastChatMessageByRole(ctx context.Context, arg GetLastCh
const getStaleChats = `-- name: GetStaleChats :many
SELECT
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
FROM
chats
WHERE
@@ -6728,6 +6735,7 @@ func (q *sqlQuerier) GetStaleChats(ctx context.Context, staleThreshold time.Time
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
); err != nil {
return nil, err
}
@@ -6816,6 +6824,7 @@ INSERT INTO chats (
last_model_config_id,
title,
mode,
plan_mode,
status,
mcp_server_ids,
labels,
@@ -6831,13 +6840,14 @@ INSERT INTO chats (
$8::uuid,
$9::text,
$10::chat_mode,
$11::chat_status,
COALESCE($12::uuid[], '{}'::uuid[]),
COALESCE($13::jsonb, '{}'::jsonb),
$14::jsonb
$11::chat_plan_mode,
$12::chat_status,
COALESCE($13::uuid[], '{}'::uuid[]),
COALESCE($14::jsonb, '{}'::jsonb),
$15::jsonb
)
RETURNING
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
`
type InsertChatParams struct {
@@ -6851,6 +6861,7 @@ type InsertChatParams struct {
LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"`
Title string `db:"title" json:"title"`
Mode NullChatMode `db:"mode" json:"mode"`
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
Status ChatStatus `db:"status" json:"status"`
MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"`
Labels pqtype.NullRawMessage `db:"labels" json:"labels"`
@@ -6869,6 +6880,7 @@ func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat
arg.LastModelConfigID,
arg.Title,
arg.Mode,
arg.PlanMode,
arg.Status,
pq.Array(arg.MCPServerIDs),
arg.Labels,
@@ -6901,6 +6913,7 @@ func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
)
return i, err
}
@@ -7429,9 +7442,9 @@ WITH chats AS (
archived = false,
updated_at = NOW()
WHERE id = $1::uuid OR root_chat_id = $1::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
FROM chats
ORDER BY (id = $1::uuid) DESC, created_at ASC, id ASC
`
@@ -7475,6 +7488,7 @@ func (q *sqlQuerier) UnarchiveChatByID(ctx context.Context, id uuid.UUID) ([]Cha
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
); err != nil {
return nil, err
}
@@ -7555,7 +7569,7 @@ UPDATE chats SET
updated_at = NOW()
WHERE
id = $3::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
`
type UpdateChatBuildAgentBindingParams struct {
@@ -7593,6 +7607,7 @@ func (q *sqlQuerier) UpdateChatBuildAgentBinding(ctx context.Context, arg Update
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
)
return i, err
}
@@ -7606,7 +7621,7 @@ SET
WHERE
id = $2::uuid
RETURNING
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
`
type UpdateChatByIDParams struct {
@@ -7643,6 +7658,7 @@ func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParam
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
)
return i, err
}
@@ -7701,7 +7717,7 @@ SET
WHERE
id = $2::uuid
RETURNING
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
`
type UpdateChatLabelsByIDParams struct {
@@ -7738,6 +7754,7 @@ func (q *sqlQuerier) UpdateChatLabelsByID(ctx context.Context, arg UpdateChatLab
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
)
return i, err
}
@@ -7747,7 +7764,7 @@ UPDATE chats SET
last_injected_context = $1::jsonb
WHERE
id = $2::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
`
type UpdateChatLastInjectedContextParams struct {
@@ -7788,6 +7805,7 @@ func (q *sqlQuerier) UpdateChatLastInjectedContext(ctx context.Context, arg Upda
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
)
return i, err
}
@@ -7801,7 +7819,7 @@ SET
WHERE
id = $2::uuid
RETURNING
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
`
type UpdateChatLastModelConfigByIDParams struct {
@@ -7838,6 +7856,7 @@ func (q *sqlQuerier) UpdateChatLastModelConfigByID(ctx context.Context, arg Upda
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
)
return i, err
}
@@ -7869,7 +7888,7 @@ SET
WHERE
id = $2::uuid
RETURNING
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
`
type UpdateChatMCPServerIDsParams struct {
@@ -7906,6 +7925,7 @@ func (q *sqlQuerier) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatM
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
)
return i, err
}
@@ -8028,6 +8048,57 @@ func (q *sqlQuerier) UpdateChatPinOrder(ctx context.Context, arg UpdateChatPinOr
return err
}
const updateChatPlanModeByID = `-- name: UpdateChatPlanModeByID :one
UPDATE
chats
SET
-- NOTE: updated_at is intentionally NOT touched here to avoid changing list ordering.
plan_mode = $1::chat_plan_mode
WHERE
id = $2::uuid
RETURNING
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
`
type UpdateChatPlanModeByIDParams struct {
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
ID uuid.UUID `db:"id" json:"id"`
}
func (q *sqlQuerier) UpdateChatPlanModeByID(ctx context.Context, arg UpdateChatPlanModeByIDParams) (Chat, error) {
row := q.db.QueryRowContext(ctx, updateChatPlanModeByID, arg.PlanMode, arg.ID)
var i Chat
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.WorkspaceID,
&i.Title,
&i.Status,
&i.WorkerID,
&i.StartedAt,
&i.HeartbeatAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentChatID,
&i.RootChatID,
&i.LastModelConfigID,
&i.Archived,
&i.LastError,
&i.Mode,
pq.Array(&i.MCPServerIDs),
&i.Labels,
&i.BuildID,
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
)
return i, err
}
const updateChatStatus = `-- name: UpdateChatStatus :one
UPDATE
chats
@@ -8041,7 +8112,7 @@ SET
WHERE
id = $6::uuid
RETURNING
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
`
type UpdateChatStatusParams struct {
@@ -8089,6 +8160,7 @@ func (q *sqlQuerier) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusP
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
)
return i, err
}
@@ -8106,7 +8178,7 @@ SET
WHERE
id = $7::uuid
RETURNING
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
`
type UpdateChatStatusPreserveUpdatedAtParams struct {
@@ -8156,6 +8228,7 @@ func (q *sqlQuerier) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
)
return i, err
}
@@ -8167,7 +8240,7 @@ UPDATE chats SET
agent_id = $3::uuid,
updated_at = NOW()
WHERE id = $4::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode
`
type UpdateChatWorkspaceBindingParams struct {
@@ -8211,6 +8284,7 @@ func (q *sqlQuerier) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateC
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
)
return i, err
}
@@ -19839,6 +19913,18 @@ func (q *sqlQuerier) GetChatIncludeDefaultSystemPrompt(ctx context.Context) (boo
return include_default_system_prompt, err
}
const getChatPlanModeInstructions = `-- name: GetChatPlanModeInstructions :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_plan_mode_instructions'), '') :: text AS plan_mode_instructions
`
func (q *sqlQuerier) GetChatPlanModeInstructions(ctx context.Context) (string, error) {
row := q.db.QueryRowContext(ctx, getChatPlanModeInstructions)
var plan_mode_instructions string
err := row.Scan(&plan_mode_instructions)
return plan_mode_instructions, err
}
const getChatRetentionDays = `-- name: GetChatRetentionDays :one
SELECT COALESCE(
(SELECT value::integer FROM site_configs
@@ -20182,6 +20268,16 @@ func (q *sqlQuerier) UpsertChatIncludeDefaultSystemPrompt(ctx context.Context, i
return err
}
const upsertChatPlanModeInstructions = `-- name: UpsertChatPlanModeInstructions :exec
INSERT INTO site_configs (key, value) VALUES ('agents_chat_plan_mode_instructions', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_plan_mode_instructions'
`
func (q *sqlQuerier) UpsertChatPlanModeInstructions(ctx context.Context, value string) error {
_, err := q.db.ExecContext(ctx, upsertChatPlanModeInstructions, value)
return err
}
const upsertChatRetentionDays = `-- name: UpsertChatRetentionDays :exec
INSERT INTO site_configs (key, value)
VALUES ('agents_chat_retention_days', CAST($1 AS integer)::text)
+13
View File
@@ -402,6 +402,7 @@ INSERT INTO chats (
last_model_config_id,
title,
mode,
plan_mode,
status,
mcp_server_ids,
labels,
@@ -417,6 +418,7 @@ INSERT INTO chats (
@last_model_config_id::uuid,
@title::text,
sqlc.narg('mode')::chat_mode,
sqlc.narg('plan_mode')::chat_plan_mode,
@status::chat_status,
COALESCE(@mcp_server_ids::uuid[], '{}'::uuid[]),
COALESCE(sqlc.narg('labels')::jsonb, '{}'::jsonb),
@@ -518,6 +520,17 @@ WHERE
RETURNING
*;
-- name: UpdateChatPlanModeByID :one
UPDATE
chats
SET
-- NOTE: updated_at is intentionally NOT touched here to avoid changing list ordering.
plan_mode = sqlc.narg('plan_mode')::chat_plan_mode
WHERE
id = @id::uuid
RETURNING
*;
-- name: UpdateChatLastModelConfigByID :one
UPDATE
chats
+8
View File
@@ -159,6 +159,14 @@ SELECT
INSERT INTO site_configs (key, value) VALUES ('agents_chat_system_prompt', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_system_prompt';
-- name: GetChatPlanModeInstructions :one
SELECT
COALESCE((SELECT value FROM site_configs WHERE key = 'agents_chat_plan_mode_instructions'), '') :: text AS plan_mode_instructions;
-- name: UpsertChatPlanModeInstructions :exec
INSERT INTO site_configs (key, value) VALUES ('agents_chat_plan_mode_instructions', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'agents_chat_plan_mode_instructions';
-- name: GetChatDesktopEnabled :one
SELECT
COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'agents_desktop_enabled'), false) :: boolean AS enable_desktop;
+221 -26
View File
@@ -379,6 +379,16 @@ func (api *API) getChatDiffStatusesByChatID(
return statusesByChatID, nil
}
func planModeToNullChatPlanMode(mode codersdk.ChatPlanMode) database.NullChatPlanMode {
if mode == "" {
return database.NullChatPlanMode{}
}
return database.NullChatPlanMode{
ChatPlanMode: database.ChatPlanMode(mode),
Valid: true,
}
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -457,6 +467,16 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
return
}
switch req.PlanMode {
case codersdk.ChatPlanModePlan, "":
// Valid.
default:
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid plan_mode value.",
})
return
}
// Validate MCP server IDs exist.
if len(req.MCPServerIDs) > 0 {
//nolint:gocritic // Need to validate MCP server IDs exist.
@@ -554,6 +574,7 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
WorkspaceID: workspaceSelection.WorkspaceID,
Title: title,
ModelConfigID: modelConfigID,
PlanMode: planModeToNullChatPlanMode(req.PlanMode),
SystemPrompt: req.SystemPrompt,
InitialUserContent: contentBlocks,
MCPServerIDs: mcpServerIDs,
@@ -1728,7 +1749,7 @@ func (api *API) watchChatDesktop(rw http.ResponseWriter, r *http.Request) {
}
// patchChat updates a chat resource. Supports updating labels,
// archiving, pinning, and pinned-chat ordering.
// workspace binding, archiving, pinning, and pinned-chat ordering.
func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
@@ -1738,6 +1759,21 @@ func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
return
}
var planModeUpdate *database.NullChatPlanMode
if req.PlanMode != nil {
switch *req.PlanMode {
case codersdk.ChatPlanModePlan, "":
// Valid.
default:
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid plan_mode value.",
})
return
}
resolvedPlanMode := planModeToNullChatPlanMode(*req.PlanMode)
planModeUpdate = &resolvedPlanMode
}
if req.Labels != nil {
if errs := httpapi.ValidateChatLabels(*req.Labels); len(errs) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
@@ -1863,6 +1899,64 @@ func (api *API) patchChat(rw http.ResponseWriter, r *http.Request) {
}
}
if req.WorkspaceID != nil {
workspaceID := uuid.NullUUID{}
workspace := database.Workspace{}
if *req.WorkspaceID != uuid.Nil {
var status int
var resp *codersdk.Response
workspaceID, workspace, status, resp = api.validateChatWorkspaceSelection(ctx, r, req.WorkspaceID)
if resp != nil {
httpapi.Write(ctx, rw, status, *resp)
return
}
if workspace.OrganizationID != chat.OrganizationID {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Workspace does not belong to this chat's organization.",
})
return
}
}
updatedChat, err := api.Database.UpdateChatWorkspaceBinding(ctx, database.UpdateChatWorkspaceBindingParams{
ID: chat.ID,
WorkspaceID: workspaceID,
BuildID: uuid.NullUUID{},
AgentID: uuid.NullUUID{},
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to update chat workspace binding.",
Detail: err.Error(),
})
return
}
chat = updatedChat
}
if planModeUpdate != nil {
updatedChat, err := api.Database.UpdateChatPlanModeByID(ctx, database.UpdateChatPlanModeByIDParams{
PlanMode: *planModeUpdate,
ID: chat.ID,
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to update chat plan mode.",
Detail: err.Error(),
})
return
}
chat = updatedChat
}
rw.WriteHeader(http.StatusNoContent)
}
@@ -1937,6 +2031,24 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
}
}
if req.PlanMode != nil {
switch *req.PlanMode {
case codersdk.ChatPlanModePlan, "":
// Valid.
default:
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid plan_mode value.",
})
return
}
}
var sendPlanMode *database.NullChatPlanMode
if req.PlanMode != nil {
resolvedPlanMode := planModeToNullChatPlanMode(*req.PlanMode)
sendPlanMode = &resolvedPlanMode
}
busyBehavior := chatd.SendMessageBusyBehaviorQueue
switch req.BusyBehavior {
case codersdk.ChatBusyBehaviorInterrupt:
@@ -1959,6 +2071,7 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
Content: contentBlocks,
ModelConfigID: req.ModelConfigID,
BusyBehavior: busyBehavior,
PlanMode: sendPlanMode,
MCPServerIDs: req.MCPServerIDs,
},
)
@@ -2929,6 +3042,46 @@ type createChatWorkspaceSelection struct {
WorkspaceID uuid.NullUUID
}
func (api *API) validateChatWorkspaceSelection(
ctx context.Context,
r *http.Request,
workspaceID *uuid.UUID,
) (
uuid.NullUUID,
database.Workspace,
int,
*codersdk.Response,
) {
if workspaceID == nil {
return uuid.NullUUID{}, database.Workspace{}, 0, nil
}
workspace, err := api.Database.GetWorkspaceByID(ctx, *workspaceID)
if err != nil {
if httpapi.Is404Error(err) {
return uuid.NullUUID{}, database.Workspace{}, http.StatusBadRequest, &codersdk.Response{
Message: "Workspace not found or you do not have access to this resource",
}
}
return uuid.NullUUID{}, database.Workspace{}, http.StatusInternalServerError, &codersdk.Response{
Message: "Failed to get workspace.",
Detail: err.Error(),
}
}
selection := uuid.NullUUID{
UUID: workspace.ID,
Valid: true,
}
if !api.Authorize(r, policy.ActionSSH, workspace) {
return uuid.NullUUID{}, database.Workspace{}, http.StatusBadRequest, &codersdk.Response{
Message: "Workspace not found or you do not have access to this resource",
}
}
return selection, workspace, 0, nil
}
func (api *API) validateCreateChatWorkspaceSelection(
ctx context.Context,
r *http.Request,
@@ -2939,39 +3092,20 @@ func (api *API) validateCreateChatWorkspaceSelection(
*codersdk.Response,
) {
selection := createChatWorkspaceSelection{}
if req.WorkspaceID == nil {
workspaceID, workspace, status, resp := api.validateChatWorkspaceSelection(ctx, r, req.WorkspaceID)
if resp != nil {
return selection, status, resp
}
selection.WorkspaceID = workspaceID
if !workspaceID.Valid {
return selection, 0, nil
}
workspace, err := api.Database.GetWorkspaceByID(ctx, *req.WorkspaceID)
if err != nil {
if httpapi.Is404Error(err) {
return selection, http.StatusBadRequest, &codersdk.Response{
Message: "Workspace not found or you do not have access to this resource",
}
}
return selection, http.StatusInternalServerError, &codersdk.Response{
Message: "Failed to get workspace.",
Detail: err.Error(),
}
}
selection.WorkspaceID = uuid.NullUUID{
UUID: workspace.ID,
Valid: true,
}
if workspace.OrganizationID != req.OrganizationID {
return selection, http.StatusBadRequest, &codersdk.Response{
Message: "Workspace does not belong to the specified organization.",
}
}
if !api.Authorize(r, policy.ActionSSH, workspace) {
return selection, http.StatusBadRequest, &codersdk.Response{
Message: "Workspace not found or you do not have access to this resource",
}
}
return selection, 0, nil
}
@@ -3151,6 +3285,67 @@ func (api *API) putChatSystemPrompt(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusNoContent)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatPlanModeInstructions(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.ResourceNotFound(rw)
return
}
instructions, err := api.Database.GetChatPlanModeInstructions(ctx)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching plan mode instructions.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatPlanModeInstructionsResponse{
PlanModeInstructions: instructions,
})
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
func (api *API) putChatPlanModeInstructions(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
// Cap the raw request body to prevent excessive memory use from
// payloads padded with invisible characters that sanitize away.
r.Body = http.MaxBytesReader(rw, r.Body, int64(2*maxSystemPromptLenBytes))
var req codersdk.UpdateChatPlanModeInstructionsRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
sanitizedInstructions := chatd.SanitizePromptText(req.PlanModeInstructions)
if len(sanitizedInstructions) > maxSystemPromptLenBytes {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Plan mode instructions exceed maximum length.",
Detail: fmt.Sprintf("Maximum length is %d bytes, got %d.", maxSystemPromptLenBytes, len(sanitizedInstructions)),
})
return
}
if err := api.Database.UpsertChatPlanModeInstructions(ctx, sanitizedInstructions); err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating plan mode instructions.",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
+331 -18
View File
@@ -664,12 +664,10 @@ func TestPostChats(t *testing.T) {
_, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: uuid.Nil,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "hello",
},
},
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "hello",
}},
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "organization_id is required.", sdkErr.Message)
@@ -692,12 +690,10 @@ func TestPostChats(t *testing.T) {
_, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: secondOrg.ID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "hello",
},
},
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "hello",
}},
})
sdkErr := requireSDKError(t, err, http.StatusForbidden)
require.Equal(t, "You are not a member of the specified organization.", sdkErr.Message)
@@ -727,12 +723,10 @@ func TestPostChats(t *testing.T) {
_, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: secondOrg.ID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "hello",
},
},
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "hello",
}},
WorkspaceID: &workspaceBuild.Workspace.ID,
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
@@ -3615,6 +3609,251 @@ func TestGetChat(t *testing.T) {
})
}
func TestPatchChat(t *testing.T) {
t.Parallel()
createChat := func(ctx context.Context, t *testing.T, client *codersdk.ExperimentalClient, orgID uuid.UUID, text string) codersdk.Chat {
t.Helper()
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: orgID,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: text,
},
},
})
require.NoError(t, err)
return chat
}
getChat := func(ctx context.Context, t *testing.T, client *codersdk.ExperimentalClient, chatID uuid.UUID) codersdk.Chat {
t.Helper()
chat, err := client.GetChat(ctx, chatID)
require.NoError(t, err)
return chat
}
createStoredChat := func(
ctx context.Context,
t *testing.T,
db database.Store,
ownerID uuid.UUID,
orgID uuid.UUID,
modelConfigID uuid.UUID,
title string,
) codersdk.Chat {
t.Helper()
dbChat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OrganizationID: orgID,
Status: database.ChatStatusWaiting,
OwnerID: ownerID,
LastModelConfigID: modelConfigID,
Title: title,
})
require.NoError(t, err)
return db2sdk.Chat(dbChat, nil, nil)
}
t.Run("PlanMode", func(t *testing.T) {
t.Parallel()
t.Run("SetToPlan", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "set plan mode")
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
PlanMode: ptr.Ref(codersdk.ChatPlanModePlan),
})
require.NoError(t, err)
updated := getChat(ctx, t, client, chat.ID)
require.Equal(t, codersdk.ChatPlanModePlan, updated.PlanMode)
})
t.Run("Clear", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "clear plan mode")
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
PlanMode: ptr.Ref(codersdk.ChatPlanModePlan),
})
require.NoError(t, err)
err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
PlanMode: ptr.Ref(codersdk.ChatPlanMode("")),
})
require.NoError(t, err)
updated := getChat(ctx, t, client, chat.ID)
require.Empty(t, updated.PlanMode)
})
t.Run("RejectsInvalidValue", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat := createChat(ctx, t, client, firstUser.OrganizationID, "invalid plan mode")
invalidPlanMode := codersdk.ChatPlanMode("invalid")
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
PlanMode: &invalidPlanMode,
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Invalid plan_mode value.", sdkErr.Message)
})
})
t.Run("WorkspaceBinding", func(t *testing.T) {
t.Parallel()
t.Run("BindValidWorkspace", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
}).WithAgent().Do()
chat := createStoredChat(
ctx,
t,
db,
firstUser.UserID,
firstUser.OrganizationID,
modelConfig.ID,
"bind workspace",
)
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
WorkspaceID: &workspaceBuild.Workspace.ID,
})
require.NoError(t, err)
updated := getChat(ctx, t, client, chat.ID)
require.NotNil(t, updated.WorkspaceID)
require.Equal(t, workspaceBuild.Workspace.ID, *updated.WorkspaceID)
})
t.Run("WorkspaceNotFound", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
chat := createStoredChat(
ctx,
t,
db,
firstUser.UserID,
firstUser.OrganizationID,
modelConfig.ID,
"missing workspace",
)
workspaceID := uuid.New()
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
WorkspaceID: &workspaceID,
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Workspace not found or you do not have access to this resource", sdkErr.Message)
})
t.Run("RejectsCrossOrgWorkspaceBinding", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
secondOrg := dbgen.Organization(t, db, database.Organization{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: secondOrg.ID,
UserID: firstUser.UserID,
})
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: secondOrg.ID,
OwnerID: firstUser.UserID,
}).WithAgent().Do()
chat := createStoredChat(
ctx,
t,
db,
firstUser.UserID,
firstUser.OrganizationID,
modelConfig.ID,
"cross org workspace binding",
)
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
WorkspaceID: &workspaceBuild.Workspace.ID,
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Workspace does not belong to this chat's organization.", sdkErr.Message)
})
t.Run("ClearWorkspaceBinding", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
}).WithAgent().Do()
chat := createStoredChat(
ctx,
t,
db,
firstUser.UserID,
firstUser.OrganizationID,
modelConfig.ID,
"clear workspace binding",
)
err := client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
WorkspaceID: &workspaceBuild.Workspace.ID,
})
require.NoError(t, err)
workspaceID := uuid.Nil
err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
WorkspaceID: &workspaceID,
})
require.NoError(t, err)
updated := getChat(ctx, t, client, chat.ID)
require.Nil(t, updated.WorkspaceID)
require.Nil(t, updated.BuildID)
require.Nil(t, updated.AgentID)
})
})
}
func TestArchiveChat(t *testing.T) {
t.Parallel()
@@ -7930,6 +8169,80 @@ func TestChatSystemPrompt(t *testing.T) {
})
}
//nolint:tparallel,paralleltest // Subtests share a single coderdtest instance.
func TestChatPlanModeInstructions(t *testing.T) {
t.Parallel()
adminClient, _ := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
_ = createChatModelConfig(t, adminClient)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
updateChatPlanModeInstructions := func(t *testing.T, ctx context.Context, req codersdk.UpdateChatPlanModeInstructionsRequest) {
t.Helper()
err := adminClient.UpdateChatPlanModeInstructions(ctx, req)
require.NoError(t, err)
}
getChatPlanModeInstructions := func(t *testing.T, ctx context.Context) codersdk.ChatPlanModeInstructionsResponse {
t.Helper()
resp, err := adminClient.GetChatPlanModeInstructions(ctx)
require.NoError(t, err)
return resp
}
roundTripTests := []struct {
name string
updates []string
want string
}{
{
name: "DefaultGETReturnsEmpty",
want: "",
},
{
name: "PUTThenGETRoundTrips",
updates: []string{"Use plan mode for multi-step changes."},
want: "Use plan mode for multi-step changes.",
},
}
for _, tt := range roundTripTests {
t.Run(tt.name, func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
for _, instructions := range tt.updates {
updateChatPlanModeInstructions(t, ctx, codersdk.UpdateChatPlanModeInstructionsRequest{
PlanModeInstructions: instructions,
})
}
resp := getChatPlanModeInstructions(t, ctx)
require.Equal(t, tt.want, resp.PlanModeInstructions)
})
}
t.Run("OversizedPayloadReturns400", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
tooLong := strings.Repeat("a", 131073)
err := adminClient.UpdateChatPlanModeInstructions(ctx, codersdk.UpdateChatPlanModeInstructionsRequest{
PlanModeInstructions: tooLong,
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Plan mode instructions exceed maximum length.", sdkErr.Message)
})
t.Run("NonAdminGETReturns404", func(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
_, err := memberClient.GetChatPlanModeInstructions(ctx)
requireSDKError(t, err, http.StatusNotFound)
})
}
func TestChatDesktopEnabled(t *testing.T) {
t.Parallel()
+432 -186
View File
@@ -788,6 +788,7 @@ type CreateOptions struct {
Title string
ModelConfigID uuid.UUID
ChatMode database.NullChatMode
PlanMode database.NullChatPlanMode
SystemPrompt string
InitialUserContent []codersdk.ChatMessagePart
MCPServerIDs []uuid.UUID
@@ -815,6 +816,7 @@ type SendMessageOptions struct {
Content []codersdk.ChatMessagePart
ModelConfigID *uuid.UUID
BusyBehavior SendMessageBusyBehavior
PlanMode *database.NullChatPlanMode
MCPServerIDs *[]uuid.UUID
}
@@ -882,6 +884,8 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C
// another pool checkout.
deploymentPrompt := p.resolveDeploymentSystemPrompt(ctx)
effectivePlanMode := opts.PlanMode
var chat database.Chat
txErr := p.db.InTx(func(tx database.Store) error {
if limitErr := p.checkUsageLimit(ctx, tx, opts.OwnerID, uuid.NullUUID{UUID: opts.OrganizationID, Valid: true}); limitErr != nil {
@@ -904,6 +908,7 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C
LastModelConfigID: opts.ModelConfigID,
Title: opts.Title,
Mode: opts.ChatMode,
PlanMode: effectivePlanMode,
// Chats created with an initial user message start pending.
// Waiting is reserved for idle chats with no pending work.
Status: database.ChatStatusPending,
@@ -1040,6 +1045,8 @@ func (p *Server) SendMessage(
return SendMessageResult{}, xerrors.Errorf("marshal message content: %w", err)
}
requestedPlanMode := opts.PlanMode
var (
result SendMessageResult
queuedMessagesSDK []codersdk.ChatQueuedMessage
@@ -1056,6 +1063,16 @@ func (p *Server) SendMessage(
return limitErr
}
if requestedPlanMode != nil {
lockedChat, err = tx.UpdateChatPlanModeByID(ctx, database.UpdateChatPlanModeByIDParams{
PlanMode: *requestedPlanMode,
ID: opts.ChatID,
})
if err != nil {
return xerrors.Errorf("update chat plan mode: %w", err)
}
}
modelConfigID := lockedChat.LastModelConfigID
if opts.ModelConfigID != nil {
modelConfigID = *opts.ModelConfigID
@@ -4365,6 +4382,336 @@ type runChatResult struct {
PendingDynamicToolCalls []chatloop.PendingToolCall
}
func allowedPlanToolNames(
allTools []fantasy.AgentTool,
mode database.NullChatPlanMode,
parentChatID uuid.NullUUID,
) []string {
isPlanModeTurn := mode.Valid && mode.ChatPlanMode == database.ChatPlanModePlan
isRootChat := !parentChatID.Valid
builtinPlanPolicy := map[string]bool{
"read_file": true,
"write_file": isRootChat,
"edit_files": isRootChat,
"execute": true,
"process_output": true,
"process_list": false,
"process_signal": false,
"list_templates": isRootChat,
"read_template": isRootChat,
"create_workspace": isRootChat,
"start_workspace": isRootChat,
"propose_plan": isRootChat,
"spawn_agent": isRootChat,
"wait_agent": isRootChat,
"message_agent": false,
"close_agent": false,
"spawn_computer_use_agent": false,
"read_skill": true,
"read_skill_file": true,
"ask_user_question": isRootChat,
}
if !isPlanModeTurn {
toolNames := make([]string, 0, len(allTools))
for _, tool := range allTools {
toolNames = append(toolNames, tool.Info().Name)
}
return toolNames
}
toolNames := make([]string, 0, len(allTools))
for _, tool := range allTools {
name := tool.Info().Name
if builtinPlanPolicy[name] {
toolNames = append(toolNames, name)
}
}
return toolNames
}
func stopAfterPlanTools(mode database.NullChatPlanMode, parentChatID uuid.NullUUID) map[string]struct{} {
if !mode.Valid || mode.ChatPlanMode != database.ChatPlanModePlan {
return nil
}
stopTools := map[string]struct{}{
"propose_plan": {},
}
if !parentChatID.Valid {
stopTools["ask_user_question"] = struct{}{}
}
return stopTools
}
type systemPromptPlanContext struct {
mode database.NullChatPlanMode
planModeInstructions string
isRootChat bool
}
// buildSystemPrompt applies system-level prompt injections in the
// canonical order. It is used by both the initial prompt assembly
// and the ReloadMessages callback to keep them in sync.
func buildSystemPrompt(
prompt []fantasy.Message,
subagentInstruction string,
instruction string,
skills []chattool.SkillMeta,
userPrompt string,
planContext systemPromptPlanContext,
) []fantasy.Message {
if subagentInstruction != "" {
prompt = chatprompt.InsertSystem(prompt, subagentInstruction)
}
if instruction != "" {
prompt = chatprompt.InsertSystem(prompt, instruction)
}
if skillIndex := chattool.FormatSkillIndex(skills); skillIndex != "" {
prompt = chatprompt.InsertSystem(prompt, skillIndex)
}
if userPrompt != "" {
prompt = chatprompt.InsertSystem(prompt, userPrompt)
}
isPlanModeTurn := planContext.mode.Valid && planContext.mode.ChatPlanMode == database.ChatPlanModePlan
if isPlanModeTurn {
if planContext.isRootChat {
prompt = chatprompt.InsertSystem(prompt, PlanningOverlayPrompt)
if planContext.planModeInstructions != "" {
prompt = chatprompt.InsertSystem(prompt, planContext.planModeInstructions)
}
} else {
prompt = chatprompt.InsertSystem(prompt, PlanningSubagentOverlayPrompt)
}
}
return prompt
}
type rootChatToolsOptions struct {
chat database.Chat
modelConfigID uuid.UUID
workspaceCtx *turnWorkspaceContext
workspaceMu *sync.Mutex
instruction *string
skills *[]chattool.SkillMeta
resolvePlanPath func(context.Context) (string, string, error)
isPlanModeTurn bool
}
func (p *Server) loadPlanModeInstructions(
ctx context.Context,
mode database.NullChatPlanMode,
logger slog.Logger,
) string {
if !mode.Valid || mode.ChatPlanMode != database.ChatPlanModePlan {
return ""
}
// Plan-mode instructions live in deployment config, but chat workers do
// not carry a deployment-config actor during background execution.
//nolint:gocritic // Required to read deployment config during background chat processing.
systemCtx := dbauthz.AsSystemRestricted(ctx)
fetched, err := p.db.GetChatPlanModeInstructions(systemCtx)
if err != nil {
logger.Warn(ctx,
"failed to fetch plan mode instructions",
slog.Error(err),
)
return ""
}
return fetched
}
func (p *Server) appendRootChatTools(
ctx context.Context,
tools []fantasy.AgentTool,
opts rootChatToolsOptions,
) []fantasy.AgentTool {
onChatUpdated := func(updatedChat database.Chat) {
opts.workspaceCtx.selectWorkspace(updatedChat)
// Notify the frontend immediately so it can start streaming
// build logs before the tool completes.
p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindStatusChange, nil)
// When a workspace is first attached mid-turn (e.g. via
// create_workspace), fetch and persist instruction files
// immediately so the LLM has AGENTS.md context for the remainder
// of this turn. The persisted marker prevents redundant fetches on
// subsequent turns.
if *opts.instruction == "" && updatedChat.WorkspaceID.Valid {
newInstruction, discoveredSkills, persistErr := p.persistInstructionFiles(
ctx,
updatedChat,
opts.modelConfigID,
opts.workspaceCtx.getWorkspaceAgent,
opts.workspaceCtx.getWorkspaceConn,
)
if persistErr != nil {
p.logger.Warn(ctx, "failed to persist instruction files on workspace attach",
slog.F("chat_id", updatedChat.ID),
slog.Error(persistErr),
)
} else {
*opts.instruction = newInstruction
if len(discoveredSkills) > 0 {
*opts.skills = discoveredSkills
}
}
}
}
tools = append(tools,
chattool.ListTemplates(opts.chat.OrganizationID, p.db, chattool.ListTemplatesOptions{
OwnerID: opts.chat.OwnerID,
AllowedTemplateIDs: p.chatTemplateAllowlist,
}),
chattool.ReadTemplate(opts.chat.OrganizationID, p.db, chattool.ReadTemplateOptions{
OwnerID: opts.chat.OwnerID,
AllowedTemplateIDs: p.chatTemplateAllowlist,
}),
chattool.CreateWorkspace(opts.chat.OrganizationID, p.db, chattool.CreateWorkspaceOptions{
OwnerID: opts.chat.OwnerID,
ChatID: opts.chat.ID,
CreateFn: p.createWorkspaceFn,
AgentConnFn: chattool.AgentConnFunc(p.agentConnFn),
AgentInactiveDisconnectTimeout: p.agentInactiveDisconnectTimeout,
WorkspaceMu: opts.workspaceMu,
OnChatUpdated: onChatUpdated,
Logger: p.logger,
AllowedTemplateIDs: p.chatTemplateAllowlist,
}),
chattool.StartWorkspace(chattool.StartWorkspaceOptions{
DB: p.db,
OwnerID: opts.chat.OwnerID,
ChatID: opts.chat.ID,
StartFn: p.startWorkspaceFn,
AgentConnFn: chattool.AgentConnFunc(p.agentConnFn),
WorkspaceMu: opts.workspaceMu,
OnChatUpdated: onChatUpdated,
Logger: p.logger,
}),
)
if opts.isPlanModeTurn {
tools = append(tools, chattool.ProposePlan(chattool.ProposePlanOptions{
GetWorkspaceConn: opts.workspaceCtx.getWorkspaceConn,
ResolvePlanPath: opts.resolvePlanPath,
IsPlanTurn: opts.isPlanModeTurn,
StoreFile: func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error) {
return p.storePlanSnapshotFile(ctx, opts.workspaceCtx, name, mediaType, data)
},
}))
}
return append(tools, p.subagentTools(ctx, func() database.Chat {
return opts.chat
})...)
}
func (p *Server) storePlanSnapshotFile(
ctx context.Context,
workspaceCtx *turnWorkspaceContext,
name string,
mediaType string,
data []byte,
) (uuid.UUID, error) {
chatSnapshot := workspaceCtx.currentChatSnapshot()
if !chatSnapshot.WorkspaceID.Valid {
return uuid.Nil, xerrors.New("no workspace is associated with this chat. Use the create_workspace tool to create one")
}
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)
}
// Cap enforcement and dedup are handled atomically in SQL.
// rejected > 0 means the cap was exceeded.
rejected, err := p.db.LinkChatFiles(ctx, database.LinkChatFilesParams{
ChatID: chatSnapshot.ID,
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
FileIds: []uuid.UUID{row.ID},
})
switch {
case err != nil:
p.logger.Error(ctx, "failed to link file to chat",
slog.F("chat_id", chatSnapshot.ID),
slog.F("file_id", row.ID),
slog.Error(err),
)
case rejected > 0:
p.logger.Warn(ctx, "file cap reached, file not linked to chat",
slog.F("chat_id", chatSnapshot.ID),
slog.F("file_id", row.ID),
slog.F("max_file_links", codersdk.MaxChatFileIDs),
)
}
return row.ID, nil
}
func appendDynamicTools(
ctx context.Context,
logger slog.Logger,
tools []fantasy.AgentTool,
raw pqtype.NullRawMessage,
mode database.NullChatPlanMode,
parentChatID uuid.NullUUID,
) ([]fantasy.AgentTool, map[string]bool, error) {
if mode.Valid && mode.ChatPlanMode == database.ChatPlanModePlan {
return tools, nil, nil
}
dynamicToolNames, err := parseDynamicToolNames(raw)
if err != nil {
return nil, nil, xerrors.Errorf("parse dynamic tool names: %w", err)
}
if len(dynamicToolNames) == 0 {
return tools, dynamicToolNames, nil
}
var dynamicToolDefs []codersdk.DynamicTool
if raw.Valid {
if err := json.Unmarshal(raw.RawMessage, &dynamicToolDefs); err != nil {
return nil, nil, xerrors.Errorf("unmarshal dynamic tools: %w", err)
}
}
activeToolNames := make(map[string]struct{}, len(tools))
for _, name := range allowedPlanToolNames(tools, mode, parentChatID) {
activeToolNames[name] = struct{}{}
}
for _, t := range tools {
info := t.Info()
if _, active := activeToolNames[info.Name]; !active {
continue
}
if dynamicToolNames[info.Name] {
logger.Warn(ctx, "dynamic tool name collides with built-in tool, built-in takes precedence",
slog.F("tool_name", info.Name))
delete(dynamicToolNames, info.Name)
}
}
var filteredDefs []codersdk.DynamicTool
for _, dt := range dynamicToolDefs {
if dynamicToolNames[dt.Name] {
filteredDefs = append(filteredDefs, dt)
}
}
return append(tools, dynamicToolsFromSDK(logger, filteredDefs)...), dynamicToolNames, nil
}
func (p *Server) runChat(
ctx context.Context,
chat database.Chat,
@@ -4378,6 +4725,7 @@ func (p *Server) runChat(
providerKeys chatprovider.ProviderAPIKeys
callConfig codersdk.ChatModelCallConfig
messages []database.ChatMessage
err error
)
// Load MCP server configs and user tokens in parallel with
@@ -4445,6 +4793,14 @@ func (p *Server) runChat(
if err := g.Wait(); err != nil {
return result, err
}
// Capture the current turn's mode from the chat plan mode so prompt
// and tool behavior can be resolved consistently for the rest of the
// turn.
currentPlanMode := chat.PlanMode
isPlanModeTurn := currentPlanMode.Valid && currentPlanMode.ChatPlanMode == database.ChatPlanModePlan
planModeInstructions := p.loadPlanModeInstructions(ctx, currentPlanMode, logger)
chainInfo := resolveChainMode(messages)
result.PushSummaryModel = model
result.ProviderKeys = providerKeys
@@ -4713,9 +5069,23 @@ func (p *Server) runChat(
if err := g2.Wait(); err != nil {
return result, err
}
if chat.ParentChatID.Valid {
prompt = chatprompt.InsertSystem(prompt, defaultSubagentInstruction)
isRootChat := !chat.ParentChatID.Valid
subagentInstruction := ""
if !isRootChat {
subagentInstruction = defaultSubagentInstruction
}
prompt = buildSystemPrompt(
prompt,
subagentInstruction,
instruction,
skills,
resolvedUserPrompt,
systemPromptPlanContext{
mode: currentPlanMode,
planModeInstructions: planModeInstructions,
isRootChat: isRootChat,
},
)
if mcpCleanup != nil {
defer mcpCleanup()
}
@@ -4730,19 +5100,8 @@ func (p *Server) runChat(
}
}
var instructionInjected bool
if instruction != "" {
prompt = chatprompt.InsertSystem(prompt, instruction)
instructionInjected = true
}
instructionInjected := instruction != ""
prompt = renderPlanPathPrompt(prompt, resolvePlanPathBlock(ctx))
if skillIndex := chattool.FormatSkillIndex(skills); skillIndex != "" {
prompt = chatprompt.InsertSystem(prompt, skillIndex)
}
if resolvedUserPrompt != "" {
prompt = chatprompt.InsertSystem(prompt, resolvedUserPrompt)
}
// Use the model config's context_limit as a fallback when the LLM
// provider doesn't include context_limit in its response metadata
// (which is the common case).
@@ -5042,6 +5401,7 @@ func (p *Server) runChat(
model = cuModel
}
allowAskUserQuestion := isPlanModeTurn && isRootChat
tools := []fantasy.AgentTool{
chattool.ReadFile(chattool.ReadFileOptions{
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
@@ -5049,10 +5409,12 @@ func (p *Server) runChat(
chattool.WriteFile(chattool.WriteFileOptions{
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
ResolvePlanPath: resolvePlanPathForTools,
IsPlanTurn: isPlanModeTurn,
}),
chattool.EditFiles(chattool.EditFilesOptions{
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
ResolvePlanPath: resolvePlanPathForTools,
IsPlanTurn: isPlanModeTurn,
}),
chattool.Execute(chattool.ExecuteOptions{
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
@@ -5067,134 +5429,24 @@ func (p *Server) runChat(
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
}),
}
if allowAskUserQuestion {
tools = append(tools, chattool.NewAskUserQuestionTool())
}
// Only root chats (not delegated subagents) get workspace
// provisioning and subagent tools. Child agents must not
// create workspaces or spawn further subagents — they should
// create workspaces or spawn further subagents. They should
// focus on completing their delegated task.
if !chat.ParentChatID.Valid {
// Workspace provisioning tools.
onChatUpdated := func(updatedChat database.Chat) {
workspaceCtx.selectWorkspace(updatedChat)
// Notify the frontend immediately so it can
// start streaming build logs before the tool
// completes.
p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindStatusChange, nil)
// When a workspace is first attached mid-turn
// (e.g. via create_workspace), fetch and persist
// instruction files immediately so the LLM has
// AGENTS.md context for the remainder of this
// turn. The persisted marker prevents redundant
// fetches on subsequent turns.
if instruction == "" && updatedChat.WorkspaceID.Valid {
newInstruction, discoveredSkills, persistErr := p.persistInstructionFiles(
ctx,
updatedChat,
modelConfig.ID,
workspaceCtx.getWorkspaceAgent,
workspaceCtx.getWorkspaceConn,
)
if persistErr != nil {
p.logger.Warn(ctx, "failed to persist instruction files on workspace attach",
slog.F("chat_id", updatedChat.ID),
slog.Error(persistErr),
)
} else {
instruction = newInstruction
if len(discoveredSkills) > 0 {
skills = discoveredSkills
}
}
}
}
tools = append(tools,
chattool.ListTemplates(chat.OrganizationID, p.db, chattool.ListTemplatesOptions{
OwnerID: chat.OwnerID,
AllowedTemplateIDs: p.chatTemplateAllowlist,
}),
chattool.ReadTemplate(chat.OrganizationID, p.db, chattool.ReadTemplateOptions{
OwnerID: chat.OwnerID,
AllowedTemplateIDs: p.chatTemplateAllowlist,
}),
chattool.CreateWorkspace(chat.OrganizationID, p.db, chattool.CreateWorkspaceOptions{
OwnerID: chat.OwnerID,
ChatID: chat.ID,
CreateFn: p.createWorkspaceFn,
AgentConnFn: chattool.AgentConnFunc(p.agentConnFn),
AgentInactiveDisconnectTimeout: p.agentInactiveDisconnectTimeout,
WorkspaceMu: &workspaceMu,
OnChatUpdated: onChatUpdated,
Logger: p.logger,
AllowedTemplateIDs: p.chatTemplateAllowlist,
}),
chattool.StartWorkspace(chattool.StartWorkspaceOptions{
DB: p.db,
OwnerID: chat.OwnerID,
ChatID: chat.ID,
StartFn: p.startWorkspaceFn,
AgentConnFn: chattool.AgentConnFunc(p.agentConnFn),
WorkspaceMu: &workspaceMu,
OnChatUpdated: onChatUpdated,
Logger: p.logger,
}),
)
// Plan presentation tool.
tools = append(tools, chattool.ProposePlan(chattool.ProposePlanOptions{
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
ResolvePlanPath: resolvePlanPathForTools,
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("no workspace is associated with this chat. Use the create_workspace tool to create one")
}
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)
}
// Cap enforcement and dedup are handled atomically
// in SQL. rejected > 0 = cap exceeded.
rejected, err := p.db.LinkChatFiles(ctx, database.LinkChatFilesParams{
ChatID: chatSnapshot.ID,
MaxFileLinks: int32(codersdk.MaxChatFileIDs),
FileIds: []uuid.UUID{row.ID},
})
switch {
case err != nil:
p.logger.Error(ctx, "failed to link file to chat",
slog.F("chat_id", chatSnapshot.ID),
slog.F("file_id", row.ID),
slog.Error(err),
)
case rejected > 0:
p.logger.Warn(ctx, "file cap reached, file not linked to chat",
slog.F("chat_id", chatSnapshot.ID),
slog.F("file_id", row.ID),
slog.F("max_file_links", codersdk.MaxChatFileIDs),
)
}
return row.ID, nil
},
}))
tools = append(tools, p.subagentTools(ctx, func() database.Chat {
return chat
})...)
if isRootChat {
tools = p.appendRootChatTools(ctx, tools, rootChatToolsOptions{
chat: chat,
modelConfigID: modelConfig.ID,
workspaceCtx: &workspaceCtx,
workspaceMu: &workspaceMu,
instruction: &instruction,
skills: &skills,
resolvePlanPath: resolvePlanPathForTools,
isPlanModeTurn: isPlanModeTurn,
})
}
// Append skill tools when the workspace has skills.
@@ -5221,49 +5473,35 @@ func (p *Server) runChat(
// Append tools from external MCP servers. These appear
// after the built-in tools so the LLM sees them as
// additional capabilities.
tools = append(tools, mcpTools...)
tools = append(tools, workspaceMCPTools...)
if !isPlanModeTurn {
tools = append(tools, mcpTools...)
tools = append(tools, workspaceMCPTools...)
}
// Append dynamic tools declared by the client at chat
// creation time. These appear in the LLM's tool list but
// are never executed by the chatloop — the client handles
// are never executed by the chatloop. The client handles
// execution via POST /tool-results.
dynamicToolNames, err := parseDynamicToolNames(chat.DynamicTools)
var dynamicToolNames map[string]bool
tools, dynamicToolNames, err = appendDynamicTools(
ctx,
logger,
tools,
chat.DynamicTools,
currentPlanMode,
chat.ParentChatID,
)
if err != nil {
return result, xerrors.Errorf("parse dynamic tool names: %w", err)
}
// Unmarshal the full definitions separately so we can
// build the filtered list below. parseDynamicToolNames
// already validated the JSON, so this cannot fail.
var dynamicToolDefs []codersdk.DynamicTool
if chat.DynamicTools.Valid {
if err := json.Unmarshal(chat.DynamicTools.RawMessage, &dynamicToolDefs); err != nil {
return result, xerrors.Errorf("unmarshal dynamic tools: %w", err)
}
}
for _, t := range tools {
info := t.Info()
if dynamicToolNames[info.Name] {
logger.Warn(ctx, "dynamic tool name collides with built-in tool, built-in takes precedence",
slog.F("tool_name", info.Name))
delete(dynamicToolNames, info.Name)
}
return result, err
}
var filteredDefs []codersdk.DynamicTool
for _, dt := range dynamicToolDefs {
if dynamicToolNames[dt.Name] {
filteredDefs = append(filteredDefs, dt)
}
}
tools = append(tools, dynamicToolsFromSDK(p.logger, filteredDefs)...)
// Build provider-native tools (e.g., web search) based on
// the model configuration.
var providerTools []chatloop.ProviderTool
if callConfig.ProviderOptions != nil {
if !isPlanModeTurn && callConfig.ProviderOptions != nil {
providerTools = buildProviderTools(model.Provider(), callConfig.ProviderOptions)
}
if isComputerUse {
if !isPlanModeTurn && isComputerUse {
desktopGeometry := workspacesdk.DefaultDesktopGeometry()
providerTools = append(providerTools, chatloop.ProviderTool{
Definition: chattool.ComputerUseProviderTool(
@@ -5291,7 +5529,8 @@ func (p *Server) runChat(
chainModeActive := chatprovider.IsResponsesStoreEnabled(providerOptions) &&
chainInfo.previousResponseID != "" &&
chainInfo.contributingTrailingUserCount > 0 &&
chainInfo.modelConfigID == modelConfig.ID
chainInfo.modelConfigID == modelConfig.ID &&
!isPlanModeTurn
if chainModeActive {
providerOptions = chatprovider.CloneWithPreviousResponseID(
providerOptions,
@@ -5300,9 +5539,12 @@ func (p *Server) runChat(
prompt = filterPromptForChainMode(prompt, chainInfo)
}
err = chatloop.Run(ctx, chatloop.RunOptions{
Model: model,
Messages: prompt,
Tools: tools, MaxSteps: maxChatSteps,
Model: model,
Messages: prompt,
Tools: tools,
ActiveTools: allowedPlanToolNames(tools, currentPlanMode, chat.ParentChatID),
StopAfterTools: stopAfterPlanTools(currentPlanMode, chat.ParentChatID),
MaxSteps: maxChatSteps,
Metrics: p.metrics,
BuiltinToolNames: builtinToolNames,
@@ -5337,9 +5579,6 @@ func (p *Server) runChat(
if err != nil {
return nil, xerrors.Errorf("convert reloaded messages: %w", err)
}
if chat.ParentChatID.Valid {
reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, defaultSubagentInstruction)
}
// Re-derive instruction and skills from the reloaded
// messages so that any context added during the
// chatloop (e.g. via persistInstructionFiles when
@@ -5351,22 +5590,26 @@ func (p *Server) runChat(
reloadedInstruction = instructionFromContextFiles(reloadedMsgs)
}
if reloadedInstruction != "" {
reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, reloadedInstruction)
instructionInjected = true
}
reloadedPrompt = renderPlanPathPrompt(reloadedPrompt, resolvePlanPathBlock(reloadCtx))
reloadedSkills := skillsFromParts(reloadedMsgs)
if len(reloadedSkills) == 0 {
reloadedSkills = skills
}
if skillIndex := chattool.FormatSkillIndex(reloadedSkills); skillIndex != "" {
reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, skillIndex)
}
reloadUserPrompt := p.resolveUserPrompt(reloadCtx, chat.OwnerID)
if reloadUserPrompt != "" {
reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, reloadUserPrompt)
}
reloadedPrompt = buildSystemPrompt(
reloadedPrompt,
subagentInstruction,
reloadedInstruction,
reloadedSkills,
reloadUserPrompt,
systemPromptPlanContext{
mode: currentPlanMode,
planModeInstructions: planModeInstructions,
isRootChat: isRootChat,
},
)
reloadedPrompt = renderPlanPathPrompt(reloadedPrompt, resolvePlanPathBlock(reloadCtx))
if chainModeActive {
reloadedPrompt = filterPromptForChainMode(
reloadedPrompt,
@@ -5416,6 +5659,9 @@ func (p *Server) runChat(
p.logger.Warn(ctx, "failed to persist interrupted chat step", slog.Error(err))
},
})
if errors.Is(err, chatloop.ErrStopAfterTool) {
err = nil
}
if errors.Is(err, chatloop.ErrDynamicToolCall) {
// The stream event is published in processChat's
// defer after the DB status transitions to
+203
View File
@@ -33,6 +33,209 @@ import (
"github.com/coder/quartz"
)
type testAgentTool struct {
info fantasy.ToolInfo
providerOptions fantasy.ProviderOptions
}
func newTestAgentTool(name string) fantasy.AgentTool {
return &testAgentTool{info: fantasy.ToolInfo{Name: name}}
}
func (t *testAgentTool) Info() fantasy.ToolInfo {
return t.info
}
func (t *testAgentTool) Run(context.Context, fantasy.ToolCall) (fantasy.ToolResponse, error) {
_ = t
return fantasy.ToolResponse{}, nil
}
func (t *testAgentTool) ProviderOptions() fantasy.ProviderOptions {
return t.providerOptions
}
func (t *testAgentTool) SetProviderOptions(opts fantasy.ProviderOptions) {
t.providerOptions = opts
}
func TestAllowedPlanToolNames(t *testing.T) {
t.Parallel()
makeTools := func(names ...string) []fantasy.AgentTool {
tools := make([]fantasy.AgentTool, 0, len(names))
for _, name := range names {
tools = append(tools, newTestAgentTool(name))
}
return tools
}
planMode := database.NullChatPlanMode{
ChatPlanMode: database.ChatPlanModePlan,
Valid: true,
}
t.Run("NormalModeReturnsAllRegisteredTools", func(t *testing.T) {
t.Parallel()
got := allowedPlanToolNames(makeTools(
"read_file",
"propose_plan",
"custom_tool",
"execute",
), database.NullChatPlanMode{}, uuid.NullUUID{})
require.Equal(t, []string{
"read_file",
"propose_plan",
"custom_tool",
"execute",
}, got)
})
t.Run("PlanModeIncludesOnlyAllowlistedBuiltIns", func(t *testing.T) {
t.Parallel()
got := allowedPlanToolNames(makeTools(
"read_file",
"write_file",
"edit_files",
"execute",
"process_output",
"process_list",
"process_signal",
"list_templates",
"read_template",
"create_workspace",
"start_workspace",
"propose_plan",
"spawn_agent",
"wait_agent",
"message_agent",
"close_agent",
"spawn_computer_use_agent",
"read_skill",
"read_skill_file",
"ask_user_question",
), planMode, uuid.NullUUID{})
require.Equal(t, []string{
"read_file",
"write_file",
"edit_files",
"execute",
"process_output",
"list_templates",
"read_template",
"create_workspace",
"start_workspace",
"propose_plan",
"spawn_agent",
"wait_agent",
"read_skill",
"read_skill_file",
"ask_user_question",
}, got)
})
t.Run("PlanModeChildChatsAllowExplorationOnly", func(t *testing.T) {
t.Parallel()
got := allowedPlanToolNames(makeTools(
"read_file",
"write_file",
"edit_files",
"execute",
"process_output",
"list_templates",
"read_template",
"create_workspace",
"start_workspace",
"propose_plan",
"spawn_agent",
"wait_agent",
"read_skill",
"read_skill_file",
"ask_user_question",
), planMode, uuid.NullUUID{UUID: uuid.New(), Valid: true})
require.Equal(t, []string{
"read_file",
"execute",
"process_output",
"read_skill",
"read_skill_file",
}, got)
require.NotContains(t, got, "write_file")
require.NotContains(t, got, "edit_files")
require.NotContains(t, got, "ask_user_question")
require.NotContains(t, got, "propose_plan")
})
t.Run("PlanModeStillExcludesDangerousTools", func(t *testing.T) {
t.Parallel()
got := allowedPlanToolNames(makeTools(
"execute",
"process_output",
"message_agent",
"spawn_computer_use_agent",
"propose_plan",
), planMode, uuid.NullUUID{})
require.Equal(t, []string{"execute", "process_output", "propose_plan"}, got)
require.NotContains(t, got, "message_agent")
require.NotContains(t, got, "spawn_computer_use_agent")
})
t.Run("PlanModeExcludesUnknownTools", func(t *testing.T) {
t.Parallel()
got := allowedPlanToolNames(makeTools(
"read_file",
"custom_tool",
"another_custom_tool",
"propose_plan",
), planMode, uuid.NullUUID{})
require.Equal(t, []string{
"read_file",
"propose_plan",
}, got)
require.NotContains(t, got, "custom_tool")
require.NotContains(t, got, "another_custom_tool")
})
}
func TestStopAfterPlanTools(t *testing.T) {
t.Parallel()
planMode := database.NullChatPlanMode{
ChatPlanMode: database.ChatPlanModePlan,
Valid: true,
}
t.Run("NormalModeReturnsNil", func(t *testing.T) {
t.Parallel()
require.Nil(t, stopAfterPlanTools(database.NullChatPlanMode{}, uuid.NullUUID{}))
})
t.Run("RootPlanModeIncludesClarificationTool", func(t *testing.T) {
t.Parallel()
require.Equal(t, map[string]struct{}{
"propose_plan": {},
"ask_user_question": {},
}, stopAfterPlanTools(planMode, uuid.NullUUID{}))
})
t.Run("ChildPlanModeSkipsClarificationTool", func(t *testing.T) {
t.Parallel()
require.Equal(t, map[string]struct{}{
"propose_plan": {},
}, stopAfterPlanTools(planMode, uuid.NullUUID{UUID: uuid.New(), Valid: true}))
})
}
func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) {
t.Parallel()
+392 -1
View File
@@ -54,6 +54,90 @@ import (
"github.com/coder/quartz"
)
type recordedOpenAIRequest struct {
Messages []chattest.OpenAIMessage
Tools []string
Store *bool
PreviousResponseID *string
ContentLength int64
}
func recordOpenAIRequest(req *chattest.OpenAIRequest) recordedOpenAIRequest {
messages := append([]chattest.OpenAIMessage(nil), req.Messages...)
tools := make([]string, 0, len(req.Tools))
for _, tool := range req.Tools {
tools = append(tools, tool.Function.Name)
}
var store *bool
if req.Store != nil {
value := *req.Store
store = &value
}
var previousResponseID *string
if req.PreviousResponseID != nil {
value := *req.PreviousResponseID
previousResponseID = &value
}
var contentLength int64
if req.Request != nil {
contentLength = req.Request.ContentLength
}
return recordedOpenAIRequest{
Messages: messages,
Tools: tools,
Store: store,
PreviousResponseID: previousResponseID,
ContentLength: contentLength,
}
}
func requestHasSystemSubstring(req recordedOpenAIRequest, want string) bool {
for _, msg := range req.Messages {
if msg.Role == "system" && strings.Contains(msg.Content, want) {
return true
}
}
return false
}
func newWorkspaceToolTestServer(
t *testing.T,
db database.Store,
ps dbpubsub.Pubsub,
agentID uuid.UUID,
planContent string,
) *chatd.Server {
t.Helper()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
mockConn.EXPECT().SetExtraHeaders(gomock.Any()).AnyTimes()
mockConn.EXPECT().ContextConfig(gomock.Any()).
Return(workspacesdk.ContextConfigResponse{}, xerrors.New("not supported")).AnyTimes()
mockConn.EXPECT().ListMCPTools(gomock.Any()).
Return(workspacesdk.ListMCPToolsResponse{}, nil).AnyTimes()
mockConn.EXPECT().LS(gomock.Any(), gomock.Any(), gomock.Any()).
Return(workspacesdk.LSResponse{}, nil).AnyTimes()
mockConn.EXPECT().ReadFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, path string, _, _ int64) (io.ReadCloser, string, error) {
if path == "/home/coder/PLAN.md" {
return io.NopCloser(strings.NewReader(planContent)), "", nil
}
return io.NopCloser(strings.NewReader("")), "", nil
}).AnyTimes()
return newActiveTestServer(t, db, ps, func(cfg *chatd.Config) {
cfg.AgentConn = func(_ context.Context, gotAgentID uuid.UUID) (workspacesdk.AgentConn, func(), error) {
require.Equal(t, agentID, gotAgentID)
return mockConn, func() {}, nil
}
})
}
func TestInterruptChatBroadcastsStatusAcrossInstances(t *testing.T) {
t.Parallel()
@@ -224,7 +308,7 @@ func TestSubagentChatExcludesWorkspaceProvisioningTools(t *testing.T) {
require.GreaterOrEqual(t, len(recorded), 2,
"expected at least 2 streamed LLM calls (root + subagent)")
workspaceTools := []string{"propose_plan", "list_templates", "read_template", "create_workspace"}
workspaceTools := []string{"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
@@ -255,6 +339,10 @@ func TestSubagentChatExcludesWorkspaceProvisioningTools(t *testing.T) {
"root chat should have subagent tool %q", tool)
}
// Standard turns (no turn mode) should hide propose_plan.
require.NotContains(t, rootCalls[0], "propose_plan",
"standard-turn root chat should NOT have propose_plan")
// Subagent calls must NOT include workspace or subagent tools.
for _, tool := range workspaceTools {
require.NotContains(t, childCalls[0], tool,
@@ -266,6 +354,153 @@ func TestSubagentChatExcludesWorkspaceProvisioningTools(t *testing.T) {
}
}
func TestPlanModeSubagentChatExcludesAskUserQuestion(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
deploymentValues := coderdtest.DeploymentValues(t)
deploymentValues.Experiments = []string{string(codersdk.ExperimentAgents)}
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: deploymentValues,
IncludeProvisionerDaemon: true,
})
user := coderdtest.CreateFirstUser(t, client)
expClient := codersdk.NewExperimentalClient(client)
agentToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.PlanComplete,
ProvisionApply: echo.ApplyComplete,
ProvisionGraph: echo.ProvisionGraphWithAgent(agentToken),
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
_ = agenttest.New(t, client.URL, agentToken)
var toolsMu sync.Mutex
toolsByCall := make([][]string, 0, 2)
requestsByCall := make([]recordedOpenAIRequest, 0, 2)
var callCount atomic.Int32
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("ok")
}
names := make([]string, 0, len(req.Tools))
for _, tool := range req.Tools {
names = append(names, tool.Function.Name)
}
toolsMu.Lock()
toolsByCall = append(toolsByCall, names)
requestsByCall = append(requestsByCall, recordOpenAIRequest(req))
toolsMu.Unlock()
if callCount.Add(1) == 1 {
return chattest.OpenAIStreamingResponse(
chattest.OpenAIToolCallChunk("spawn_agent", `{"prompt":"inspect the codebase","title":"sub"}`),
)
}
return chattest.OpenAIStreamingResponse(
chattest.OpenAITextChunks("done")...,
)
})
_, err := expClient.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{
Provider: "openai-compat",
APIKey: "test-api-key",
BaseURL: openAIURL,
})
require.NoError(t, err)
contextLimit := int64(4096)
isDefault := true
_, err = expClient.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{
Provider: "openai-compat",
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
IsDefault: &isDefault,
})
require.NoError(t, err)
chat, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: user.OrganizationID,
PlanMode: codersdk.ChatPlanModePlan,
Content: []codersdk.ChatInputPart{
{
Type: codersdk.ChatInputPartTypeText,
Text: "Spawn a subagent to inspect the codebase.",
},
},
})
require.NoError(t, err)
require.Eventually(t, func() bool {
got, getErr := expClient.GetChat(ctx, chat.ID)
if getErr != nil {
return false
}
if got.Status != codersdk.ChatStatusWaiting && got.Status != codersdk.ChatStatusError {
return false
}
toolsMu.Lock()
n := len(toolsByCall)
toolsMu.Unlock()
return n >= 3
}, testutil.WaitLong, testutil.IntervalFast)
toolsMu.Lock()
recorded := append([][]string(nil), toolsByCall...)
recordedRequests := append([]recordedOpenAIRequest(nil), requestsByCall...)
toolsMu.Unlock()
require.GreaterOrEqual(t, len(recorded), 2,
"expected at least 2 streamed LLM calls (root + subagent)")
require.Len(t, recordedRequests, len(recorded))
var rootCalls, childCalls [][]string
var rootRequests, childRequests []recordedOpenAIRequest
for i, tools := range recorded {
if slice.Contains(tools, "spawn_agent") {
rootCalls = append(rootCalls, tools)
rootRequests = append(rootRequests, recordedRequests[i])
continue
}
childCalls = append(childCalls, tools)
childRequests = append(childRequests, recordedRequests[i])
}
require.NotEmpty(t, rootCalls, "expected at least one root chat LLM call")
require.NotEmpty(t, childCalls, "expected at least one subagent LLM call")
require.NotEmpty(t, rootRequests, "expected at least one root prompt")
require.NotEmpty(t, childRequests, "expected at least one subagent prompt")
require.Contains(t, rootCalls[0], "ask_user_question",
"root plan-mode chat should have ask_user_question")
require.Contains(t, rootCalls[0], "write_file",
"root plan-mode chat should have write_file")
require.Contains(t, rootCalls[0], "edit_files",
"root plan-mode chat should have edit_files")
require.Contains(t, rootCalls[0], "execute",
"root plan-mode chat should have execute")
require.Contains(t, rootCalls[0], "process_output",
"root plan-mode chat should have process_output")
require.NotContains(t, childCalls[0], "ask_user_question",
"plan-mode subagent should NOT have ask_user_question")
require.NotContains(t, childCalls[0], "write_file",
"plan-mode subagent should NOT have write_file")
require.NotContains(t, childCalls[0], "edit_files",
"plan-mode subagent should NOT have edit_files")
require.Contains(t, childCalls[0], "execute",
"plan-mode subagent should have execute")
require.Contains(t, childCalls[0], "process_output",
"plan-mode subagent should have process_output")
require.True(t, requestHasSystemSubstring(rootRequests[0], "You are in Plan Mode."))
require.True(t, requestHasSystemSubstring(childRequests[0], "You are in Plan Mode as a delegated sub-agent."))
require.False(t, requestHasSystemSubstring(childRequests[0], "When the plan is ready, call propose_plan"))
}
func TestInterruptChatClearsWorkerInDatabase(t *testing.T) {
t.Parallel()
@@ -579,6 +814,77 @@ func TestSendMessageQueueBehaviorQueuesWhenBusy(t *testing.T) {
require.Len(t, messages, 1)
}
func TestPlanTurnPromptContract(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
db, ps := dbtestutil.NewDB(t)
var (
requests []recordedOpenAIRequest
requestsMu sync.Mutex
)
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("title")
}
requestsMu.Lock()
requests = append(requests, recordOpenAIRequest(req))
requestsMu.Unlock()
return chattest.OpenAIStreamingResponse(
chattest.OpenAITextChunks("plan acknowledged")...,
)
})
user, org, model := seedChatDependenciesWithProvider(ctx, t, db, "openai-compat", openAIURL)
planModeInstructions := "Ask about deployment sequencing before finalizing the plan."
err := db.UpsertChatPlanModeInstructions(dbauthz.AsSystemRestricted(ctx), planModeInstructions)
require.NoError(t, err)
ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID)
server := newWorkspaceToolTestServer(t, db, ps, dbAgent.ID, "# Plan\n")
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OwnerID: user.ID,
OrganizationID: org.ID,
Title: "plan-turn-prompt-contract",
ModelConfigID: model.ID,
PlanMode: database.NullChatPlanMode{ChatPlanMode: database.ChatPlanModePlan, Valid: true},
WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true},
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("Plan the rollout."),
},
})
require.NoError(t, err)
waitForChatProcessed(ctx, t, db, chat.ID, server)
requestsMu.Lock()
recorded := append([]recordedOpenAIRequest(nil), requests...)
requestsMu.Unlock()
require.Len(t, recorded, 1, "expected exactly 1 streamed model call")
require.True(t, requestHasSystemSubstring(recorded[0], "You are in Plan Mode."))
require.True(t, requestHasSystemSubstring(recorded[0], "The only intentional authored workspace artifact is the plan file"))
require.True(t, requestHasSystemSubstring(recorded[0], "You may use execute and process_output for exploration"))
require.True(t, requestHasSystemSubstring(recorded[0], "After a successful propose_plan call, stop immediately"))
require.True(t, requestHasSystemSubstring(recorded[0], planModeInstructions))
for _, msg := range recorded[0].Messages {
if msg.Role != "system" {
continue
}
// The overlay constant includes a placeholder that is replaced at
// runtime, so strip only the stable body text before checking.
overlayBody := strings.TrimSuffix(
chatd.PlanningOverlayPrompt,
"{{CODER_CHAT_PLAN_FILE_PATH_BLOCK}}",
)
sanitized := strings.ReplaceAll(msg.Content, overlayBody, "")
require.NotContains(t, sanitized, "propose_plan")
}
}
func TestSendMessageQueuesWhenWaitingWithQueuedBacklog(t *testing.T) {
t.Parallel()
@@ -2162,6 +2468,91 @@ func TestDynamicToolCallPausesAndResumes(t *testing.T) {
"expected second LLM call to include the submitted dynamic tool result")
}
func TestDynamicToolNamedProposePlanRemainsAvailableOutsidePlanMode(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
var streamedCallsMu sync.Mutex
streamedCalls := make([]chattest.OpenAIRequest, 0, 1)
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
if !req.Stream {
return chattest.OpenAINonStreamingResponse("Dynamic tool collision test")
}
streamedCallsMu.Lock()
streamedCalls = append(streamedCalls, chattest.OpenAIRequest{
Messages: append([]chattest.OpenAIMessage(nil), req.Messages...),
Tools: append([]chattest.OpenAITool(nil), req.Tools...),
Stream: req.Stream,
})
streamedCallsMu.Unlock()
return chattest.OpenAIStreamingResponse(
chattest.OpenAITextChunks("Dynamic tool list captured.")...,
)
})
user, org, model := seedChatDependenciesWithProvider(ctx, t, db, "openai-compat", openAIURL)
server := newActiveTestServer(t, db, ps)
dynamicToolsJSON, err := json.Marshal([]mcpgo.Tool{{
Name: "propose_plan",
Description: "A dynamic tool whose name collides with the hidden built-in.",
InputSchema: mcpgo.ToolInputSchema{
Type: "object",
Properties: map[string]any{
"input": map[string]any{"type": "string"},
},
Required: []string{"input"},
},
}})
require.NoError(t, err)
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "dynamic-propose-plan-collision",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("List the available tools."),
},
DynamicTools: dynamicToolsJSON,
})
require.NoError(t, err)
var chatResult database.Chat
require.Eventually(t, func() bool {
got, getErr := db.GetChatByID(ctx, chat.ID)
if getErr != nil {
return false
}
chatResult = got
return got.Status == database.ChatStatusWaiting || got.Status == database.ChatStatusError
}, testutil.WaitLong, testutil.IntervalFast)
if chatResult.Status == database.ChatStatusError {
require.FailNowf(t, "chat run failed", "last_error=%q", chatResult.LastError.String)
}
streamedCallsMu.Lock()
recordedCalls := append([]chattest.OpenAIRequest(nil), streamedCalls...)
streamedCallsMu.Unlock()
require.NotEmpty(t, recordedCalls)
var foundDynamicTool bool
for _, tool := range recordedCalls[0].Tools {
if tool.Function.Name == "propose_plan" {
foundDynamicTool = true
break
}
}
require.True(t, foundDynamicTool,
"expected the dynamic propose_plan tool to remain visible outside plan mode")
}
func TestDynamicToolCallMixedWithBuiltIn(t *testing.T) {
t.Parallel()
+54 -4
View File
@@ -43,6 +43,10 @@ const (
var (
ErrInterrupted = xerrors.New("chat interrupted")
ErrDynamicToolCall = xerrors.New("dynamic tool call")
// ErrStopAfterTool is returned when a tool listed in
// StopAfterTools produces a successful result, indicating
// the run should terminate cleanly after persistence.
ErrStopAfterTool = xerrors.New("stop after tool")
errStartupTimeout = xerrors.New(
"chat response did not start before the startup timeout",
@@ -114,6 +118,11 @@ type RunOptions struct {
// the chatloop persists partial results and exits with
// ErrDynamicToolCall instead of executing the tool.
DynamicToolNames map[string]bool
// StopAfterTools lists tool names that, when they produce a
// successful result, cause the run to stop after persisting
// the current step. This is used for plan turns where
// propose_plan should terminate the run on success.
StopAfterTools map[string]struct{}
// ModelConfig holds per-call LLM parameters (temperature,
// max tokens, etc.) read from the chat model configuration.
@@ -472,7 +481,7 @@ func Run(ctx context.Context, opts RunOptions) error {
}
// Execute only built-in tools.
toolResults = executeTools(ctx, opts.Tools, opts.ProviderTools, builtinCalls, opts.Metrics, provider, opts.BuiltinToolNames, func(tr fantasy.ToolResultContent, completedAt time.Time) {
toolResults = executeTools(ctx, opts.Tools, opts.ActiveTools, opts.ProviderTools, builtinCalls, opts.Metrics, provider, opts.BuiltinToolNames, func(tr fantasy.ToolResultContent, completedAt time.Time) {
recordToolResultTimestamp(&result, tr.ToolCallID, completedAt)
ssePart := chatprompt.PartFromContent(tr)
ssePart.CreatedAt = &completedAt
@@ -566,6 +575,12 @@ func Run(ctx context.Context, opts RunOptions) error {
lastUsage = result.usage
lastProviderMetadata = result.providerMetadata
// Check if any executed tool triggers an early stop.
if shouldStopAfterTools(opts.StopAfterTools, toolResults) {
tryCompactOnExit(ctx, opts, result.usage, result.providerMetadata)
return ErrStopAfterTool
}
// When chain mode is active (PreviousResponseID set), exit
// it after persisting the first chained step. Continuation
// steps include tool-result messages, which fantasy rejects
@@ -1022,6 +1037,7 @@ func processStepStream(
func executeTools(
ctx context.Context,
allTools []fantasy.AgentTool,
activeTools []string,
providerTools []ProviderTool,
toolCalls []fantasy.ToolCallContent,
metrics *Metrics,
@@ -1051,11 +1067,14 @@ func executeTools(
for _, t := range allTools {
toolMap[t.Info().Name] = t
}
providerRunnerNames := make(map[string]struct{}, len(providerTools))
// Include runners from provider tools so locally-executed
// provider tools (e.g. computer use) can be dispatched.
for _, pt := range providerTools {
if pt.Runner != nil {
toolMap[pt.Runner.Info().Name] = pt.Runner
name := pt.Runner.Info().Name
toolMap[name] = pt.Runner
providerRunnerNames[name] = struct{}{}
}
}
@@ -1081,7 +1100,7 @@ func executeTools(
// accurate individual completion times.
completedAt[i] = dbtime.Now()
}()
results[i] = executeSingleTool(ctx, toolMap, tc, metrics, provider, builtinToolNames)
results[i] = executeSingleTool(ctx, toolMap, tc, metrics, provider, builtinToolNames, activeTools, providerRunnerNames)
}()
}
wg.Wait()
@@ -1105,6 +1124,8 @@ func executeSingleTool(
metrics *Metrics,
provider string,
builtinToolNames map[string]bool,
activeTools []string,
providerRunnerNames map[string]struct{},
) fantasy.ToolResultContent {
result := fantasy.ToolResultContent{
ToolCallID: tc.ToolCallID,
@@ -1121,6 +1142,13 @@ func executeSingleTool(
)
}()
if _, isProviderRunner := providerRunnerNames[tc.ToolName]; !isProviderRunner && !isToolActive(tc.ToolName, activeTools) {
result.Result = fantasy.ToolResultOutputContentError{
Error: xerrors.New("Tool not active in this turn: " + tc.ToolName),
}
return result
}
tool, exists := toolMap[tc.ToolName]
if !exists {
result.Result = fantasy.ToolResultOutputContentError{
@@ -1325,6 +1353,10 @@ func tryCompactOnExit(
}
}
func isToolActive(name string, activeTools []string) bool {
return len(activeTools) == 0 || slices.Contains(activeTools, name)
}
// buildToolDefinitions converts AgentTool definitions into the
// fantasy.Tool slice expected by fantasy.Call. When activeTools
// is non-empty, only function tools whose name appears in the
@@ -1334,7 +1366,7 @@ func buildToolDefinitions(tools []fantasy.AgentTool, activeTools []string, provi
prepared := make([]fantasy.Tool, 0, len(tools)+len(providerTools))
for _, tool := range tools {
info := tool.Info()
if len(activeTools) > 0 && !slices.Contains(activeTools, info.Name) {
if !isToolActive(info.Name, activeTools) {
continue
}
@@ -1361,6 +1393,24 @@ func buildToolDefinitions(tools []fantasy.AgentTool, activeTools []string, provi
return prepared
}
// shouldStopAfterTools returns true if any tool result in the
// slice matches a name in stopTools and produced a successful
// (non-error) result.
func shouldStopAfterTools(stopTools map[string]struct{}, results []fantasy.ToolResultContent) bool {
if len(stopTools) == 0 {
return false
}
for _, tr := range results {
if _, ok := stopTools[tr.ToolName]; !ok {
continue
}
if _, isErr := tr.Result.(fantasy.ToolResultOutputContentError); !isErr {
return true
}
}
return false
}
func shouldApplyAnthropicPromptCaching(model fantasy.LanguageModel) bool {
if model == nil {
return false
+282
View File
@@ -101,6 +101,150 @@ func TestRun_ActiveToolsPrepareBehavior(t *testing.T) {
require.True(t, hasAnthropicEphemeralCacheControl(capturedCall.Prompt[4]))
}
func TestRun_ActiveToolsRejectsDisallowedExecution(t *testing.T) {
t.Parallel()
var blockedCalls atomic.Int32
blockedToolName := "write_file"
model := &chattest.FakeModel{
ProviderName: "fake",
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
return streamFromParts([]fantasy.StreamPart{
{Type: fantasy.StreamPartTypeToolInputStart, ID: "tc-blocked", ToolCallName: blockedToolName},
{Type: fantasy.StreamPartTypeToolInputDelta, ID: "tc-blocked", Delta: `{"path":"/tmp/nope"}`},
{Type: fantasy.StreamPartTypeToolInputEnd, ID: "tc-blocked"},
{
Type: fantasy.StreamPartTypeToolCall,
ID: "tc-blocked",
ToolCallName: blockedToolName,
ToolCallInput: `{"path":"/tmp/nope"}`,
},
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonToolCalls},
}), nil
},
}
blockedTool := fantasy.NewAgentTool(
blockedToolName,
"blocked tool",
func(context.Context, struct{}, fantasy.ToolCall) (fantasy.ToolResponse, error) {
blockedCalls.Add(1)
return fantasy.NewTextResponse("should not run"), nil
},
)
var persistedStep PersistedStep
err := Run(context.Background(), RunOptions{
Model: model,
Messages: []fantasy.Message{
textMessage(fantasy.MessageRoleUser, "try the blocked tool"),
},
Tools: []fantasy.AgentTool{
newNoopTool(activeToolName),
blockedTool,
},
ActiveTools: []string{activeToolName},
MaxSteps: 1,
PersistStep: func(_ context.Context, step PersistedStep) error {
persistedStep = step
return nil
},
})
require.NoError(t, err)
require.Zero(t, blockedCalls.Load(), "disallowed tool must not execute")
var foundToolError bool
for _, block := range persistedStep.Content {
toolResult, ok := fantasy.AsContentType[fantasy.ToolResultContent](block)
if !ok || toolResult.ToolName != blockedToolName {
continue
}
errResult, ok := toolResult.Result.(fantasy.ToolResultOutputContentError)
require.True(t, ok)
assert.EqualError(t, errResult.Error, "Tool not active in this turn: "+blockedToolName)
foundToolError = true
}
require.True(t, foundToolError, "persisted step should include the rejected tool result")
}
func TestRun_ActiveToolsAllowsProviderRunnerExecution(t *testing.T) {
t.Parallel()
providerRunnerName := "computer"
var runnerCalls atomic.Int32
model := &chattest.FakeModel{
ProviderName: "fake",
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
return streamFromParts([]fantasy.StreamPart{
{Type: fantasy.StreamPartTypeToolInputStart, ID: "tc-provider-runner", ToolCallName: providerRunnerName},
{Type: fantasy.StreamPartTypeToolInputDelta, ID: "tc-provider-runner", Delta: `{}`},
{Type: fantasy.StreamPartTypeToolInputEnd, ID: "tc-provider-runner"},
{
Type: fantasy.StreamPartTypeToolCall,
ID: "tc-provider-runner",
ToolCallName: providerRunnerName,
ToolCallInput: `{}`,
},
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonToolCalls},
}), nil
},
}
runnerTool := fantasy.NewAgentTool(
providerRunnerName,
"provider runner",
func(context.Context, struct{}, fantasy.ToolCall) (fantasy.ToolResponse, error) {
runnerCalls.Add(1)
return fantasy.NewTextResponse("ran provider runner"), nil
},
)
var persistedStep PersistedStep
err := Run(context.Background(), RunOptions{
Model: model,
Messages: []fantasy.Message{
textMessage(fantasy.MessageRoleUser, "use the computer"),
},
Tools: []fantasy.AgentTool{newNoopTool(activeToolName)},
ActiveTools: []string{activeToolName},
ProviderTools: []ProviderTool{
{
Definition: fantasy.FunctionTool{
Name: providerRunnerName,
Description: "provider runner",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{},
},
},
Runner: runnerTool,
},
},
MaxSteps: 1,
PersistStep: func(_ context.Context, step PersistedStep) error {
persistedStep = step
return nil
},
})
require.NoError(t, err)
require.Equal(t, int32(1), runnerCalls.Load(),
"provider runner should execute even when omitted from active tools")
var foundToolResult bool
for _, block := range persistedStep.Content {
toolResult, ok := fantasy.AsContentType[fantasy.ToolResultContent](block)
if !ok || toolResult.ToolName != providerRunnerName {
continue
}
textResult, ok := toolResult.Result.(fantasy.ToolResultOutputContentText)
require.True(t, ok)
assert.Equal(t, "ran provider runner", textResult.Text)
foundToolResult = true
}
require.True(t, foundToolResult,
"persisted step should include the provider runner result")
}
func TestProcessStepStream_AnthropicUsageMatchesFinalDelta(t *testing.T) {
t.Parallel()
@@ -921,6 +1065,144 @@ func TestRun_MultiStepToolExecution(t *testing.T) {
"tool-result timestamp must be >= tool-call timestamp")
}
func TestStopAfterTool_Success(t *testing.T) {
t.Parallel()
streamCalls := 0
model := &chattest.FakeModel{
ProviderName: "fake",
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
streamCalls++
return streamFromParts([]fantasy.StreamPart{
{Type: fantasy.StreamPartTypeToolInputStart, ID: "tc-plan", ToolCallName: "propose_plan"},
{Type: fantasy.StreamPartTypeToolInputDelta, ID: "tc-plan", Delta: `{"path":"/tmp/plan.md"}`},
{Type: fantasy.StreamPartTypeToolInputEnd, ID: "tc-plan"},
{
Type: fantasy.StreamPartTypeToolCall,
ID: "tc-plan",
ToolCallName: "propose_plan",
ToolCallInput: `{"path":"/tmp/plan.md"}`,
},
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonToolCalls},
}), nil
},
}
proposePlanTool := fantasy.NewAgentTool(
"propose_plan",
"writes a plan",
func(context.Context, struct{}, fantasy.ToolCall) (fantasy.ToolResponse, error) {
return fantasy.NewTextResponse("plan saved"), nil
},
)
var persistedSteps []PersistedStep
persistStepCalls := 0
err := Run(context.Background(), RunOptions{
Model: model,
Messages: []fantasy.Message{
textMessage(fantasy.MessageRoleUser, "propose a plan"),
},
Tools: []fantasy.AgentTool{proposePlanTool},
MaxSteps: 5,
StopAfterTools: map[string]struct{}{
"propose_plan": {},
},
PersistStep: func(_ context.Context, step PersistedStep) error {
persistStepCalls++
persistedSteps = append(persistedSteps, step)
return nil
},
})
require.ErrorIs(t, err, ErrStopAfterTool)
require.Equal(t, 1, streamCalls)
require.Equal(t, 1, persistStepCalls)
require.Len(t, persistedSteps, 1)
var foundToolResult bool
for _, block := range persistedSteps[0].Content {
toolResult, ok := fantasy.AsContentType[fantasy.ToolResultContent](block)
if !ok || toolResult.ToolName != "propose_plan" {
continue
}
foundToolResult = true
_, isErr := toolResult.Result.(fantasy.ToolResultOutputContentError)
require.False(t, isErr, "stop-after-tool should only trigger on successful tool results")
}
require.True(t, foundToolResult, "persisted step should include the successful tool result before stopping")
}
func TestStopAfterTool_IgnoresErrorResults(t *testing.T) {
t.Parallel()
streamCalls := 0
model := &chattest.FakeModel{
ProviderName: "fake",
StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
streamCalls++
if streamCalls == 1 {
return streamFromParts([]fantasy.StreamPart{
{Type: fantasy.StreamPartTypeToolInputStart, ID: "tc-plan", ToolCallName: "propose_plan"},
{Type: fantasy.StreamPartTypeToolInputDelta, ID: "tc-plan", Delta: `{"path":"/tmp/plan.md"}`},
{Type: fantasy.StreamPartTypeToolInputEnd, ID: "tc-plan"},
{
Type: fantasy.StreamPartTypeToolCall,
ID: "tc-plan",
ToolCallName: "propose_plan",
ToolCallInput: `{"path":"/tmp/plan.md"}`,
},
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonToolCalls},
}), nil
}
return streamFromParts([]fantasy.StreamPart{
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "tool failed, continue"},
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop},
}), nil
},
}
proposePlanTool := fantasy.NewAgentTool(
"propose_plan",
"writes a plan",
func(context.Context, struct{}, fantasy.ToolCall) (fantasy.ToolResponse, error) {
return fantasy.NewTextErrorResponse("plan failed"), nil
},
)
var persistedSteps []PersistedStep
err := Run(context.Background(), RunOptions{
Model: model,
Messages: []fantasy.Message{
textMessage(fantasy.MessageRoleUser, "propose a plan"),
},
Tools: []fantasy.AgentTool{proposePlanTool},
MaxSteps: 5,
StopAfterTools: map[string]struct{}{
"propose_plan": {},
},
PersistStep: func(_ context.Context, step PersistedStep) error {
persistedSteps = append(persistedSteps, step)
return nil
},
})
require.NoError(t, err)
require.Equal(t, 2, streamCalls)
require.Len(t, persistedSteps, 2)
var foundToolError bool
for _, block := range persistedSteps[0].Content {
toolResult, ok := fantasy.AsContentType[fantasy.ToolResultContent](block)
if !ok || toolResult.ToolName != "propose_plan" {
continue
}
_, foundToolError = toolResult.Result.(fantasy.ToolResultOutputContentError)
}
require.True(t, foundToolError, "first step should persist the failed tool result")
}
func TestRun_ParallelToolExecutionTimestamps(t *testing.T) {
t.Parallel()
+153
View File
@@ -0,0 +1,153 @@
package chattool
import (
"context"
"encoding/json"
"fmt"
"strings"
"charm.land/fantasy"
"golang.org/x/xerrors"
)
const (
askUserQuestionToolName = "ask_user_question"
askUserQuestionToolDesc = "Ask the user one or more structured clarification questions during plan mode. Use this instead of listing open questions in prose. Each question should have a short label, a detailed question, and 2-4 answer options."
)
var (
_ fantasy.AgentTool = (*askUserQuestionTool)(nil)
_ fantasy.Tool = (*askUserQuestionTool)(nil)
)
type askUserQuestionOption struct {
Label string `json:"label"`
Description string `json:"description"`
}
type askUserQuestion struct {
Header string `json:"header"`
Question string `json:"question"`
Options []askUserQuestionOption `json:"options"`
}
type askUserQuestionArgs struct {
Questions []askUserQuestion `json:"questions"`
}
// NewAskUserQuestionTool creates the ask_user_question tool.
func NewAskUserQuestionTool() fantasy.AgentTool {
return &askUserQuestionTool{}
}
type askUserQuestionTool struct {
providerOptions fantasy.ProviderOptions
}
func (*askUserQuestionTool) GetType() fantasy.ToolType {
return fantasy.ToolTypeFunction
}
func (*askUserQuestionTool) GetName() string {
return askUserQuestionToolName
}
func (*askUserQuestionTool) Info() fantasy.ToolInfo {
return fantasy.ToolInfo{
Name: askUserQuestionToolName,
Description: askUserQuestionToolDesc,
Parameters: map[string]any{
"questions": map[string]any{
"type": "array",
"description": "The structured clarification questions to present to the user.",
"minItems": 1,
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"header": map[string]any{
"type": "string",
"description": "A short label for the question.",
},
"question": map[string]any{
"type": "string",
"description": "The detailed question text.",
},
"options": map[string]any{
"type": "array",
"description": "The answer options the user can choose from. Do not include an 'Other' or freeform option; one is provided automatically by the UI.",
"minItems": 2,
"maxItems": 4,
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"label": map[string]any{
"type": "string",
"description": "A short answer label.",
},
"description": map[string]any{
"type": "string",
"description": "More detail about what this option means.",
},
},
"required": []string{"label", "description"},
},
},
},
"required": []string{"header", "question", "options"},
},
},
},
Required: []string{"questions"},
}
}
func (*askUserQuestionTool) Run(_ context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
var args askUserQuestionArgs
if err := json.Unmarshal([]byte(call.Input), &args); err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid parameters: %s", err)), nil
}
if err := validateAskUserQuestionArgs(args); err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
data, err := json.Marshal(map[string]any{"questions": args.Questions})
if err != nil {
return fantasy.NewTextErrorResponse("failed to marshal questions: " + err.Error()), nil
}
return fantasy.NewTextResponse(string(data)), nil
}
func (t *askUserQuestionTool) ProviderOptions() fantasy.ProviderOptions {
return t.providerOptions
}
func (t *askUserQuestionTool) SetProviderOptions(opts fantasy.ProviderOptions) {
t.providerOptions = opts
}
func validateAskUserQuestionArgs(args askUserQuestionArgs) error {
if len(args.Questions) == 0 {
return xerrors.New("questions is required")
}
for i, question := range args.Questions {
if strings.TrimSpace(question.Header) == "" {
return xerrors.Errorf("questions[%d].header is required", i)
}
if strings.TrimSpace(question.Question) == "" {
return xerrors.Errorf("questions[%d].question is required", i)
}
if len(question.Options) < 2 || len(question.Options) > 4 {
return xerrors.Errorf("questions[%d].options must contain 2-4 items", i)
}
for j, option := range question.Options {
if strings.TrimSpace(option.Label) == "" {
return xerrors.Errorf("questions[%d].options[%d].label is required", i, j)
}
if strings.TrimSpace(option.Description) == "" {
return xerrors.Errorf("questions[%d].options[%d].description is required", i, j)
}
}
}
return nil
}
@@ -0,0 +1,141 @@
package chattool //nolint:testpackage // Uses internal symbols.
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestValidateAskUserQuestionArgs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args askUserQuestionArgs
wantErr string
}{
{
name: "QuestionsRequired",
args: askUserQuestionArgs{},
wantErr: "questions is required",
},
{
name: "HeaderRequired",
args: askUserQuestionArgs{Questions: []askUserQuestion{{
Header: " \t ",
Question: "What should we build?",
Options: validAskUserQuestionOptions(2),
}}},
wantErr: "questions[0].header is required",
},
{
name: "QuestionRequired",
args: askUserQuestionArgs{Questions: []askUserQuestion{{
Header: "Scope",
Question: "\n\t ",
Options: validAskUserQuestionOptions(2),
}}},
wantErr: "questions[0].question is required",
},
{
name: "TooFewOptions",
args: askUserQuestionArgs{Questions: []askUserQuestion{{
Header: "Scope",
Question: "What should we build?",
Options: validAskUserQuestionOptions(1),
}}},
wantErr: "questions[0].options must contain 2-4 items",
},
{
name: "TooManyOptions",
args: askUserQuestionArgs{Questions: []askUserQuestion{{
Header: "Scope",
Question: "What should we build?",
Options: validAskUserQuestionOptions(5),
}}},
wantErr: "questions[0].options must contain 2-4 items",
},
{
name: "OptionLabelRequired",
args: askUserQuestionArgs{Questions: []askUserQuestion{{
Header: "Scope",
Question: "What should we build?",
Options: []askUserQuestionOption{
{Label: " ", Description: "Build the API first."},
{Label: "Frontend", Description: "Build the UI first."},
},
}}},
wantErr: "questions[0].options[0].label is required",
},
{
name: "OptionDescriptionRequired",
args: askUserQuestionArgs{Questions: []askUserQuestion{{
Header: "Scope",
Question: "What should we build?",
Options: []askUserQuestionOption{
{Label: "Backend", Description: "\t"},
{Label: "Frontend", Description: "Build the UI first."},
},
}}},
wantErr: "questions[0].options[0].description is required",
},
{
name: "ValidTwoOptions",
args: askUserQuestionArgs{Questions: []askUserQuestion{{
Header: "Scope",
Question: "What should we build?",
Options: validAskUserQuestionOptions(2),
}}},
},
{
name: "ValidFourOptions",
args: askUserQuestionArgs{Questions: []askUserQuestion{{
Header: "Scope",
Question: "What should we build?",
Options: validAskUserQuestionOptions(4),
}}},
},
{
name: "SecondQuestionInvalid",
args: askUserQuestionArgs{Questions: []askUserQuestion{
{
Header: "Scope",
Question: "What should we build?",
Options: validAskUserQuestionOptions(2),
},
{
Header: "Timeline",
Question: "\t ",
Options: validAskUserQuestionOptions(2),
},
}},
wantErr: "questions[1].question is required",
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
err := validateAskUserQuestionArgs(testCase.args)
if testCase.wantErr == "" {
require.NoError(t, err)
return
}
require.EqualError(t, err, testCase.wantErr)
})
}
}
func validAskUserQuestionOptions(count int) []askUserQuestionOption {
options := []askUserQuestionOption{
{Label: "Backend", Description: "Build the API first."},
{Label: "Frontend", Description: "Build the UI first."},
{Label: "Docs", Description: "Write the docs first."},
{Label: "Tests", Description: "Start with tests first."},
{Label: "Research", Description: "Investigate the problem first."},
}
return append([]askUserQuestionOption(nil), options[:count]...)
}
+20
View File
@@ -12,6 +12,7 @@ import (
type EditFilesOptions struct {
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
ResolvePlanPath func(context.Context) (chatPath string, home string, err error)
IsPlanTurn bool
}
type EditFilesArgs struct {
@@ -24,6 +25,20 @@ func EditFiles(options EditFilesOptions) fantasy.AgentTool {
"Perform search-and-replace edits on one or more files in the workspace."+
" Each file can have multiple edits applied atomically.",
func(ctx context.Context, args EditFilesArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
var planPath string
if options.IsPlanTurn && len(args.Files) > 0 {
resolvedPlanPath, err := resolvePlanTurnPath(ctx, options.ResolvePlanPath)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
for i := range args.Files {
args.Files[i].Path = strings.TrimSpace(args.Files[i].Path)
if args.Files[i].Path != resolvedPlanPath {
return fantasy.NewTextErrorResponse("during plan turns, edit_files is restricted to " + resolvedPlanPath), nil
}
}
planPath = resolvedPlanPath
}
if options.GetWorkspaceConn == nil {
return fantasy.NewTextErrorResponse("workspace connection resolver is not configured"), nil
}
@@ -31,6 +46,11 @@ func EditFiles(options EditFilesOptions) fantasy.AgentTool {
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
if planPath != "" {
if err := ensurePlanPathResolvesToItself(ctx, conn, planPath); err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
}
return executeEditFilesTool(ctx, conn, args, options.ResolvePlanPath)
},
)
+159
View File
@@ -2,6 +2,7 @@ package chattool_test
import (
"context"
"net/http"
"testing"
"charm.land/fantasy"
@@ -18,6 +19,164 @@ import (
func TestEditFiles(t *testing.T) {
t.Parallel()
t.Run("PlanTurnRejectsNonPlanPath", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
planPath := "/home/coder/.coder/plans/PLAN-test-uuid.md"
getWorkspaceConnCalled := false
tool := chattool.EditFiles(chattool.EditFilesOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
getWorkspaceConnCalled = true
return mockConn, nil
},
ResolvePlanPath: func(context.Context) (string, string, error) {
return planPath, "/home/coder", nil
},
IsPlanTurn: true,
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "edit_files",
Input: `{"files":[{"path":"/home/coder/README.md","edits":[{"search":"old","replace":"new"}]}]}`,
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Equal(t, "during plan turns, edit_files is restricted to "+planPath, resp.Content)
assert.False(t, getWorkspaceConnCalled)
})
t.Run("PlanTurnRejectsMixedPaths", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
planPath := "/home/coder/.coder/plans/PLAN-test-uuid.md"
getWorkspaceConnCalled := false
tool := chattool.EditFiles(chattool.EditFilesOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
getWorkspaceConnCalled = true
return mockConn, nil
},
ResolvePlanPath: func(context.Context) (string, string, error) {
return planPath, "/home/coder", nil
},
IsPlanTurn: true,
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "edit_files",
Input: `{"files":[` +
`{"path":"` + planPath + `","edits":[{"search":"old","replace":"new"}]},` +
`{"path":"/home/coder/README.md","edits":[{"search":"old","replace":"new"}]}` +
`]}`,
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Equal(t, "during plan turns, edit_files is restricted to "+planPath, resp.Content)
assert.False(t, getWorkspaceConnCalled)
})
t.Run("PlanTurnAllowsResolvedPlanPath", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
planPath := "/home/coder/.coder/plans/PLAN-test-uuid.md"
resolvePlanPathCalls := 0
mockConn.EXPECT().ResolvePath(gomock.Any(), planPath).Return(planPath, nil)
request := workspacesdk.FileEditRequest{Files: []workspacesdk.FileEdits{{
Path: planPath,
Edits: []workspacesdk.FileEdit{{
Search: "old",
Replace: "new",
}},
}}}
mockConn.EXPECT().EditFiles(gomock.Any(), request).Return(nil)
tool := chattool.EditFiles(chattool.EditFilesOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
ResolvePlanPath: func(context.Context) (string, string, error) {
resolvePlanPathCalls++
return planPath, "/home/coder", nil
},
IsPlanTurn: true,
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "edit_files",
Input: `{"files":[{"path":"` + planPath + `","edits":[{"search":"old","replace":"new"}]}]}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
assert.Equal(t, 1, resolvePlanPathCalls)
})
t.Run("PlanTurnAllowsLegacyAgentWithoutResolvePath", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
planPath := "/home/coder/.coder/plans/PLAN-test-uuid.md"
mockConn.EXPECT().
ResolvePath(gomock.Any(), planPath).
Return("", statusError{statusCode: http.StatusNotFound, message: "missing resolve-path endpoint"})
request := workspacesdk.FileEditRequest{Files: []workspacesdk.FileEdits{{
Path: planPath,
Edits: []workspacesdk.FileEdit{{
Search: "old",
Replace: "new",
}},
}}}
mockConn.EXPECT().EditFiles(gomock.Any(), request).Return(nil)
tool := chattool.EditFiles(chattool.EditFilesOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
ResolvePlanPath: func(context.Context) (string, string, error) {
return planPath, "/home/coder", nil
},
IsPlanTurn: true,
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "edit_files",
Input: `{"files":[{"path":"` + planPath + `","edits":[{"search":"old","replace":"new"}]}]}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
})
t.Run("PlanTurnRejectsSymlinkedPlanPath", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
planPath := "/home/coder/.coder/plans/PLAN-test-uuid.md"
mockConn.EXPECT().ResolvePath(gomock.Any(), planPath).Return("/home/coder/README.md", nil)
tool := chattool.EditFiles(chattool.EditFilesOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
ResolvePlanPath: func(context.Context) (string, string, error) {
return planPath, "/home/coder", nil
},
IsPlanTurn: true,
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "edit_files",
Input: `{"files":[{"path":"` + planPath + `","edits":[{"search":"old","replace":"new"}]}]}`,
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Equal(t, "the chat-specific plan path /home/coder/.coder/plans/PLAN-test-uuid.md resolves to /home/coder/README.md; symlinked plan paths are not allowed during plan turns", resp.Content)
})
t.Run("RejectsPlanPathsWhenResolvePlanPathIsConfigured", func(t *testing.T) {
t.Parallel()
+20
View File
@@ -53,6 +53,26 @@ func PlanPathForChat(home string, chatID uuid.UUID) string {
)
}
func resolvePlanTurnPath(
ctx context.Context,
resolvePlanPath func(context.Context) (chatPath string, home string, err error),
) (string, error) {
if resolvePlanPath == nil {
return "", xerrors.New("chat-specific plan path resolver is not configured")
}
planPath, _, err := resolvePlanPath(ctx)
if err != nil {
return "", xerrors.Errorf("resolve chat-specific plan path: %w", err)
}
planPath = strings.TrimSpace(planPath)
if planPath == "" {
return "", xerrors.New("chat-specific plan path is empty")
}
return planPath, nil
}
// chatd consumes agent-normalized POSIX paths. Workspace agents are
// expected to convert separators to forward slashes before these
// helpers run.
@@ -0,0 +1,19 @@
package chattool_test
func sharedPlanPathResolvedMessage(requestedPath, planPath string) string {
return "the plan path " + requestedPath +
" is no longer supported at the home root; use the chat-specific plan path: " + planPath
}
func planPathVerificationMessage(requestedPath string) string {
return "the plan path " + requestedPath +
" could not be verified because the workspace is currently unavailable to resolve the chat-specific plan path, try again shortly"
}
func editFilesBatchRejectedMessage(message string) string {
return message + "; no files in this batch were applied"
}
func relativePlanPathMessage() string {
return "plan files must use absolute paths; use the chat-specific absolute plan path"
}
@@ -46,6 +46,14 @@ func sharedPlanPathMessage(requestedPath, chatPath string) string {
)
}
func symlinkedPlanPathMessage(planPath, resolvedPath string) string {
return fmt.Sprintf(
"the chat-specific plan path %s resolves to %s; symlinked plan paths are not allowed during plan turns",
planPath,
resolvedPath,
)
}
func planPathVerificationMessage(requestedPath string) string {
return fmt.Sprintf(
"the plan path %s could not be verified because the workspace is currently unavailable to resolve the chat-specific plan path, try again shortly",
@@ -0,0 +1,54 @@
package chattool
import (
"context"
"net/http"
"path"
"path/filepath"
"strings"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
func ensurePlanPathResolvesToItself(
ctx context.Context,
conn workspacesdk.AgentConn,
planPath string,
) error {
if conn == nil {
return xerrors.New("workspace connection is required")
}
normalizedPlanPath := normalizeWorkspacePath(planPath)
resolvedPath, err := conn.ResolvePath(ctx, planPath)
if err != nil {
if resolvePathUnsupported(err) {
// Older workspace agents do not expose /resolve-path yet. Keep
// plan turns working during rolling upgrades, even though they
// cannot enforce the symlink guard until the agent is upgraded.
return nil
}
return xerrors.Errorf("resolve plan path: %w", err)
}
resolvedPath = normalizeWorkspacePath(resolvedPath)
if resolvedPath != normalizedPlanPath {
return xerrors.New(symlinkedPlanPathMessage(normalizedPlanPath, resolvedPath))
}
return nil
}
func resolvePathUnsupported(err error) bool {
var statusErr interface{ StatusCode() int }
return xerrors.As(err, &statusErr) && statusErr.StatusCode() == http.StatusNotFound
}
func normalizeWorkspacePath(pathString string) string {
pathString = strings.TrimSpace(pathString)
if pathString == "" {
return ""
}
return path.Clean(filepath.ToSlash(pathString))
}
+19
View File
@@ -19,6 +19,7 @@ type ProposePlanOptions struct {
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
ResolvePlanPath func(context.Context) (chatPath string, home string, err error)
StoreFile func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error)
IsPlanTurn bool
}
// ProposePlanArgs are the arguments for the propose_plan tool.
@@ -36,6 +37,21 @@ func ProposePlan(options ProposePlanOptions) fantasy.AgentTool {
"Pass the absolute file path to the plan. Important: use the chat-specific absolute plan path, 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.IsPlanTurn {
planPath, err := resolvePlanTurnPath(ctx, options.ResolvePlanPath)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
path := strings.TrimSpace(args.Path)
switch {
case path == "":
args.Path = planPath
case path != planPath:
return fantasy.NewTextErrorResponse("during plan turns, propose_plan path must be " + planPath), nil
default:
args.Path = path
}
}
if options.GetWorkspaceConn == nil {
return fantasy.NewTextErrorResponse("workspace connection resolver is not configured"), nil
}
@@ -90,6 +106,9 @@ func executeProposePlanTool(
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
if len(data) == 0 || strings.TrimSpace(string(data)) == "" {
return fantasy.NewTextErrorResponse("plan file is empty; write your plan to " + requestedPath + " before proposing"), nil
}
if int64(len(data)) > maxProposePlanSize {
return fantasy.NewTextErrorResponse("plan file exceeds 32 KiB size limit"), nil
}
+136 -381
View File
@@ -6,7 +6,6 @@ import (
"io"
"strings"
"testing"
"testing/iotest"
"charm.land/fantasy"
"github.com/google/uuid"
@@ -31,13 +30,13 @@ type proposePlanResponse struct {
func TestProposePlan(t *testing.T) {
t.Parallel()
t.Run("EmptyPathReturnsError", func(t *testing.T) {
t.Run("RejectsEmptyPath", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
storeFile, _ := fakeStoreFile(t)
tool := newProposePlanTool(t, mockConn, storeFile)
tool := newProposePlanToolWithPlanPath(t, mockConn, storeFile, nil, false)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "propose_plan",
@@ -45,326 +44,133 @@ func TestProposePlan(t *testing.T) {
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Contains(t, resp.Content, "path is required")
assert.Equal(t, "path is required (use the chat-specific absolute plan path)", resp.Content)
})
t.Run("WhitespaceOnlyPathReturnsError", func(t *testing.T) {
t.Run("RejectsNonMarkdownPath", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
storeFile, _ := fakeStoreFile(t)
tool := newProposePlanTool(t, mockConn, storeFile)
tool := newProposePlanToolWithPlanPath(t, mockConn, storeFile, nil, false)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "propose_plan",
Input: `{"path":" "}`,
Input: `{"path":"/home/coder/.coder/plans/PLAN-chat.txt"}`,
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Contains(t, resp.Content, "path is required")
assert.Equal(t, "path must end with .md", resp.Content)
})
t.Run("NonMdPathReturnsError", func(t *testing.T) {
t.Run("PlanTurnDefaultsEmptyPathToResolvedPath", 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("RelativePlanPathReturnsError", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
storeFile, _ := fakeStoreFile(t)
resolvePlanPathCalled := false
tool := newProposePlanToolWithPlanPath(
t,
mockConn,
storeFile,
func(context.Context) (string, string, error) {
resolvePlanPathCalled = 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":"plan.md"}`,
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.False(t, resolvePlanPathCalled)
assert.Equal(t, relativePlanPathMessage(), resp.Content)
})
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)
planPathCalled := false
tool := newProposePlanToolWithPlanPath(
t,
mockConn,
storeFile,
func(context.Context) (string, string, error) {
planPathCalled = true
return "/home/coder/.coder/plans/PLAN-xxx.md", "/home/coder", nil
},
)
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)
assert.True(t, planPathCalled)
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("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)
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("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(chattool.LegacySharedPlanPath, "/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, planPathVerificationMessage(chattool.LegacySharedPlanPath), resp.Content)
})
t.Run("PerChatPlanPathIsAllowed", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
chatPlanPath := "/home/coder/.coder/plans/PLAN-123e4567-e89b-12d3-a456-426614174000.md"
chatPlanPath := "/home/coder/.coder/plans/PLAN-chat.md"
mockConn.EXPECT().
ReadFile(gomock.Any(), chatPlanPath, int64(0), int64(32*1024+1)).
Return(io.NopCloser(strings.NewReader("# Per-Chat Plan")), "text/markdown", nil)
Return(io.NopCloser(strings.NewReader("# Plan")), "text/markdown", nil)
storeFile, stored := fakeStoreFile(t)
resolvePlanPathCalled := false
tool := newProposePlanToolWithPlanPath(
t,
mockConn,
storeFile,
func(context.Context) (string, string, error) {
resolvePlanPathCalled = true
return chatPlanPath, "/home/coder", nil
},
true,
)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "propose_plan",
Input: `{"path":""}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
result := decodeProposePlanResponse(t, resp)
assert.True(t, result.OK)
assert.Equal(t, chatPlanPath, 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, "# Plan", string(*stored))
})
t.Run("PlanTurnRejectsWrongPath", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
chatPlanPath := "/home/coder/.coder/plans/PLAN-chat.md"
storeFile, _ := fakeStoreFile(t)
tool := newProposePlanToolWithPlanPath(
t,
mockConn,
storeFile,
func(context.Context) (string, string, error) {
return chatPlanPath, "/home/coder", nil
},
true,
)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "propose_plan",
Input: `{"path":"/home/coder/README.md"}`,
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Equal(t, "during plan turns, propose_plan path must be "+chatPlanPath, resp.Content)
})
t.Run("RejectsReadFileErrors", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
chatPlanPath := "/home/coder/.coder/plans/PLAN-chat.md"
mockConn.EXPECT().
ReadFile(gomock.Any(), chatPlanPath, int64(0), int64(32*1024+1)).
Return(nil, "", xerrors.New("read failed"))
storeFile, _ := fakeStoreFile(t)
tool := newProposePlanToolWithPlanPath(t, mockConn, storeFile, nil, false)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "propose_plan",
Input: `{"path":"` + chatPlanPath + `"}`,
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Equal(t, "read failed", resp.Content)
})
t.Run("PlanTurnRejectsEmptyPlan", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
chatPlanPath := "/home/coder/.coder/plans/PLAN-chat.md"
mockConn.EXPECT().
ReadFile(gomock.Any(), chatPlanPath, int64(0), int64(32*1024+1)).
Return(io.NopCloser(strings.NewReader("")), "text/markdown", nil)
storeFile, stored := fakeStoreFile(t)
storeCalled := false
tool := newProposePlanToolWithPlanPath(
t,
mockConn,
func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error) {
storeCalled = true
return storeFile(ctx, name, mediaType, data)
},
func(context.Context) (string, string, error) {
return chatPlanPath, "/home/coder", nil
},
true,
)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
@@ -372,117 +178,83 @@ func TestProposePlan(t *testing.T) {
Input: `{"path":"` + chatPlanPath + `"}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
assert.False(t, resolvePlanPathCalled)
result := decodeProposePlanResponse(t, resp)
assert.True(t, result.OK)
assert.Equal(t, chatPlanPath, result.Path)
assert.Equal(t, []byte("# Per-Chat Plan"), *stored)
assert.True(t, resp.IsError)
assert.Contains(t, resp.Content, "plan file is empty")
assert.Contains(t, resp.Content, chatPlanPath)
assert.False(t, storeCalled)
assert.Nil(t, *stored)
})
t.Run("NestedPlanPathAllowedWhenResolverFails", func(t *testing.T) {
t.Run("RejectsOversizedPlan", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
chatPlanPath := "/home/coder/.coder/plans/PLAN-chat.md"
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)
ReadFile(gomock.Any(), chatPlanPath, int64(0), int64(32*1024+1)).
Return(io.NopCloser(strings.NewReader(strings.Repeat("x", 32*1024+1))), "text/markdown", nil)
storeFile, stored := fakeStoreFile(t)
storeCalled := false
tool := newProposePlanToolWithPlanPath(
t,
mockConn,
storeFile,
func(context.Context) (string, string, error) {
return "", "", xerrors.New("workspace unavailable")
func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error) {
storeCalled = true
return storeFile(ctx, name, mediaType, data)
},
nil,
false,
)
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)
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("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"}`,
Input: `{"path":"` + chatPlanPath + `"}`,
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Contains(t, resp.Content, "connection failed")
assert.Equal(t, "plan file exceeds 32 KiB size limit", resp.Content)
assert.False(t, storeCalled)
assert.Nil(t, *stored)
})
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.Run("PropagatesStoreFileErrors", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
chatPlanPath := "/home/coder/.coder/plans/PLAN-chat.md"
tool := chattool.ProposePlan(chattool.ProposePlanOptions{
GetWorkspaceConn: func(_ context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
mockConn.EXPECT().
ReadFile(gomock.Any(), chatPlanPath, int64(0), int64(32*1024+1)).
Return(io.NopCloser(strings.NewReader("# Plan")), "text/markdown", nil)
tool := newProposePlanToolWithPlanPath(
t,
mockConn,
func(context.Context, string, string, []byte) (uuid.UUID, error) {
return uuid.Nil, xerrors.New("store failed")
},
})
nil,
false,
)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "propose_plan",
Input: `{"path":"/home/coder/PLAN.md"}`,
Input: `{"path":"` + chatPlanPath + `"}`,
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Contains(t, resp.Content, "file storage is not configured")
assert.Equal(t, "failed to store plan file: store failed", resp.Content)
})
}
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),
resolvePlanPath func(context.Context) (string, string, error),
isPlanTurn bool,
) fantasy.AgentTool {
t.Helper()
return chattool.ProposePlan(chattool.ProposePlanOptions{
@@ -491,27 +263,10 @@ func newProposePlanToolWithPlanPath(
},
ResolvePlanPath: resolvePlanPath,
StoreFile: storeFile,
IsPlanTurn: isPlanTurn,
})
}
func sharedPlanPathResolvedMessage(requestedPath, planPath string) string {
return "the plan path " + requestedPath +
" is no longer supported at the home root; use the chat-specific plan path: " + planPath
}
func planPathVerificationMessage(requestedPath string) string {
return "the plan path " + requestedPath +
" could not be verified because the workspace is currently unavailable to resolve the chat-specific plan path, try again shortly"
}
func editFilesBatchRejectedMessage(message string) string {
return message + "; no files in this batch were applied"
}
func relativePlanPathMessage() string {
return "plan files must use absolute paths; use the chat-specific absolute plan path"
}
func fakeStoreFile(t *testing.T) (func(ctx context.Context, name string, mediaType string, data []byte) (uuid.UUID, error), *[]byte) {
t.Helper()
@@ -0,0 +1,19 @@
package chattool_test
import "fmt"
type statusError struct {
statusCode int
message string
}
func (e statusError) Error() string {
if e.message != "" {
return e.message
}
return fmt.Sprintf("status %d", e.statusCode)
}
func (e statusError) StatusCode() int {
return e.statusCode
}
+18
View File
@@ -12,6 +12,7 @@ import (
type WriteFileOptions struct {
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
ResolvePlanPath func(context.Context) (chatPath string, home string, err error)
IsPlanTurn bool
}
type WriteFileArgs struct {
@@ -24,6 +25,18 @@ func WriteFile(options WriteFileOptions) fantasy.AgentTool {
"write_file",
"Write a file to the workspace.",
func(ctx context.Context, args WriteFileArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
var planPath string
if options.IsPlanTurn {
args.Path = strings.TrimSpace(args.Path)
resolvedPlanPath, err := resolvePlanTurnPath(ctx, options.ResolvePlanPath)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
if args.Path != resolvedPlanPath {
return fantasy.NewTextErrorResponse("during plan turns, write_file is restricted to " + resolvedPlanPath), nil
}
planPath = resolvedPlanPath
}
if options.GetWorkspaceConn == nil {
return fantasy.NewTextErrorResponse("workspace connection resolver is not configured"), nil
}
@@ -31,6 +44,11 @@ func WriteFile(options WriteFileOptions) fantasy.AgentTool {
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
if planPath != "" {
if err := ensurePlanPathResolvesToItself(ctx, conn, planPath); err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
}
return executeWriteFileTool(ctx, conn, args, options.ResolvePlanPath)
},
)
+131
View File
@@ -3,6 +3,7 @@ package chattool_test
import (
"context"
"io"
"net/http"
"strings"
"testing"
@@ -20,6 +21,136 @@ import (
func TestWriteFile(t *testing.T) {
t.Parallel()
t.Run("PlanTurnRejectsNonPlanPath", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
planPath := "/home/coder/.coder/plans/PLAN-test-uuid.md"
getWorkspaceConnCalled := false
tool := chattool.WriteFile(chattool.WriteFileOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
getWorkspaceConnCalled = true
return mockConn, nil
},
ResolvePlanPath: func(context.Context) (string, string, error) {
return planPath, "/home/coder", nil
},
IsPlanTurn: true,
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "write_file",
Input: `{"path":"/home/coder/README.md","content":"# Plan"}`,
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Equal(t, "during plan turns, write_file is restricted to "+planPath, resp.Content)
assert.False(t, getWorkspaceConnCalled)
})
t.Run("PlanTurnAllowsResolvedPlanPath", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
planPath := "/home/coder/.coder/plans/PLAN-test-uuid.md"
resolvePlanPathCalls := 0
mockConn.EXPECT().ResolvePath(gomock.Any(), planPath).Return(planPath, nil)
mockConn.EXPECT().
WriteFile(gomock.Any(), planPath, gomock.Any()).
DoAndReturn(func(_ context.Context, path string, reader io.Reader) error {
data, err := io.ReadAll(reader)
require.NoError(t, err)
require.Equal(t, planPath, path)
require.Equal(t, "# Plan", string(data))
return nil
})
tool := chattool.WriteFile(chattool.WriteFileOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
ResolvePlanPath: func(context.Context) (string, string, error) {
resolvePlanPathCalls++
return planPath, "/home/coder", nil
},
IsPlanTurn: true,
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "write_file",
Input: `{"path":"` + planPath + `","content":"# Plan"}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
assert.Equal(t, 1, resolvePlanPathCalls)
assert.Equal(t, `{"ok":true}`, strings.TrimSpace(resp.Content))
})
t.Run("PlanTurnAllowsLegacyAgentWithoutResolvePath", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
planPath := "/home/coder/.coder/plans/PLAN-test-uuid.md"
mockConn.EXPECT().
ResolvePath(gomock.Any(), planPath).
Return("", statusError{statusCode: http.StatusNotFound, message: "missing resolve-path endpoint"})
mockConn.EXPECT().
WriteFile(gomock.Any(), planPath, gomock.Any()).
DoAndReturn(func(_ context.Context, path string, reader io.Reader) error {
data, err := io.ReadAll(reader)
require.NoError(t, err)
require.Equal(t, planPath, path)
require.Equal(t, "# Plan", string(data))
return nil
})
tool := chattool.WriteFile(chattool.WriteFileOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
ResolvePlanPath: func(context.Context) (string, string, error) {
return planPath, "/home/coder", nil
},
IsPlanTurn: true,
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "write_file",
Input: `{"path":"` + planPath + `","content":"# Plan"}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
assert.Equal(t, `{"ok":true}`, strings.TrimSpace(resp.Content))
})
t.Run("PlanTurnRejectsSymlinkedPlanPath", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockConn := agentconnmock.NewMockAgentConn(ctrl)
planPath := "/home/coder/.coder/plans/PLAN-test-uuid.md"
mockConn.EXPECT().ResolvePath(gomock.Any(), planPath).Return("/home/coder/README.md", nil)
tool := chattool.WriteFile(chattool.WriteFileOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return mockConn, nil
},
ResolvePlanPath: func(context.Context) (string, string, error) {
return planPath, "/home/coder", nil
},
IsPlanTurn: true,
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "write_file",
Input: `{"path":"` + planPath + `","content":"# Plan"}`,
})
require.NoError(t, err)
assert.True(t, resp.IsError)
assert.Equal(t, "the chat-specific plan path /home/coder/.coder/plans/PLAN-test-uuid.md resolves to /home/coder/README.md; symlinked plan paths are not allowed during plan turns", resp.Content)
})
t.Run("RejectsHomeRootPlanVariantsWhenResolvePlanPathIsConfigured", func(t *testing.T) {
t.Parallel()
+28 -5
View File
@@ -21,7 +21,7 @@ Ask concise clarifying questions only when:
- architecture, tooling, or style preferences would change the implementation;
- the action is destructive, irreversible, or expensive; or
- you cannot make progress with confidence.
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.
If a task is too ambiguous to implement with confidence, ask for clarification before proceeding.
</behavior>
<personality>
@@ -94,12 +94,35 @@ Once a workspace is available:
chat-specific path from the <plan-file-path> block below when it is
available.
3. Iterate on the plan with edit_files if needed.
4. Call propose_plan with the same absolute plan file path from the
<plan-file-path> block below.
5. Wait for the user to review and approve the plan before starting implementation.
4. Present the plan to the user and wait for review 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.
When the <plan-file-path> block below is present, use that exact path.
` + defaultSystemPromptPlanPathBlockPlaceholder + `
</planning>`
// PlanningOverlayPrompt contains plan-mode-only instructions appended
// when the chat is in plan mode.
const PlanningOverlayPrompt = `You are in Plan Mode.
Every response must work toward producing a plan.
The only intentional authored workspace artifact is the plan file at the path specified in the <plan-file-path> block below.
You may use execute and process_output for exploration, including cloning repositories, searching code, and running inspection commands needed to build the plan.
Do not use Plan Mode to implement the requested changes or intentionally modify project files outside the plan file.
If no workspace is attached to this chat yet, create and start one with create_workspace and start_workspace before investigating.
If the plan file already exists, read it first with read_file before replacing or refining it.
Use read_file, execute, process_output, list_templates, read_template, and spawn_agent to gather context. In Plan Mode, spawn_agent delegation is for investigation and planning support, not code writing or implementation.
Use write_file to create the plan file and edit_files to refine it.
Use ask_user_question for structured clarification instead of freeform questions.
When the plan is ready, call propose_plan with the plan file path.
After a successful propose_plan call, stop immediately. Do not produce follow-up output.
` + defaultSystemPromptPlanPathBlockPlaceholder
// PlanningSubagentOverlayPrompt contains plan-mode instructions for
// delegated child chats. Child chats may investigate with shell tools
// but should return findings to the parent instead of authoring the
// final plan.
const PlanningSubagentOverlayPrompt = `You are in Plan Mode as a delegated sub-agent.
Every response must help the parent agent produce a plan.
You may use read_file, execute, process_output, read_skill, and read_skill_file for exploration, including cloning repositories, searching code, and running inspection commands.
Do not implement changes or intentionally modify workspace files.
Return concise findings and recommendations to the parent agent.`
+30 -18
View File
@@ -97,26 +97,36 @@ func (p *Server) isDesktopEnabled(ctx context.Context) bool {
}
func (p *Server) subagentTools(ctx context.Context, currentChat func() database.Chat) []fantasy.AgentTool {
var planMode database.NullChatPlanMode
if currentChat != nil {
planMode = currentChat().PlanMode
}
spawnAgentDescription := "Spawn a delegated child agent to work on a clearly scoped, " +
"independent task in parallel. Use this when the task is " +
"self-contained and would benefit from a separate agent " +
"(e.g. fixing a specific bug, writing a single module, " +
"running a migration). Do NOT use for simple or quick " +
"operations you can handle directly with execute, " +
"read_file, or write_file - for example, reading a group " +
"of files and outputting them verbatim does not need a " +
"subagent. Reserve subagents for tasks that require " +
"intellectual work such as code analysis, writing new " +
"code, or complex refactoring. Be careful when running " +
"parallel subagents: if two subagents modify the same " +
"files they will conflict with each other, so ensure " +
"parallel subagent tasks are independent. " +
"The child agent receives the same workspace tools but " +
"cannot spawn its own subagents. After spawning, use " +
"wait_agent to collect the result."
if planMode.Valid && planMode.ChatPlanMode == database.ChatPlanModePlan {
spawnAgentDescription += " During plan mode, spawned agents may use shell commands for exploration, such as cloning repositories, searching code, and running inspection commands, but they must not implement changes or intentionally modify workspace files."
}
tools := []fantasy.AgentTool{
fantasy.NewAgentTool(
"spawn_agent",
"Spawn a delegated child agent to work on a clearly scoped, "+
"independent task in parallel. Use this when the task is "+
"self-contained and would benefit from a separate agent "+
"(e.g. fixing a specific bug, writing a single module, "+
"running a migration). Do NOT use for simple or quick "+
"operations you can handle directly with execute, "+
"read_file, or write_file - for example, reading a group "+
"of files and outputting them verbatim does not need a "+
"subagent. Reserve subagents for tasks that require "+
"intellectual work such as code analysis, writing new "+
"code, or complex refactoring. Be careful when running "+
"parallel subagents: if two subagents modify the same "+
"files they will conflict with each other, so ensure "+
"parallel subagent tasks are independent. "+
"The child agent receives the same workspace tools but "+
"cannot spawn its own subagents. After spawning, use "+
"wait_agent to collect the result.",
spawnAgentDescription,
func(ctx context.Context, args spawnAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
if currentChat == nil {
return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil
@@ -131,11 +141,12 @@ func (p *Server) subagentTools(ctx context.Context, currentChat func() database.
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
childChat, err := p.createChildSubagentChat(
childChat, err := p.createChildSubagentChatWithOptions(
ctx,
parent,
args.Prompt,
args.Title,
childSubagentChatOptions{},
)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
@@ -474,6 +485,7 @@ func (p *Server) createChildSubagentChatWithOptions(
LastModelConfigID: parent.LastModelConfigID,
Title: title,
Mode: opts.chatMode,
PlanMode: parent.PlanMode,
Status: database.ChatStatusPending,
MCPServerIDs: mcpServerIDs,
Labels: pqtype.NullRawMessage{
+37
View File
@@ -256,6 +256,43 @@ func TestCreateChildSubagentChatInheritsWorkspaceBinding(t *testing.T) {
require.Equal(t, parentChat.AgentID, childChat.AgentID)
}
func TestCreateChildSubagentChatCopiesPlanMode(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
user, org, model := seedInternalChatDeps(ctx, t, db)
planMode := database.NullChatPlanMode{
ChatPlanMode: database.ChatPlanModePlan,
Valid: true,
}
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
Title: "plan-parent",
ModelConfigID: model.ID,
PlanMode: planMode,
InitialUserContent: []codersdk.ChatMessagePart{
codersdk.ChatMessageText("plan this change"),
},
})
require.NoError(t, err)
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
require.Equal(t, planMode, parentChat.PlanMode)
child, err := server.createChildSubagentChat(ctx, parentChat, "inspect bindings", "")
require.NoError(t, err)
childChat, err := db.GetChatByID(ctx, child.ID)
require.NoError(t, err)
require.Equal(t, planMode, childChat.PlanMode)
}
func TestSpawnComputerUseAgent_NoAnthropicProvider(t *testing.T) {
t.Parallel()