refactor(bootstrap): separate profile and seeding callbacks, consolidate per-user state

- Split EnsureUserFilesFunc into EnsureUserProfileFunc (profile + workspace)
  and SeedUserFilesFunc (context file seeding) for single-responsibility
- Merge userWorkspaces + userFilesSeeded sync.Maps into unified userSetups
  struct to prevent desync between workspace and seeding state
- Add skipIfAnyExist param to SeedUserFiles to encapsulate the
  "seed only for brand-new users" logic within the bootstrap package
- Extract getOrCreateUserSetup helper for clean per-user initialization
- Add bootstrap state tests covering all 4 system prompt branches
- Keep legacy EnsureUserFilesFunc as fallback for backward compatibility
This commit is contained in:
viettranx committed 2026-03-27 19:16:31 +07:00
1 parent 23c43259c9
commit d3bf16d2de
10 files changed
+365 -94

No files matched your search

+18 -26
View File
@@ -14,33 +14,14 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// buildEnsureUserFiles creates the per-user file seeding callback.
// Seeds per-user context files on first chat (new user profile).
func buildEnsureUserFiles(as store.AgentStore, configPermStore store.ConfigPermissionStore) agent.EnsureUserFilesFunc {
return func(ctx context.Context, agentID uuid.UUID, userID, agentType, workspace, channel string) (string, error) {
// buildEnsureUserProfile creates the user profile resolution callback.
// Creates/resolves user profile and returns effective workspace.
// Separated from seeding to allow independent lifecycle management.
func buildEnsureUserProfile(as store.AgentStore, configPermStore store.ConfigPermissionStore) agent.EnsureUserProfileFunc {
return func(ctx context.Context, agentID uuid.UUID, userID, workspace, channel string) (string, bool, error) {
isNew, effectiveWs, err := as.GetOrCreateUserProfile(ctx, agentID, userID, workspace, channel)
if err != nil {
return effectiveWs, err
}
// Seed context files:
// - isNew: always seed (first-time user)
// - !isNew: only seed when user has NO context files at all.
// This handles the EnsureUserProfile (HTTP API) pre-creation case where profile
// exists but context files were never seeded. We check for zero files rather than
// calling SeedUserFiles unconditionally to avoid re-seeding BOOTSTRAP.md after
// auto-cleanup (which DELETEs the row — SeedUserFiles would treat it as missing).
needSeed := isNew
if !isNew {
existing, qErr := as.GetUserContextFiles(ctx, agentID, userID)
if qErr == nil && len(existing) == 0 {
needSeed = true
}
}
if needSeed {
if _, seedErr := bootstrap.SeedUserFiles(ctx, as, agentID, userID, agentType); seedErr != nil {
slog.Warn("failed to seed user context files", "error", seedErr, "agent", agentID, "user", userID)
}
return effectiveWs, false, err
}
// Auto-add first group member as a file writer (bootstrap the allowlist).
@@ -68,7 +49,18 @@ func buildEnsureUserFiles(as store.AgentStore, configPermStore store.ConfigPermi
}
}
return effectiveWs, nil
return effectiveWs, isNew, nil
}
}
// buildSeedUserFiles creates the context file seeding callback.
// Seeds BOOTSTRAP.md, USER.md, etc. into user_context_files.
// isNew=true seeds all files; isNew=false only seeds if user has zero files
// (avoids re-seeding BOOTSTRAP.md after auto-cleanup on server restart).
func buildSeedUserFiles(as store.AgentStore) agent.SeedUserFilesFunc {
return func(ctx context.Context, agentID uuid.UUID, userID, agentType string, isNew bool) error {
_, err := bootstrap.SeedUserFiles(ctx, as, agentID, userID, agentType, !isNew)
return err
}
}
+7 -4
View File
@@ -90,10 +90,12 @@ func wireExtras(
}
}
// 2. User seeding callback: seeds per-user context files on first chat
var ensureUserFiles agent.EnsureUserFilesFunc
// 2. Per-user profile + context file seeding callbacks
var ensureUserProfile agent.EnsureUserProfileFunc
var seedUserFiles agent.SeedUserFilesFunc
if stores.Agents != nil {
ensureUserFiles = buildEnsureUserFiles(stores.Agents, stores.ConfigPermissions)
ensureUserProfile = buildEnsureUserProfile(stores.Agents, stores.ConfigPermissions)
seedUserFiles = buildSeedUserFiles(stores.Agents)
}
// 3. Context file loader callback: loads per-user context files dynamically
@@ -138,7 +140,8 @@ func wireExtras(
SkillAccessStore: skillAccessStore,
HasMemory: hasMemory,
TraceCollector: traceCollector,
EnsureUserFiles: ensureUserFiles,
EnsureUserProfile: ensureUserProfile,
SeedUserFiles: seedUserFiles,
ContextFileLoader: contextFileLoader,
BootstrapCleanup: buildBootstrapCleanup(stores.Agents),
InjectionAction: injectionAction,
+8 -33
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
@@ -97,49 +96,25 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
ctx = tools.WithTeamTaskID(ctx, req.TeamTaskID)
}
// --- Per-user file seeding + workspace resolution ---
// --- Per-user setup: file seeding + workspace resolution ---
// Uses userSetups sync.Map to track both concerns atomically per user.
// Seeding must run before buildMessages→resolveContextFiles reads context files.
// Team sessions skip: members process tasks from leader, not end-user onboarding.
// SeedUserFiles is idempotent — safe to call even if files already exist.
// ensureUserFiles both seeds files AND returns effective workspace, so we capture
// the workspace here to avoid a second DB call in the workspace resolution block.
// Team sessions skip seeding: members process tasks from leader, not end-user onboarding.
isTeamSession := bootstrap.IsTeamSession(req.SessionKey)
var ensuredWs string // workspace path returned by ensureUserFiles (empty if not called)
if req.UserID != "" && l.ensureUserFiles != nil && !isTeamSession {
if _, seeded := l.userFilesSeeded.Load(req.UserID); !seeded {
ws, err := l.ensureUserFiles(ctx, l.agentUUID, req.UserID, l.agentType, l.workspace, req.Channel)
if err != nil {
slog.Warn("failed to ensure user context files", "error", err)
} else {
ensuredWs = ws
}
l.userFilesSeeded.Store(req.UserID, true)
}
}
setup := l.getOrCreateUserSetup(ctx, req.UserID, req.Channel, isTeamSession)
// Workspace resolution (layered pipeline).
// Layer order: tenant → team → project (future) → user/chat
// Two entry modes: solo agent (base = l.workspace) or team context (base = l.dataDir).
// Result is always a single folder set via WithToolWorkspace.
if l.workspace != "" && req.UserID != "" {
cachedWs, loaded := l.userWorkspaces.Load(req.UserID)
if !loaded {
// Use workspace from ensureUserFiles if available (avoids second DB call),
// otherwise fall back to agent's default workspace.
ws := ensuredWs
if ws == "" {
ws = l.workspace
}
ws = config.ExpandHome(ws)
if !filepath.IsAbs(ws) {
ws, _ = filepath.Abs(ws)
}
l.userWorkspaces.Store(req.UserID, ws)
cachedWs = ws
ws := setup.workspace
if ws == "" {
ws = l.workspace
}
// Apply user isolation layer via pipeline.
shared := l.shouldShareWorkspace(req.UserID, req.PeerKind)
effectiveWorkspace := tools.ResolveWorkspace(cachedWs.(string),
effectiveWorkspace := tools.ResolveWorkspace(ws,
tools.UserChatLayer(tools.SanitizePathSegment(req.UserID), shared),
)
if l.shouldShareMemory() {
+9 -9
View File
@@ -92,20 +92,20 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
_, hasKG := l.tools.Get("knowledge_graph_search")
// Per-user workspace: show the user's subdirectory in the system prompt.
// Uses cached workspace from user_agent_profiles (includes channel isolation).
// Uses cached workspace from userSetups (includes channel isolation).
// When workspace sharing is enabled, show the base workspace without user subfolder.
promptWorkspace := l.workspace
if l.agentUUID != uuid.Nil && userID != "" && l.workspace != "" {
shared := l.shouldShareWorkspace(userID, peerKind)
if cachedWs, ok := l.userWorkspaces.Load(userID); ok {
promptWorkspace = tools.ResolveWorkspace(cachedWs.(string),
tools.UserChatLayer(tools.SanitizePathSegment(userID), shared),
)
} else {
promptWorkspace = tools.ResolveWorkspace(l.workspace,
tools.UserChatLayer(tools.SanitizePathSegment(userID), shared),
)
baseWs := l.workspace
if val, ok := l.userSetups.Load(userID); ok {
if ws := val.(*userSetup).workspace; ws != "" {
baseWs = ws
}
}
promptWorkspace = tools.ResolveWorkspace(baseWs,
tools.UserChatLayer(tools.SanitizePathSegment(userID), shared),
)
}
// Resolve context files once — also detect BOOTSTRAP.md presence.
+33 -8
View File
@@ -24,8 +24,28 @@ import (
// Bootstrap typically completes in 2-3 conversation turns.
const bootstrapAutoCleanupTurns = 3
// EnsureUserFilesFunc seeds per-user context files on first chat.
// Returns the effective workspace path (from user_agent_profiles) for caching.
// userSetup tracks per-user initialization state within a Loop instance.
// Consolidates workspace resolution and context file seeding into one struct
// to prevent desync between the two concerns.
type userSetup struct {
workspace string // effective workspace from user_agent_profiles (expanded, absolute)
seeded bool // whether SeedUserFiles has been called this instance
}
// EnsureUserProfileFunc creates/resolves a user's profile and workspace.
// Returns the effective workspace path from user_agent_profiles.
// Does NOT seed context files — that's SeedUserFilesFunc's responsibility.
type EnsureUserProfileFunc func(ctx context.Context, agentID uuid.UUID, userID, workspace, channel string) (effectiveWorkspace string, isNew bool, err error)
// SeedUserFilesFunc seeds per-user context files (BOOTSTRAP.md, USER.md, etc.).
// Called once per user per Loop instance, independent of workspace.
// isNew indicates whether the profile was just created (seed all) or already existed
// (only seed if user has zero files — avoids re-seeding after BOOTSTRAP.md cleanup).
type SeedUserFilesFunc func(ctx context.Context, agentID uuid.UUID, userID, agentType string, isNew bool) error
// EnsureUserFilesFunc is the legacy combined callback (profile + seed + workspace).
// Deprecated: use EnsureUserProfileFunc + SeedUserFilesFunc separately.
// Kept for backward compatibility with existing callers during migration.
type EnsureUserFilesFunc func(ctx context.Context, agentID uuid.UUID, userID, agentType, workspace, channel string) (effectiveWorkspace string, err error)
// ContextFileLoaderFunc loads context files dynamically per-request.
@@ -75,12 +95,13 @@ type Loop struct {
hasMemory bool
contextFiles []bootstrap.ContextFile
// Per-user file seeding + dynamic context loading
ensureUserFiles EnsureUserFilesFunc
// Per-user profile + file seeding + dynamic context loading
ensureUserProfile EnsureUserProfileFunc // create/resolve user profile + workspace
seedUserFiles SeedUserFilesFunc // seed context files (BOOTSTRAP.md, USER.md)
ensureUserFiles EnsureUserFilesFunc // legacy combined callback (fallback)
contextFileLoader ContextFileLoaderFunc
bootstrapCleanup BootstrapCleanupFunc
userWorkspaces sync.Map // userID → string (expanded workspace path from user_agent_profiles)
userFilesSeeded sync.Map // userID → bool (whether SeedUserFiles has been called this instance)
userSetups sync.Map // userID → *userSetup (workspace + seeding state, per Loop instance)
// Compaction config (memory flush settings)
compactionCfg *config.CompactionConfig
@@ -223,8 +244,10 @@ type LoopConfig struct {
TenantID uuid.UUID // agent's owning tenant — injected into execution context
AgentType string // "open" or "predefined"
// Per-user file seeding + dynamic context loading
EnsureUserFiles EnsureUserFilesFunc
// Per-user profile + file seeding + dynamic context loading
EnsureUserProfile EnsureUserProfileFunc // preferred: separate profile + workspace
SeedUserFiles SeedUserFilesFunc // preferred: separate context file seeding
EnsureUserFiles EnsureUserFilesFunc // legacy: combined (used when above are nil)
ContextFileLoader ContextFileLoaderFunc
BootstrapCleanup BootstrapCleanupFunc
@@ -337,6 +360,8 @@ func NewLoop(cfg LoopConfig) *Loop {
skillAllowList: cfg.SkillAllowList,
hasMemory: cfg.HasMemory,
contextFiles: cfg.ContextFiles,
ensureUserProfile: cfg.EnsureUserProfile,
seedUserFiles: cfg.SeedUserFiles,
ensureUserFiles: cfg.EnsureUserFiles,
contextFileLoader: cfg.ContextFileLoader,
bootstrapCleanup: cfg.BootstrapCleanup,
+69 -3
View File
@@ -1,11 +1,14 @@
package agent
import (
"context"
"fmt"
"log/slog"
"path/filepath"
"slices"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
@@ -58,10 +61,73 @@ func (l *Loop) shouldShareKnowledgeGraph() bool {
return l.workspaceSharing != nil && l.workspaceSharing.ShareKnowledgeGraph
}
// InvalidateUserWorkspace clears the cached workspace for a user,
// forcing the next request to re-read from user_agent_profiles.
// getOrCreateUserSetup returns the cached userSetup for a user, creating it on first call.
// On first call: seeds context files (non-team) and resolves workspace from user profile.
// On subsequent calls: returns cached setup immediately (no DB calls).
func (l *Loop) getOrCreateUserSetup(ctx context.Context, userID, channel string, isTeamSession bool) *userSetup {
if userID == "" {
return &userSetup{workspace: l.workspace}
}
// Fast path: already initialized
if val, ok := l.userSetups.Load(userID); ok {
return val.(*userSetup)
}
// Slow path: first request for this user in this Loop instance.
setup := &userSetup{}
if !isTeamSession {
if l.ensureUserProfile != nil && l.seedUserFiles != nil {
// Preferred path: separate callbacks for profile + seed.
// Step 1: Create/resolve profile → get isNew + workspace
ws, isNew, err := l.ensureUserProfile(ctx, l.agentUUID, userID, l.workspace, channel)
if err != nil {
slog.Warn("failed to ensure user profile", "error", err)
} else if ws != "" {
setup.workspace = expandWorkspace(ws)
}
// Step 2: Seed context files (must run before buildMessages reads them).
// Passes isNew so SeedUserFiles knows whether to skip existing files.
if err := l.seedUserFiles(ctx, l.agentUUID, userID, l.agentType, isNew); err != nil {
slog.Warn("failed to seed user context files", "error", err)
}
setup.seeded = true
} else if l.ensureUserFiles != nil {
// Legacy fallback: combined callback handles both profile + seed
ws, err := l.ensureUserFiles(ctx, l.agentUUID, userID, l.agentType, l.workspace, channel)
if err != nil {
slog.Warn("failed to ensure user context files", "error", err)
} else if ws != "" {
setup.workspace = expandWorkspace(ws)
}
setup.seeded = true
}
}
// Fallback: use agent's default workspace if profile didn't provide one
if setup.workspace == "" && l.workspace != "" {
setup.workspace = expandWorkspace(l.workspace)
}
// Store atomically — if another goroutine raced, use their result
actual, _ := l.userSetups.LoadOrStore(userID, setup)
return actual.(*userSetup)
}
// expandWorkspace expands ~ and converts to absolute path.
func expandWorkspace(ws string) string {
ws = config.ExpandHome(ws)
if !filepath.IsAbs(ws) {
ws, _ = filepath.Abs(ws)
}
return ws
}
// InvalidateUserWorkspace clears the cached setup for a user,
// forcing the next request to re-resolve workspace and re-seed if needed.
func (l *Loop) InvalidateUserWorkspace(userID string) {
l.userWorkspaces.Delete(userID)
l.userSetups.Delete(userID)
}
// Provider returns the LLM provider for this agent loop.
+5 -3
View File
@@ -35,8 +35,9 @@ type ResolverDeps struct {
OnEvent func(AgentEvent)
TraceCollector *tracing.Collector
// Per-user file seeding + dynamic context loading
EnsureUserFiles EnsureUserFilesFunc
// Per-user profile + file seeding + dynamic context loading
EnsureUserProfile EnsureUserProfileFunc
SeedUserFiles SeedUserFilesFunc
ContextFileLoader ContextFileLoaderFunc
BootstrapCleanup BootstrapCleanupFunc
@@ -365,7 +366,8 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
SkillAllowList: skillAllowList,
HasMemory: hasMemory,
ContextFiles: contextFiles,
EnsureUserFiles: deps.EnsureUserFiles,
EnsureUserProfile: deps.EnsureUserProfile,
SeedUserFiles: deps.SeedUserFiles,
ContextFileLoader: deps.ContextFileLoader,
BootstrapCleanup: deps.BootstrapCleanup,
OnEvent: deps.OnEvent,
@@ -0,0 +1,138 @@
package agent
import (
"strings"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// TestBuildSystemPrompt_BootstrapStates verifies the 4 bootstrap states
// produce the correct system prompt sections.
func TestBuildSystemPrompt_BootstrapStates(t *testing.T) {
blankUserMD := "# USER.md\n\n- **Name:**\n- **Language:**\n- **Timezone:**\n"
populatedUserMD := "# USER.md\n\n- **Name:** Alice\n- **Language:** English\n- **Timezone:** UTC+7\n"
tests := []struct {
name string
cfg SystemPromptConfig
wantIn string // substring that MUST appear
wantNotIn string // substring that MUST NOT appear (empty = skip check)
}{
{
name: "open agent with BOOTSTRAP.md → FIRST RUN slim mode",
cfg: SystemPromptConfig{
IsBootstrap: true,
AgentType: store.AgentTypeOpen,
ContextFiles: []bootstrap.ContextFile{
{Path: bootstrap.BootstrapFile, Content: "# BOOTSTRAP"},
{Path: bootstrap.UserFile, Content: blankUserMD},
},
ToolNames: []string{"write_file", "Write"},
},
wantIn: "## FIRST RUN",
wantNotIn: "USER PROFILE INCOMPLETE",
},
{
name: "predefined agent with BOOTSTRAP.md → FIRST RUN full capabilities",
cfg: SystemPromptConfig{
IsBootstrap: false,
AgentType: store.AgentTypePredefined,
ContextFiles: []bootstrap.ContextFile{
{Path: bootstrap.BootstrapFile, Content: "# BOOTSTRAP"},
{Path: bootstrap.UserFile, Content: blankUserMD},
},
ToolNames: []string{"write_file", "Write", "skill_search"},
},
wantIn: "## FIRST RUN",
wantNotIn: "USER PROFILE INCOMPLETE",
},
{
name: "no BOOTSTRAP.md + blank USER.md → USER PROFILE INCOMPLETE",
cfg: SystemPromptConfig{
IsBootstrap: false,
AgentType: store.AgentTypePredefined,
ContextFiles: []bootstrap.ContextFile{
{Path: bootstrap.UserFile, Content: blankUserMD},
},
ToolNames: []string{"write_file"},
},
wantIn: "## USER PROFILE INCOMPLETE",
wantNotIn: "FIRST RUN",
},
{
name: "no BOOTSTRAP.md + populated USER.md → no nudge at all",
cfg: SystemPromptConfig{
IsBootstrap: false,
AgentType: store.AgentTypePredefined,
ContextFiles: []bootstrap.ContextFile{
{Path: bootstrap.UserFile, Content: populatedUserMD},
},
ToolNames: []string{"write_file"},
},
wantNotIn: "FIRST RUN",
},
{
name: "open agent slim mode has write_file note",
cfg: SystemPromptConfig{
IsBootstrap: true,
AgentType: store.AgentTypeOpen,
ContextFiles: []bootstrap.ContextFile{
{Path: bootstrap.BootstrapFile, Content: "# BOOTSTRAP"},
},
ToolNames: []string{"write_file"},
},
wantIn: "only have write_file available",
},
{
name: "predefined agent first run has write_file instruction",
cfg: SystemPromptConfig{
IsBootstrap: false,
AgentType: store.AgentTypePredefined,
ContextFiles: []bootstrap.ContextFile{
{Path: bootstrap.BootstrapFile, Content: "# BOOTSTRAP"},
},
ToolNames: []string{"write_file", "web_search"},
},
wantIn: "MUST ALSO call write_file",
wantNotIn: "only have write_file available",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
prompt := BuildSystemPrompt(tt.cfg)
if tt.wantIn != "" && !strings.Contains(prompt, tt.wantIn) {
t.Errorf("expected %q in system prompt, got:\n%s", tt.wantIn, prompt[:min(len(prompt), 500)])
}
if tt.wantNotIn != "" && strings.Contains(prompt, tt.wantNotIn) {
t.Errorf("unexpected %q in system prompt", tt.wantNotIn)
}
// Always verify: populated USER.md must never trigger INCOMPLETE
if tt.name == "no BOOTSTRAP.md + populated USER.md → no nudge at all" {
if strings.Contains(prompt, "USER PROFILE INCOMPLETE") {
t.Error("populated USER.md should not trigger USER PROFILE INCOMPLETE")
}
}
})
}
}
// TestBuildSystemPrompt_NoBootstrapNoUser verifies that when there are no
// bootstrap-related files at all, no nudge sections appear.
func TestBuildSystemPrompt_NoBootstrapNoUser(t *testing.T) {
prompt := BuildSystemPrompt(SystemPromptConfig{
AgentType: store.AgentTypePredefined,
ToolNames: []string{"write_file"},
})
if strings.Contains(prompt, "FIRST RUN") {
t.Error("unexpected FIRST RUN section with no context files")
}
if strings.Contains(prompt, "USER PROFILE INCOMPLETE") {
t.Error("unexpected USER PROFILE INCOMPLETE section with no context files")
}
}
+15 -2
View File
@@ -99,17 +99,23 @@ var userSeedFilesPredefined = []string{
}
// SeedUserFiles seeds embedded templates into user_context_files for a new user.
// For "open" agents: all 7 files (including BOOTSTRAP.md).
// For "open" agents: all 5 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.
//
// When skipIfAnyExist is true, returns immediately if the user already has ANY context
// files (even with empty content slots). This prevents re-seeding BOOTSTRAP.md after
// auto-cleanup (which DELETEs the row) on server restart — existing files like USER.md
// indicate the user is not brand-new, so BOOTSTRAP.md should stay gone.
// Use skipIfAnyExist=true for existing profiles, false for newly created profiles.
//
// 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) {
func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uuid.UUID, userID, agentType string, skipIfAnyExist bool) ([]string, error) {
files := userSeedFilesOpen
if agentType == store.AgentTypePredefined {
files = userSeedFilesPredefined
@@ -120,6 +126,13 @@ func SeedUserFiles(ctx context.Context, agentStore store.AgentStore, agentID uui
if err != nil {
return nil, err
}
// Early exit: user already has files → not a brand-new user.
// Avoids re-seeding BOOTSTRAP.md after auto-cleanup on server restart.
if skipIfAnyExist && len(existing) > 0 {
return nil, nil
}
hasFile := make(map[string]bool, len(existing))
for _, f := range existing {
if f.Content != "" {
+63 -6
View File
@@ -122,7 +122,7 @@ func TestSeedUserFiles_PredefinedAgent_UsesAgentLevelUserMD(t *testing.T) {
// 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)
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-alice", store.AgentTypePredefined, false)
if err != nil {
t.Fatalf("SeedUserFiles returned error: %v", err)
}
@@ -155,7 +155,7 @@ func TestSeedUserFiles_PredefinedAgent_FallsBackToTemplateWhenNoAgentLevelUserMD
agentID := uuid.New()
// No agent-level USER.md — wizard did not write one
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-bob", store.AgentTypePredefined)
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-bob", store.AgentTypePredefined, false)
if err != nil {
t.Fatalf("SeedUserFiles returned error: %v", err)
}
@@ -193,7 +193,7 @@ func TestSeedUserFiles_PredefinedAgent_DoesNotOverwriteExistingPerUserContent(t
// 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)
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-charlie", store.AgentTypePredefined, false)
if err != nil {
t.Fatalf("SeedUserFiles returned error: %v", err)
}
@@ -218,7 +218,7 @@ func TestSeedUserFiles_OpenAgent_UsesEmbeddedTemplate(t *testing.T) {
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)
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-dave", store.AgentTypeOpen, false)
if err != nil {
t.Fatalf("SeedUserFiles returned error: %v", err)
}
@@ -251,14 +251,14 @@ func TestSeedUserFiles_IdempotentOnSecondCall(t *testing.T) {
agentID := uuid.New()
// First call — seeds files
SeedUserFiles(context.Background(), as, agentID, "user-frank", store.AgentTypePredefined)
SeedUserFiles(context.Background(), as, agentID, "user-frank", store.AgentTypePredefined, false)
// Simulate what the first call wrote (move seededUserFiles → userFiles)
maps.Copy(as.userFiles, as.seededUserFiles)
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)
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-frank", store.AgentTypePredefined, false)
if err != nil {
t.Fatalf("second SeedUserFiles returned error: %v", err)
}
@@ -269,3 +269,60 @@ func TestSeedUserFiles_IdempotentOnSecondCall(t *testing.T) {
t.Errorf("second call should not write any files, but wrote: %v", as.seededUserFiles)
}
}
// TestSeedUserFiles_SkipIfAnyExist verifies that skipIfAnyExist=true prevents
// re-seeding BOOTSTRAP.md after auto-cleanup (which DELETEs the row).
// This is the key scenario: server restart → ensureUserFiles called → existing profile
// has USER.md but BOOTSTRAP.md was cleaned up → must NOT re-seed BOOTSTRAP.md.
func TestSeedUserFiles_SkipIfAnyExist(t *testing.T) {
as := newSeedStub()
agentID := uuid.New()
// Step 1: Seed initial files (new user, skipIfAnyExist=false)
SeedUserFiles(context.Background(), as, agentID, "user-eve", store.AgentTypePredefined, false)
maps.Copy(as.userFiles, as.seededUserFiles)
as.seededUserFiles = make(map[string]string)
// Step 2: Simulate auto-cleanup — BOOTSTRAP.md deleted, USER.md remains
delete(as.userFiles, BootstrapFile)
// Step 3: skipIfAnyExist=true (existing profile) → must NOT re-seed
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-eve", store.AgentTypePredefined, true)
if err != nil {
t.Fatalf("SeedUserFiles(skipIfAnyExist=true) returned error: %v", err)
}
if len(seeded) != 0 {
t.Errorf("skipIfAnyExist=true should seed nothing when user has files, but seeded: %v", seeded)
}
// Step 4: skipIfAnyExist=false (force) → SHOULD re-seed missing BOOTSTRAP.md
seeded, err = SeedUserFiles(context.Background(), as, agentID, "user-eve", store.AgentTypePredefined, false)
if err != nil {
t.Fatalf("SeedUserFiles(skipIfAnyExist=false) returned error: %v", err)
}
foundBootstrap := false
for _, f := range seeded {
if f == BootstrapFile {
foundBootstrap = true
}
}
if !foundBootstrap {
t.Errorf("skipIfAnyExist=false should re-seed missing BOOTSTRAP.md, but seeded: %v", seeded)
}
}
// TestSeedUserFiles_SkipIfAnyExist_EmptyUser verifies that skipIfAnyExist=true
// still seeds when the user has NO files at all (EnsureUserProfile pre-creation case).
func TestSeedUserFiles_SkipIfAnyExist_EmptyUser(t *testing.T) {
as := newSeedStub()
agentID := uuid.New()
// No existing user files → skipIfAnyExist=true should still seed
seeded, err := SeedUserFiles(context.Background(), as, agentID, "user-ghost", store.AgentTypePredefined, true)
if err != nil {
t.Fatalf("SeedUserFiles returned error: %v", err)
}
if len(seeded) == 0 {
t.Error("skipIfAnyExist=true with zero existing files should seed, but seeded nothing")
}
}