fix(agents): predefined agents visible in web UI for channel members (#34)

* fix: SeedUserFiles uses agent-level USER.md as seed for predefined agents

For predefined agents, the wizard writes a populated USER.md (owner
name, language, notes) via agents.files.set into agent_context_files
(agent-level). On the first chat message, SeedUserFiles was seeding a
blank embedded template into user_context_files (per-user level).

LoadContextFiles for predefined agents lets per-user content override
agent-level content, so the blank per-user USER.md shadowed the
wizard-written content. The owner profile was stored in the DB but
never served to the agent, triggering unnecessary onboarding.

Fix: before falling back to the embedded template, check whether
agent_context_files already has a non-empty USER.md for the agent.
If it does, use that content as the per-user seed. The check is
conditional (predefined agent + USER.md not yet seeded per-user) so
it adds at most one extra DB read per new user.

The existing "don't overwrite" guard (hasFile check) is unchanged —
personalized per-user content written through conversation is still
never overwritten.

* test: add unit tests for SeedUserFiles agent-level USER.md seeding

Covers:
- Predefined agent: wizard-written USER.md at agent level is used as
  per-user seed (primary regression test for the bug fix)
- Predefined agent: falls back to blank embedded template when no
  agent-level USER.md exists
- Predefined agent: existing per-user content is never overwritten
- Open agent: embedded template path is unaffected
- Predefined agent: BOOTSTRAP.md uses BOOTSTRAP_PREDEFINED.md template
- Idempotency: second SeedUserFiles call seeds nothing

* fix(bootstrap): SeedUserFiles uses agent-level USER.md as seed for predefined agents

Use map[string]string for agent-level files (more general, extensible)
and clean continue-based loop flow, matching the investigation spec
in plans/goclaw-pr/goclaw-pr-seed-user-files.md exactly.

Previous implementation used a single string and if/else structure —
functionally correct but diverged from the spec in variable type,
load condition, and loop exit pattern.

* fix(agents): predefined agents visible in web UI for channel members

Two bugs combined to make wizard-installed predefined agents invisible
in the web UI for all real users.

Bug 1 — agents.create WS hardcoded owner_id = "system":
- Add `owner_ids []string` to WS params struct (omitempty, backward compat)
- Resolve ownerID: use first entry if provided, fall back to "system"
- Allows external provisioning tools to set a real user as owner

Bug 2 — ListAccessible SQL excluded system-owned predefined agents:
- Extend WHERE clause with 4th OR: predefined agents are visible when
  the requesting user appears in any enabled channel's allow_from list
- Uses EXISTS + jsonb_array_elements_text(ci.config->'allow_from')
- No schema migration needed — allow_from already populated by wizard

Also adds 4 unit tests for Bug 1 (agents_create_owner_test.go):
- UsesProvidedOwnerID, FallsBackToSystem_WhenAbsent,
  FallsBackToSystem_WhenEmpty, MultipleOwnerIDs_UsesFirst
This commit is contained in:
therichardngai-code authored and GitHub committed 2026-03-01 21:17:32 +07:00
1 parent 419935b9f9
commit 3cbdd8d778
5 files changed
+568 -9

No files matched your search

+38 -2
View File
@@ -91,6 +91,12 @@ var userSeedFilesPredefined = []string{
// For "open" agents: all 7 files (including BOOTSTRAP.md).
// For "predefined" agents: USER.md + BOOTSTRAP.md (user-focused onboarding template).
// Only writes files that don't already exist — safe to call multiple times.
//
// For predefined agents seeding USER.md: if the agent already has a populated
// USER.md in agent_context_files (e.g. written by the wizard or management dashboard),
// that content is used as the per-user seed instead of the blank embedded template.
// This ensures wizard-configured owner profiles are preserved on first chat.
//
// Returns the list of file names that were seeded.
func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uuid.UUID, userID, agentType string) ([]string, error) {
files := userSeedFilesOpen
@@ -98,7 +104,7 @@ func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uui
files = userSeedFilesPredefined
}
// Check existing files to avoid overwriting personalized content
// Check existing per-user files to avoid overwriting personalized content
existing, err := agentStore.GetUserContextFiles(ctx, agentID, userID)
if err != nil {
return nil, err
@@ -110,10 +116,40 @@ func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uui
}
}
// For predefined agents: load agent-level files once to use as seed fallback.
// USER.md at agent-level may contain a pre-configured owner profile (e.g. set by
// the wizard or management dashboard). Use it as the per-user seed instead of the
// blank embedded template so the agent starts with the correct owner context.
var agentLevelFiles map[string]string
if agentType == store.AgentTypePredefined {
agentFiles, err := agentStore.GetAgentContextFiles(ctx, agentID)
if err == nil && len(agentFiles) > 0 {
agentLevelFiles = make(map[string]string, len(agentFiles))
for _, f := range agentFiles {
if f.Content != "" {
agentLevelFiles[f.FileName] = f.Content
}
}
}
}
var seeded []string
for _, name := range files {
if hasFile[name] {
continue // already has content, don't overwrite
continue // already has personalized content, don't overwrite
}
// For predefined agents seeding USER.md: prefer agent-level content as seed.
// This propagates wizard/dashboard-configured owner profile to the first user.
if agentType == store.AgentTypePredefined && name == UserFile {
if agentContent, ok := agentLevelFiles[name]; ok {
if err := agentStore.SetUserContextFile(ctx, agentID, userID, name, agentContent); err != nil {
return seeded, err
}
seeded = append(seeded, name)
continue
}
// No agent-level USER.md → fall through to blank embedded template
}
// Predefined agents use a user-focused bootstrap template
+279
View File
@@ -0,0 +1,279 @@
package bootstrap
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// ---- minimal AgentStore stub for seed tests ----
type seedStubStore struct {
// agent-level files (simulates agent_context_files)
agentFiles map[string]string // fileName → content
// per-user files (simulates user_context_files)
userFiles map[string]string // fileName → content (shared across all users for simplicity)
// captures what was written per-user: fileName → content
seededUserFiles map[string]string
}
func newSeedStub() *seedStubStore {
return &seedStubStore{
agentFiles: make(map[string]string),
userFiles: make(map[string]string),
seededUserFiles: make(map[string]string),
}
}
func (s *seedStubStore) GetAgentContextFiles(_ context.Context, _ uuid.UUID) ([]store.AgentContextFileData, error) {
var out []store.AgentContextFileData
for name, content := range s.agentFiles {
out = append(out, store.AgentContextFileData{FileName: name, Content: content})
}
return out, nil
}
func (s *seedStubStore) SetAgentContextFile(_ context.Context, _ uuid.UUID, name, content string) error {
s.agentFiles[name] = content
return nil
}
func (s *seedStubStore) GetUserContextFiles(_ context.Context, _ uuid.UUID, _ string) ([]store.UserContextFileData, error) {
var out []store.UserContextFileData
for name, content := range s.userFiles {
out = append(out, store.UserContextFileData{FileName: name, Content: content})
}
return out, nil
}
func (s *seedStubStore) SetUserContextFile(_ context.Context, _ uuid.UUID, _, name, content string) error {
s.seededUserFiles[name] = content
return nil
}
func (s *seedStubStore) DeleteUserContextFile(_ context.Context, _ uuid.UUID, _, _ string) error {
return nil
}
// Remaining interface methods — not exercised.
func (s *seedStubStore) Create(_ context.Context, _ *store.AgentData) error { return nil }
func (s *seedStubStore) GetByKey(_ context.Context, _ string) (*store.AgentData, error) { return nil, nil }
func (s *seedStubStore) GetByID(_ context.Context, _ uuid.UUID) (*store.AgentData, error) { return nil, nil }
func (s *seedStubStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error { return nil }
func (s *seedStubStore) Delete(_ context.Context, _ uuid.UUID) error { return nil }
func (s *seedStubStore) List(_ context.Context, _ string) ([]store.AgentData, error) { return nil, nil }
func (s *seedStubStore) ShareAgent(_ context.Context, _ uuid.UUID, _, _, _ string) error { return nil }
func (s *seedStubStore) RevokeShare(_ context.Context, _ uuid.UUID, _ string) error { return nil }
func (s *seedStubStore) ListShares(_ context.Context, _ uuid.UUID) ([]store.AgentShareData, error) {
return nil, nil
}
func (s *seedStubStore) CanAccess(_ context.Context, _ uuid.UUID, _ string) (bool, string, error) {
return true, "admin", nil
}
func (s *seedStubStore) ListAccessible(_ context.Context, _ string) ([]store.AgentData, error) {
return nil, nil
}
func (s *seedStubStore) GetUserOverride(_ context.Context, _ uuid.UUID, _ string) (*store.UserAgentOverrideData, error) {
return nil, nil
}
func (s *seedStubStore) SetUserOverride(_ context.Context, _ *store.UserAgentOverrideData) error {
return nil
}
func (s *seedStubStore) GetOrCreateUserProfile(_ context.Context, _ uuid.UUID, _, _ string) (bool, error) {
return false, nil
}
func (s *seedStubStore) IsGroupFileWriter(_ context.Context, _ uuid.UUID, _, _ string) (bool, error) {
return false, nil
}
func (s *seedStubStore) AddGroupFileWriter(_ context.Context, _ uuid.UUID, _, _, _, _ string) error {
return nil
}
func (s *seedStubStore) RemoveGroupFileWriter(_ context.Context, _ uuid.UUID, _, _ string) error {
return nil
}
func (s *seedStubStore) ListGroupFileWriters(_ context.Context, _ uuid.UUID, _ string) ([]store.GroupFileWriterData, error) {
return nil, nil
}
// ---- Tests ----
// TestSeedUserFiles_PredefinedAgent_UsesAgentLevelUserMD is the primary regression test.
// When a predefined agent has wizard-written USER.md in agent_context_files, SeedUserFiles
// must seed that content into user_context_files — NOT the blank embedded template.
func TestSeedUserFiles_PredefinedAgent_UsesAgentLevelUserMD(t *testing.T) {
as := newSeedStub()
agentID := uuid.New()
wizardContent := "# User Profile\nOwner: Alice\nLanguage: English\nNotes: Prefers concise answers"
// Simulate wizard writing USER.md at agent level via agents.files.set
as.agentFiles[UserFile] = wizardContent
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-alice", store.AgentTypePredefined)
if err != nil {
t.Fatalf("SeedUserFiles returned error: %v", err)
}
// USER.md must be in the seeded list
foundUserMD := false
for _, f := range seeded {
if f == UserFile {
foundUserMD = true
}
}
if !foundUserMD {
t.Errorf("USER.md not in seeded files list: %v", seeded)
}
// The seeded USER.md must contain wizard content, not the blank template
got, ok := as.seededUserFiles[UserFile]
if !ok {
t.Fatal("USER.md was not written to user_context_files")
}
if got != wizardContent {
t.Errorf("seeded USER.md content mismatch:\n want: %q\n got: %q", wizardContent, got)
}
}
// TestSeedUserFiles_PredefinedAgent_FallsBackToTemplateWhenNoAgentLevelUserMD verifies
// that when there is no wizard-written USER.md at agent level, the blank template is used.
func TestSeedUserFiles_PredefinedAgent_FallsBackToTemplateWhenNoAgentLevelUserMD(t *testing.T) {
as := newSeedStub()
agentID := uuid.New()
// No agent-level USER.md — wizard did not write one
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-bob", store.AgentTypePredefined)
if err != nil {
t.Fatalf("SeedUserFiles returned error: %v", err)
}
// USER.md should still be seeded (from embedded template)
foundUserMD := false
for _, f := range seeded {
if f == UserFile {
foundUserMD = true
}
}
if !foundUserMD {
t.Errorf("USER.md should be seeded from template when no agent-level file exists: %v", seeded)
}
// Content should be the embedded template (non-empty — the template file exists)
got, ok := as.seededUserFiles[UserFile]
if !ok {
t.Fatal("USER.md was not written to user_context_files")
}
if got == "" {
t.Error("seeded USER.md should not be empty (expected embedded template content)")
}
}
// TestSeedUserFiles_PredefinedAgent_DoesNotOverwriteExistingPerUserContent verifies
// that personalized per-user USER.md written via conversation is never overwritten.
func TestSeedUserFiles_PredefinedAgent_DoesNotOverwriteExistingPerUserContent(t *testing.T) {
as := newSeedStub()
agentID := uuid.New()
personalContent := "# User Profile\nMy customized personal content"
// Pre-populate per-user USER.md (simulates user who already chatted and personalized)
as.userFiles[UserFile] = personalContent
// Also set wizard content at agent level
as.agentFiles[UserFile] = "wizard content that should NOT override personal content"
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-charlie", store.AgentTypePredefined)
if err != nil {
t.Fatalf("SeedUserFiles returned error: %v", err)
}
// USER.md must NOT be in the seeded list (existing content, should skip)
for _, f := range seeded {
if f == UserFile {
t.Error("USER.md should NOT be re-seeded when per-user content already exists")
}
}
// SetUserContextFile must not have been called for USER.md
if _, wrote := as.seededUserFiles[UserFile]; wrote {
t.Error("SetUserContextFile should not be called when per-user USER.md already has content")
}
}
// TestSeedUserFiles_OpenAgent_UsesEmbeddedTemplate verifies that open agents
// are completely unaffected — they still receive embedded templates per-user.
func TestSeedUserFiles_OpenAgent_UsesEmbeddedTemplate(t *testing.T) {
as := newSeedStub()
agentID := uuid.New()
// Open agents should never check agent_context_files for USER.md
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-dave", store.AgentTypeOpen)
if err != nil {
t.Fatalf("SeedUserFiles returned error: %v", err)
}
// Open agents seed the full set: AGENTS.md, SOUL.md, IDENTITY.md, USER.md, BOOTSTRAP.md
expectedFiles := map[string]bool{
AgentsFile: true, SoulFile: true, IdentityFile: true, UserFile: true, BootstrapFile: true,
}
for _, f := range seeded {
delete(expectedFiles, f)
}
if len(expectedFiles) > 0 {
t.Errorf("open agent: missing seeded files: %v", expectedFiles)
}
// USER.md must have been written using embedded template (non-empty)
got, ok := as.seededUserFiles[UserFile]
if !ok {
t.Fatal("open agent: USER.md was not written to user_context_files")
}
if got == "" {
t.Error("open agent: seeded USER.md should not be empty")
}
}
// TestSeedUserFiles_PredefinedAgent_BootstrapUsesCorrectTemplate verifies that
// BOOTSTRAP.md is still seeded from BOOTSTRAP_PREDEFINED.md for predefined agents.
func TestSeedUserFiles_PredefinedAgent_BootstrapUsesCorrectTemplate(t *testing.T) {
as := newSeedStub()
agentID := uuid.New()
_, err := SeedUserFiles(context.Background(), as, agentID, "user-eve", store.AgentTypePredefined)
if err != nil {
t.Fatalf("SeedUserFiles returned error: %v", err)
}
// BOOTSTRAP.md must have been seeded
got, ok := as.seededUserFiles[BootstrapFile]
if !ok {
t.Fatal("predefined agent: BOOTSTRAP.md was not seeded")
}
if got == "" {
t.Error("predefined agent: BOOTSTRAP.md should have content from BOOTSTRAP_PREDEFINED.md template")
}
}
// TestSeedUserFiles_IdempotentOnSecondCall verifies that calling SeedUserFiles
// a second time for the same user does not re-seed already-present files.
func TestSeedUserFiles_IdempotentOnSecondCall(t *testing.T) {
as := newSeedStub()
agentID := uuid.New()
// First call — seeds files
SeedUserFiles(context.Background(), as, agentID, "user-frank", store.AgentTypePredefined)
// Simulate what the first call wrote (move seededUserFiles → userFiles)
for k, v := range as.seededUserFiles {
as.userFiles[k] = v
}
as.seededUserFiles = make(map[string]string)
// Second call — must seed nothing (all files already exist)
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-frank", store.AgentTypePredefined)
if err != nil {
t.Fatalf("second SeedUserFiles returned error: %v", err)
}
if len(seeded) != 0 {
t.Errorf("second call should seed nothing, but seeded: %v", seeded)
}
if len(as.seededUserFiles) != 0 {
t.Errorf("second call should not write any files, but wrote: %v", as.seededUserFiles)
}
}
+14 -6
View File
@@ -103,11 +103,12 @@ func (m *AgentsMethods) handleList(_ context.Context, client *gateway.Client, re
func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
var params struct {
Name string `json:"name"`
Workspace string `json:"workspace"`
Emoji string `json:"emoji"`
Avatar string `json:"avatar"`
AgentType string `json:"agent_type"` // "open" (default) or "predefined"
Name string `json:"name"`
Workspace string `json:"workspace"`
Emoji string `json:"emoji"`
Avatar string `json:"avatar"`
AgentType string `json:"agent_type"` // "open" (default) or "predefined"
OwnerIDs []string `json:"owner_ids,omitempty"` // first entry used as DB owner_id; falls back to "system"
// Per-agent config overrides (managed mode only)
ToolsConfig json.RawMessage `json:"tools_config,omitempty"`
SubagentsConfig json.RawMessage `json:"subagents_config,omitempty"`
@@ -155,10 +156,17 @@ func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client,
return
}
// Resolve owner: use first provided ID so external provisioning tools (e.g. goclaw-wizards)
// can set a real user as owner at creation time. Falls back to "system" for backward compat.
ownerID := "system"
if len(params.OwnerIDs) > 0 && params.OwnerIDs[0] != "" {
ownerID = params.OwnerIDs[0]
}
agentData := &store.AgentData{
AgentKey: agentID,
DisplayName: params.Name,
OwnerID: "system",
OwnerID: ownerID,
AgentType: agentType,
Provider: m.cfg.Agents.Defaults.Provider,
Model: m.cfg.Agents.Defaults.Model,
@@ -0,0 +1,221 @@
package methods
import (
"context"
"encoding/json"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// ---- stub AgentStore that captures Create calls ----
type createCaptureStore struct {
created *store.AgentData
}
func (s *createCaptureStore) Create(_ context.Context, d *store.AgentData) error {
d.ID = uuid.New() // simulate DB-assigned UUID so SeedToStore doesn't panic
s.created = d
return nil
}
func (s *createCaptureStore) GetByKey(_ context.Context, _ string) (*store.AgentData, error) {
return nil, nil // agent does not exist yet → allows creation to proceed
}
func (s *createCaptureStore) GetByID(_ context.Context, _ uuid.UUID) (*store.AgentData, error) {
return nil, nil
}
func (s *createCaptureStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error {
return nil
}
func (s *createCaptureStore) Delete(_ context.Context, _ uuid.UUID) error { return nil }
func (s *createCaptureStore) List(_ context.Context, _ string) ([]store.AgentData, error) {
return nil, nil
}
func (s *createCaptureStore) ShareAgent(_ context.Context, _ uuid.UUID, _, _, _ string) error {
return nil
}
func (s *createCaptureStore) RevokeShare(_ context.Context, _ uuid.UUID, _ string) error {
return nil
}
func (s *createCaptureStore) ListShares(_ context.Context, _ uuid.UUID) ([]store.AgentShareData, error) {
return nil, nil
}
func (s *createCaptureStore) CanAccess(_ context.Context, _ uuid.UUID, _ string) (bool, string, error) {
return true, "owner", nil
}
func (s *createCaptureStore) ListAccessible(_ context.Context, _ string) ([]store.AgentData, error) {
return nil, nil
}
func (s *createCaptureStore) GetAgentContextFiles(_ context.Context, _ uuid.UUID) ([]store.AgentContextFileData, error) {
return nil, nil
}
func (s *createCaptureStore) SetAgentContextFile(_ context.Context, _ uuid.UUID, _, _ string) error {
return nil
}
func (s *createCaptureStore) GetUserContextFiles(_ context.Context, _ uuid.UUID, _ string) ([]store.UserContextFileData, error) {
return nil, nil
}
func (s *createCaptureStore) SetUserContextFile(_ context.Context, _ uuid.UUID, _, _, _ string) error {
return nil
}
func (s *createCaptureStore) DeleteUserContextFile(_ context.Context, _ uuid.UUID, _, _ string) error {
return nil
}
func (s *createCaptureStore) GetUserOverride(_ context.Context, _ uuid.UUID, _ string) (*store.UserAgentOverrideData, error) {
return nil, nil
}
func (s *createCaptureStore) SetUserOverride(_ context.Context, _ *store.UserAgentOverrideData) error {
return nil
}
func (s *createCaptureStore) GetOrCreateUserProfile(_ context.Context, _ uuid.UUID, _, _ string) (bool, error) {
return false, nil
}
func (s *createCaptureStore) IsGroupFileWriter(_ context.Context, _ uuid.UUID, _, _ string) (bool, error) {
return false, nil
}
func (s *createCaptureStore) AddGroupFileWriter(_ context.Context, _ uuid.UUID, _, _, _, _ string) error {
return nil
}
func (s *createCaptureStore) RemoveGroupFileWriter(_ context.Context, _ uuid.UUID, _, _ string) error {
return nil
}
func (s *createCaptureStore) ListGroupFileWriters(_ context.Context, _ uuid.UUID, _ string) ([]store.GroupFileWriterData, error) {
return nil, nil
}
// ---- helpers ----
// minimalConfig returns a config sufficient for handleCreate (provider + model defaults only).
func minimalConfig() *config.Config {
cfg := &config.Config{}
cfg.Agents.Defaults.Provider = "anthropic"
cfg.Agents.Defaults.Model = "claude-3-5-haiku-20241022"
return cfg
}
// buildCreateRequest marshals params into a RequestFrame for agents.create.
func buildCreateRequest(t *testing.T, params map[string]any) *protocol.RequestFrame {
t.Helper()
raw, err := json.Marshal(params)
if err != nil {
t.Fatalf("marshal params: %v", err)
}
return &protocol.RequestFrame{
ID: "test-req-1",
Method: protocol.MethodAgentsCreate,
Params: raw,
}
}
// nullClient returns a zero-value Client. Its send channel is nil, so SendResponse
// safely falls to the select default branch — no panic, response silently dropped.
func nullClient() *gateway.Client {
return &gateway.Client{}
}
// newManagedMethods returns AgentsMethods wired for managed mode with the given stub store.
func newManagedMethods(t *testing.T, stub store.AgentStore) *AgentsMethods {
t.Helper()
return &AgentsMethods{
agents: agent.NewRouter(),
cfg: minimalConfig(),
workspace: t.TempDir(),
agentStore: stub,
isManaged: true,
}
}
// ---- Tests ----
// TestHandleCreate_UsesProvidedOwnerID verifies that when owner_ids is supplied,
// the first entry is used as the agent's owner_id in the DB — not "system".
func TestHandleCreate_UsesProvidedOwnerID(t *testing.T) {
stub := &createCaptureStore{}
m := newManagedMethods(t, stub)
req := buildCreateRequest(t, map[string]any{
"name": "Test Agent",
"agent_type": "predefined",
"owner_ids": []string{"8514594032"},
})
m.handleCreate(context.Background(), nullClient(), req)
if stub.created == nil {
t.Fatal("agentStore.Create was not called")
}
if stub.created.OwnerID != "8514594032" {
t.Errorf("OwnerID = %q, want %q", stub.created.OwnerID, "8514594032")
}
}
// TestHandleCreate_FallsBackToSystem_WhenOwnerIDsAbsent verifies that omitting
// owner_ids preserves backward compat — agent is created with owner_id = "system".
func TestHandleCreate_FallsBackToSystem_WhenOwnerIDsAbsent(t *testing.T) {
stub := &createCaptureStore{}
m := newManagedMethods(t, stub)
req := buildCreateRequest(t, map[string]any{
"name": "Test Agent Two",
"agent_type": "predefined",
// owner_ids intentionally absent
})
m.handleCreate(context.Background(), nullClient(), req)
if stub.created == nil {
t.Fatal("agentStore.Create was not called")
}
if stub.created.OwnerID != "system" {
t.Errorf("OwnerID = %q, want %q", stub.created.OwnerID, "system")
}
}
// TestHandleCreate_FallsBackToSystem_WhenOwnerIDsEmpty verifies that an empty
// owner_ids slice also falls back to "system".
func TestHandleCreate_FallsBackToSystem_WhenOwnerIDsEmpty(t *testing.T) {
stub := &createCaptureStore{}
m := newManagedMethods(t, stub)
req := buildCreateRequest(t, map[string]any{
"name": "Test Agent Three",
"owner_ids": []string{},
})
m.handleCreate(context.Background(), nullClient(), req)
if stub.created == nil {
t.Fatal("agentStore.Create was not called")
}
if stub.created.OwnerID != "system" {
t.Errorf("OwnerID = %q, want %q", stub.created.OwnerID, "system")
}
}
// TestHandleCreate_MultipleOwnerIDs_UsesFirst verifies that when multiple IDs
// are supplied, only the first entry is used as owner_id.
func TestHandleCreate_MultipleOwnerIDs_UsesFirst(t *testing.T) {
stub := &createCaptureStore{}
m := newManagedMethods(t, stub)
req := buildCreateRequest(t, map[string]any{
"name": "Test Agent Four",
"owner_ids": []string{"first-owner", "second-owner"},
})
m.handleCreate(context.Background(), nullClient(), req)
if stub.created == nil {
t.Fatal("agentStore.Create was not called")
}
if stub.created.OwnerID != "first-owner" {
t.Errorf("OwnerID = %q, want %q", stub.created.OwnerID, "first-owner")
}
}
+16 -1
View File
@@ -283,7 +283,22 @@ func (s *PGAgentStore) ListAccessible(ctx context.Context, userID string) ([]sto
rows, err := s.db.QueryContext(ctx,
`SELECT `+agentSelectCols+`
FROM agents
WHERE deleted_at IS NULL AND (owner_id = $1 OR is_default = true OR id IN (SELECT agent_id FROM agent_shares WHERE user_id = $1))
WHERE deleted_at IS NULL AND (
owner_id = $1
OR is_default = true
OR id IN (SELECT agent_id FROM agent_shares WHERE user_id = $1)
OR (
agent_type = 'predefined'
AND id IN (
SELECT agent_id FROM channel_instances ci
WHERE ci.enabled = true
AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(ci.config->'allow_from') af
WHERE af = $1
)
)
)
)
ORDER BY created_at DESC`, userID)
if err != nil {
return nil, err