mirror of
https://github.com/coder/coder.git
synced 2026-09-22 21:22:17 +08:00
155 lines
5.8 KiB
Go
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")
|
|
}
|