diff --git a/cmd/gateway_callbacks.go b/cmd/gateway_callbacks.go index af924db2..66f6fddb 100644 --- a/cmd/gateway_callbacks.go +++ b/cmd/gateway_callbacks.go @@ -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 } } diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index 0890057a..49ead5f8 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -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, diff --git a/internal/agent/loop_context.go b/internal/agent/loop_context.go index 751d9b3a..1828c3ad 100644 --- a/internal/agent/loop_context.go +++ b/internal/agent/loop_context.go @@ -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() { diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index b44a740b..429b44e1 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -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. diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index cb705200..842ea90c 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -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, diff --git a/internal/agent/loop_utils.go b/internal/agent/loop_utils.go index 12685dd9..40f7223a 100644 --- a/internal/agent/loop_utils.go +++ b/internal/agent/loop_utils.go @@ -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. diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 6a6f4a68..6583caec 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -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, diff --git a/internal/agent/systemprompt_bootstrap_test.go b/internal/agent/systemprompt_bootstrap_test.go new file mode 100644 index 00000000..31758898 --- /dev/null +++ b/internal/agent/systemprompt_bootstrap_test.go @@ -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") + } +} diff --git a/internal/bootstrap/seed_store.go b/internal/bootstrap/seed_store.go index 39729992..bbc3d002 100644 --- a/internal/bootstrap/seed_store.go +++ b/internal/bootstrap/seed_store.go @@ -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 != "" { diff --git a/internal/bootstrap/seed_store_test.go b/internal/bootstrap/seed_store_test.go index aa964e25..ed75d92a 100644 --- a/internal/bootstrap/seed_store_test.go +++ b/internal/bootstrap/seed_store_test.go @@ -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") + } +}