Files
coder/coderd/x/chatd/subagent_context_internal_test.go
T

155 lines
5.8 KiB
Go

package chatd
import (
"context"
"database/sql"
"encoding/json"
"testing"
"charm.land/fantasy"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/aibridge"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
"github.com/coder/coder/v2/codersdk"
)
// createWorkspaceBoundParentChat creates a parent chat bound to a workspace
// agent that has already pushed a context snapshot, so CreateChat pins the chat
// to that snapshot at create time. It returns the pinned parent and the source
// of its single pinned resource, letting a spawned child be asserted to inherit
// the same pin.
func createWorkspaceBoundParentChat(
ctx context.Context,
t *testing.T,
db database.Store,
server *Server,
) (database.Chat, string) {
t.Helper()
user, org, model := seedInternalChatDeps(t, db)
tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{
OrganizationID: org.ID,
CreatedBy: user.ID,
})
tmpl := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
ActiveVersionID: tv.ID,
CreatedBy: user.ID,
})
ws := dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID,
OrganizationID: org.ID,
TemplateID: tmpl.ID,
})
pj := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
OrganizationID: org.ID,
CompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now()},
})
build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
WorkspaceID: ws.ID,
TemplateVersionID: tv.ID,
JobID: pj.ID,
Transition: database.WorkspaceTransitionStart,
})
res := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{
Transition: database.WorkspaceTransitionStart,
JobID: pj.ID,
})
agent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ResourceID: res.ID})
const source = "/home/coder/project/AGENTS.md"
seedAgentContext(ctx, t, db, agent.ID, source, []byte("instruction:parent"),
database.WorkspaceAgentContextBodyKindInstructionFile,
json.RawMessage(`{"instruction_file":{"content":"parent"}}`))
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "parent-with-context",
ModelConfigID: model.ID,
WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true},
BuildID: uuid.NullUUID{UUID: build.ID, Valid: true},
AgentID: uuid.NullUUID{UUID: agent.ID, Valid: true},
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
return parentChat, source
}
// TestSpawnComputerUseAgentInheritsPinnedContext verifies that a spawned
// subagent inherits its parent's pinned workspace context. The child shares the
// parent's workspace agent, so create-time hydration pins it to the same
// snapshot instead of copying any context through chat history. It also asserts
// the child is created in computer_use mode.
func TestSpawnComputerUseAgentInheritsPinnedContext(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
parentChat, wantSource := createWorkspaceBoundParentChat(ctx, t, db, server)
parentRes, err := db.ListChatContextResourcesByChatID(ctx, parentChat.ID)
require.NoError(t, err)
require.Len(t, parentRes, 1, "parent is pinned to its agent's snapshot at create")
require.Equal(t, wantSource, parentRes[0].Source)
require.NotNil(t, parentChat.ContextAggregateHash)
insertEnabledAnthropicProvider(t, db, parentChat.OwnerID)
// The direct DB insert above bypasses the pubsub event that production
// uses to invalidate the provider cache. Explicitly invalidate here so the
// background processing goroutine does not serve a stale provider list
// (OpenAI only) that was cached before the Anthropic provider was inserted.
server.configCache.InvalidateProviders()
ctx = aibridge.WithDelegatedAPIKeyID(ctx, testAPIKeyID(t, db, parentChat.OwnerID))
tools := server.subagentTools(ctx, func() database.Chat { return parentChat }, parentChat.LastModelConfigID)
tool := findToolByName(tools, spawnAgentToolName)
require.NotNil(t, tool)
resp, err := tool.Run(ctx, fantasy.ToolCall{
ID: "call-context",
Name: spawnAgentToolName,
Input: `{"type":"computer_use","prompt":"inspect bindings"}`,
})
require.NoError(t, err)
require.False(t, resp.IsError, "expected success but got: %s", resp.Content)
var result map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Content), &result))
childIDStr, ok := result["chat_id"].(string)
require.True(t, ok)
childID, err := uuid.Parse(childIDStr)
require.NoError(t, err)
childChat, err := db.GetChatByID(ctx, childID)
require.NoError(t, err)
require.True(t, childChat.Mode.Valid)
require.Equal(t, database.ChatModeComputerUse, childChat.Mode.ChatMode)
// The child shares the parent's workspace agent, so create-time hydration
// pins it to the same snapshot: it inherits the parent's pinned resources
// verbatim, with nothing copied through chat history.
childRes, err := db.ListChatContextResourcesByChatID(ctx, childID)
require.NoError(t, err)
require.Len(t, childRes, len(parentRes), "child inherits the parent's pinned resource set")
require.Equal(t, parentRes[0].Source, childRes[0].Source)
require.Equal(t, parentRes[0].ContentHash, childRes[0].ContentHash)
require.JSONEq(t, string(parentRes[0].Body), string(childRes[0].Body))
require.Equal(t, parentChat.ContextAggregateHash, childChat.ContextAggregateHash,
"child pins the same aggregate hash as the parent")
}