diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index 5937b73d..bb204cbd 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -49,7 +49,7 @@ func TestNewProgramNormalizesInvalidCurrentModelOnStartup(t *testing.T) { } configPath := filepath.Join(configDir, "config.yaml") - raw := []byte("selected_provider: openai\ncurrent_model: unsupported-current\nworkdir: .\nshell: powershell\n") + raw := []byte("selected_provider: openai\ncurrent_model: unsupported-current\nshell: powershell\n") if err := os.WriteFile(configPath, raw, 0o644); err != nil { t.Fatalf("write config: %v", err) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index cc0d4ead..da3e6bee 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -41,6 +41,7 @@ func TestParseConfigFormats(t *testing.T) { tests := []struct { name string data string + err string assert func(t *testing.T, cfg *Config) }{ { @@ -48,7 +49,6 @@ func TestParseConfigFormats(t *testing.T) { data: ` selected_provider: openai current_model: gpt-5.4 -workdir: . shell: powershell provider_overrides: @@ -87,12 +87,31 @@ providers: } }, }, + { + name: "legacy default_workdir key is rejected", + data: ` +selected_provider: openai +current_model: gpt-4.1 +default_workdir: ./from-default +shell: powershell +`, + err: "legacy config key \"default_workdir\" is no longer supported", + }, + { + name: "legacy workdir key is rejected", + data: ` +selected_provider: openai +current_model: gpt-4.1 +workdir: ./from-legacy +shell: powershell +`, + err: "legacy config key \"workdir\" is no longer supported", + }, { name: "legacy persisted providers list keeps selection only", data: ` selected_provider: openai current_model: gpt-5.4 -workdir: . shell: powershell providers: - name: openai @@ -167,6 +186,12 @@ providers: t.Run(tt.name, func(t *testing.T) { t.Parallel() cfg, err := parseConfig([]byte(tt.data)) + if tt.err != "" { + if err == nil || !strings.Contains(err.Error(), tt.err) { + t.Fatalf("expected parseConfig() error containing %q, got %v", tt.err, err) + } + return + } if err != nil { t.Fatalf("parseConfig() error = %v", err) } @@ -572,6 +597,9 @@ func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { t.Fatalf("read config file: %v", err) } text := string(data) + if strings.Contains(text, "default_workdir:") || strings.Contains(text, "\nworkdir:") || strings.HasPrefix(text, "workdir:") { + t.Fatalf("expected persisted config to avoid any workdir keys, got:\n%s", text) + } if strings.Contains(text, "\nproviders:") || strings.HasPrefix(text, "providers:") { t.Fatalf("expected persisted config to omit providers, got:\n%s", text) } diff --git a/internal/config/loader.go b/internal/config/loader.go index c3563828..ce8ed1a1 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -22,13 +22,14 @@ type Loader struct { } type persistedConfig struct { - SelectedProvider string `yaml:"selected_provider"` - CurrentModel string `yaml:"current_model"` - Workdir string `yaml:"workdir"` - Shell string `yaml:"shell"` - MaxLoops int `yaml:"max_loops,omitempty"` - ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` - Tools ToolsConfig `yaml:"tools,omitempty"` + SelectedProvider string `yaml:"selected_provider"` + CurrentModel string `yaml:"current_model"` + LegacyDefaultWorkdir *string `yaml:"default_workdir,omitempty"` + LegacyWorkdir *string `yaml:"workdir,omitempty"` + Shell string `yaml:"shell"` + MaxLoops int `yaml:"max_loops,omitempty"` + ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` + Tools ToolsConfig `yaml:"tools,omitempty"` } func NewLoader(baseDir string, defaults *Config) *Loader { @@ -152,11 +153,16 @@ func parseCurrentConfig(data []byte) (*Config, error) { if err := yaml.Unmarshal(data, &file); err != nil { return nil, err } + if file.LegacyDefaultWorkdir != nil { + return nil, fmt.Errorf("legacy config key %q is no longer supported", "default_workdir") + } + if file.LegacyWorkdir != nil { + return nil, fmt.Errorf("legacy config key %q is no longer supported", "workdir") + } cfg := &Config{ SelectedProvider: strings.TrimSpace(file.SelectedProvider), CurrentModel: strings.TrimSpace(file.CurrentModel), - Workdir: strings.TrimSpace(file.Workdir), Shell: strings.TrimSpace(file.Shell), MaxLoops: file.MaxLoops, ToolTimeoutSec: file.ToolTimeoutSec, @@ -170,7 +176,6 @@ func marshalPersistedConfig(snapshot Config) ([]byte, error) { file := persistedConfig{ SelectedProvider: snapshot.SelectedProvider, CurrentModel: snapshot.CurrentModel, - Workdir: snapshot.Workdir, Shell: snapshot.Shell, MaxLoops: snapshot.MaxLoops, ToolTimeoutSec: snapshot.ToolTimeoutSec, diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 054e9db5..bfbf7513 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -45,6 +45,52 @@ func TestLoaderLoadMalformedYAML(t *testing.T) { } } +func TestLoaderRejectsLegacyWorkdirKey(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir(), testDefaultConfig()) + if err := os.MkdirAll(loader.BaseDir(), 0o755); err != nil { + t.Fatalf("mkdir base dir: %v", err) + } + raw := ` +selected_provider: openai +current_model: gpt-4.1 +workdir: . +shell: powershell +` + if err := os.WriteFile(loader.ConfigPath(), []byte(strings.TrimSpace(raw)+"\n"), 0o644); err != nil { + t.Fatalf("write legacy config: %v", err) + } + + _, err := loader.Load(context.Background()) + if err == nil || !strings.Contains(err.Error(), "legacy config key \"workdir\" is no longer supported") { + t.Fatalf("expected legacy workdir rejection, got %v", err) + } +} + +func TestLoaderRejectsLegacyDefaultWorkdirKey(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir(), testDefaultConfig()) + if err := os.MkdirAll(loader.BaseDir(), 0o755); err != nil { + t.Fatalf("mkdir base dir: %v", err) + } + raw := ` +selected_provider: openai +current_model: gpt-4.1 +default_workdir: . +shell: powershell +` + if err := os.WriteFile(loader.ConfigPath(), []byte(strings.TrimSpace(raw)+"\n"), 0o644); err != nil { + t.Fatalf("write legacy config: %v", err) + } + + _, err := loader.Load(context.Background()) + if err == nil || !strings.Contains(err.Error(), "legacy config key \"default_workdir\" is no longer supported") { + t.Fatalf("expected legacy default_workdir rejection, got %v", err) + } +} + func TestLoaderLoadInvalidBaseDir(t *testing.T) { t.Parallel() @@ -72,7 +118,6 @@ func TestLoaderRewritesLegacyProvidersFormatOnLoad(t *testing.T) { legacy := ` selected_provider: openai current_model: gpt-5.4 -workdir: . shell: powershell providers: - name: openai @@ -105,6 +150,9 @@ providers: t.Fatalf("read rewritten config: %v", err) } text := string(rewritten) + if strings.Contains(text, "default_workdir:") || strings.Contains(text, "\nworkdir:") || strings.HasPrefix(text, "workdir:") { + t.Fatalf("expected rewritten config to avoid any workdir keys, got:\n%s", text) + } if strings.Contains(text, "provider_overrides:") { t.Fatalf("expected rewritten config to drop provider overrides, got:\n%s", text) } @@ -126,7 +174,6 @@ func TestLoaderRewritesNormalizedSelectionStateOnLoad(t *testing.T) { raw := ` selected_provider: missing-provider -workdir: . shell: powershell ` if err := os.WriteFile(loader.ConfigPath(), []byte(strings.TrimSpace(raw)+"\n"), 0o644); err != nil { @@ -167,7 +214,6 @@ func TestLoaderRewritesMissingCurrentModelOnLoad(t *testing.T) { raw := ` selected_provider: openai -workdir: . shell: powershell ` if err := os.WriteFile(loader.ConfigPath(), []byte(strings.TrimSpace(raw)+"\n"), 0o644); err != nil { diff --git a/internal/config/model.go b/internal/config/model.go index f8c2bd52..a89e63fb 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -30,7 +30,7 @@ type Config struct { Providers []ProviderConfig `yaml:"-"` SelectedProvider string `yaml:"selected_provider"` CurrentModel string `yaml:"current_model"` - Workdir string `yaml:"workdir"` + Workdir string `yaml:"-"` Shell string `yaml:"shell"` MaxLoops int `yaml:"max_loops,omitempty"` ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index a9ea5dac..b94ed0e7 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -6,6 +6,8 @@ import ( "fmt" "log" "math/rand/v2" + "os" + "path/filepath" "strings" "sync" "time" @@ -28,18 +30,27 @@ const ( providerRetryMaxWait = 5 * time.Second ) +var runtimeSessionWorkdirs = struct { + mu sync.RWMutex + data map[string]string +}{ + data: make(map[string]string), +} + type Runtime interface { Run(ctx context.Context, input UserInput) error CancelActiveRun() bool Events() <-chan RuntimeEvent ListSessions(ctx context.Context) ([]SessionSummary, error) LoadSession(ctx context.Context, id string) (Session, error) + SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (Session, error) } type UserInput struct { SessionID string RunID string Content string + Workdir string } type ProviderFactory interface { @@ -99,7 +110,8 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { return errors.New("runtime: input content is empty") } - session, err := s.loadOrCreateSession(ctx, input.SessionID, input.Content) + initialCfg := s.configManager.Get() + session, err := s.loadOrCreateSession(ctx, input.SessionID, input.Content, initialCfg.Workdir, input.Workdir) if err != nil { return s.handleRunError(ctx, input.RunID, input.SessionID, err) } @@ -121,6 +133,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { } cfg := s.configManager.Get() + activeWorkdir := effectiveSessionWorkdir(session.Workdir, cfg.Workdir) maxLoops := cfg.MaxLoops if maxLoops <= 0 { maxLoops = 8 @@ -134,7 +147,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { builtContext, err := s.contextBuilder.Build(ctx, agentcontext.BuildInput{ Messages: session.Messages, Metadata: agentcontext.Metadata{ - Workdir: cfg.Workdir, + Workdir: activeWorkdir, Shell: cfg.Shell, Provider: cfg.SelectedProvider, Model: cfg.CurrentModel, @@ -196,7 +209,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { ID: call.ID, Name: call.Name, Arguments: []byte(call.Arguments), - Workdir: cfg.Workdir, + Workdir: activeWorkdir, SessionID: session.ID, EmitChunk: func(chunk []byte) { s.emit(ctx, EventToolChunk, input.RunID, session.ID, string(chunk)) @@ -267,18 +280,100 @@ func (s *Service) ListSessions(ctx context.Context) ([]SessionSummary, error) { } func (s *Service) LoadSession(ctx context.Context, id string) (Session, error) { - return s.sessionStore.Load(ctx, id) + session, err := s.sessionStore.Load(ctx, id) + if err != nil { + return Session{}, err + } + session.Workdir = s.sessionWorkdir(id, session.Workdir) + return session, nil +} + +func (s *Service) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (Session, error) { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return Session{}, errors.New("runtime: session id is empty") + } + + session, err := s.sessionStore.Load(ctx, sessionID) + if err != nil { + return Session{}, err + } + session.Workdir = s.sessionWorkdir(sessionID, session.Workdir) + + cfg := s.configManager.Get() + resolved, err := resolveWorkdirForSession(cfg.Workdir, session.Workdir, workdir) + if err != nil { + return Session{}, err + } + if session.Workdir == resolved { + return session, nil + } + + session.Workdir = resolved + s.setSessionWorkdir(sessionID, resolved) + return session, nil +} + +func (s *Service) sessionWorkdir(sessionID string, fallback string) string { + key := s.sessionWorkdirKey(sessionID) + runtimeSessionWorkdirs.mu.RLock() + value, ok := runtimeSessionWorkdirs.data[key] + runtimeSessionWorkdirs.mu.RUnlock() + if ok { + return strings.TrimSpace(value) + } + return strings.TrimSpace(fallback) +} + +func (s *Service) setSessionWorkdir(sessionID string, workdir string) { + key := s.sessionWorkdirKey(sessionID) + runtimeSessionWorkdirs.mu.Lock() + runtimeSessionWorkdirs.data[key] = strings.TrimSpace(workdir) + runtimeSessionWorkdirs.mu.Unlock() } -func (s *Service) loadOrCreateSession(ctx context.Context, sessionID string, title string) (Session, error) { +func (s *Service) sessionWorkdirKey(sessionID string) string { + return fmt.Sprintf("%p:%s", s, strings.TrimSpace(sessionID)) +} + +func (s *Service) loadOrCreateSession( + ctx context.Context, + sessionID string, + title string, + defaultWorkdir string, + requestedWorkdir string, +) (Session, error) { if strings.TrimSpace(sessionID) == "" { - session := newSession(title) + sessionWorkdir, err := resolveWorkdirForSession(defaultWorkdir, "", requestedWorkdir) + if err != nil { + return Session{}, err + } + session := newSessionWithWorkdir(title, sessionWorkdir) + s.setSessionWorkdir(session.ID, sessionWorkdir) if err := s.sessionStore.Save(ctx, &session); err != nil { return Session{}, err } return session, nil } - return s.sessionStore.Load(ctx, sessionID) + session, err := s.sessionStore.Load(ctx, sessionID) + if err != nil { + return Session{}, err + } + session.Workdir = s.sessionWorkdir(sessionID, session.Workdir) + if strings.TrimSpace(requestedWorkdir) == "" && strings.TrimSpace(session.Workdir) != "" { + return session, nil + } + + resolved, err := resolveWorkdirForSession(defaultWorkdir, session.Workdir, requestedWorkdir) + if err != nil { + return Session{}, err + } + if session.Workdir == resolved { + return session, nil + } + session.Workdir = resolved + s.setSessionWorkdir(sessionID, resolved) + return session, nil } // emit 向事件通道发送事件。 @@ -453,3 +548,46 @@ func providerRetryBackoff(attempt int) time.Duration { func (s *Service) isRunCanceled(err error) bool { return errors.Is(err, context.Canceled) } + +func effectiveSessionWorkdir(sessionWorkdir string, defaultWorkdir string) string { + workdir := strings.TrimSpace(sessionWorkdir) + if workdir != "" { + return workdir + } + return strings.TrimSpace(defaultWorkdir) +} + +func resolveWorkdirForSession(defaultWorkdir string, currentWorkdir string, requestedWorkdir string) (string, error) { + base := effectiveSessionWorkdir(currentWorkdir, defaultWorkdir) + if strings.TrimSpace(requestedWorkdir) == "" { + return normalizeExistingWorkdir(base) + } + + target := strings.TrimSpace(requestedWorkdir) + if !filepath.IsAbs(target) { + target = filepath.Join(base, target) + } + return normalizeExistingWorkdir(target) +} + +func normalizeExistingWorkdir(workdir string) (string, error) { + trimmed := strings.TrimSpace(workdir) + if trimmed == "" { + return "", errors.New("runtime: workdir is empty") + } + + absolute, err := filepath.Abs(trimmed) + if err != nil { + return "", fmt.Errorf("runtime: resolve workdir: %w", err) + } + + info, err := os.Stat(absolute) + if err != nil { + return "", fmt.Errorf("runtime: resolve workdir: %w", err) + } + if !info.IsDir() { + return "", fmt.Errorf("runtime: workdir %q is not a directory", absolute) + } + + return filepath.Clean(absolute), nil +} diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 6665c943..3bf2e7d9 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "strings" "testing" "time" @@ -1230,18 +1231,178 @@ func TestServiceConstructorsAndDelegates(t *testing.T) { } } +func TestServiceRunUsesSessionWorkdirForContextAndTools(t *testing.T) { + manager := newRuntimeConfigManager(t) + defaultWorkdir := t.TempDir() + sessionWorkdir := t.TempDir() + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Workdir = defaultWorkdir + return nil + }); err != nil { + t.Fatalf("update default workdir: %v", err) + } + + store := newMemoryStore() + session := newSessionWithWorkdir("Session Workdir", sessionWorkdir) + store.sessions[session.ID] = cloneSession(session) + + tool := &stubTool{name: "filesystem_edit", content: "ok"} + registry := tools.NewRegistry() + registry.Register(tool) + + builder := &stubContextBuilder{} + scripted := &scriptedProvider{ + responses: []provider.ChatResponse{ + { + Message: provider.Message{ + Role: "assistant", + ToolCalls: []provider.ToolCall{ + {ID: "call-session-workdir", Name: "filesystem_edit", Arguments: `{"path":"main.go"}`}, + }, + }, + FinishReason: "tool_calls", + }, + { + Message: provider.Message{Role: "assistant", Content: "done"}, + FinishReason: "stop", + }, + }, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, builder) + if err := service.Run(context.Background(), UserInput{ + SessionID: session.ID, + RunID: "run-session-workdir", + Content: "edit", + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if builder.lastInput.Metadata.Workdir != sessionWorkdir { + t.Fatalf("expected context workdir %q, got %q", sessionWorkdir, builder.lastInput.Metadata.Workdir) + } + if tool.lastInput.Workdir != sessionWorkdir { + t.Fatalf("expected tool input workdir %q, got %q", sessionWorkdir, tool.lastInput.Workdir) + } +} + +func TestServiceRunUsesInputWorkdirForNewSession(t *testing.T) { + manager := newRuntimeConfigManager(t) + defaultWorkdir := t.TempDir() + draftRoot := t.TempDir() + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Workdir = defaultWorkdir + return nil + }); err != nil { + t.Fatalf("update default workdir: %v", err) + } + + store := newMemoryStore() + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + builder := &stubContextBuilder{} + scripted := &scriptedProvider{ + responses: []provider.ChatResponse{ + { + Message: provider.Message{Role: "assistant", Content: "done"}, + FinishReason: "stop", + }, + }, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, builder) + if err := service.Run(context.Background(), UserInput{ + RunID: "run-new-session-workdir", + Content: "hello", + Workdir: draftRoot, + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + created := onlySession(t, store) + if created.Workdir != draftRoot { + t.Fatalf("expected session workdir %q, got %q", draftRoot, created.Workdir) + } + if builder.lastInput.Metadata.Workdir != draftRoot { + t.Fatalf("expected context metadata workdir %q, got %q", draftRoot, builder.lastInput.Metadata.Workdir) + } +} + +func TestServiceSetSessionWorkdir(t *testing.T) { + manager := newRuntimeConfigManager(t) + defaultWorkdir := t.TempDir() + target := filepath.Join(defaultWorkdir, "sub") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("mkdir target: %v", err) + } + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Workdir = defaultWorkdir + return nil + }); err != nil { + t.Fatalf("update default workdir: %v", err) + } + + store := newMemoryStore() + session := newSession("set workdir") + store.sessions[session.ID] = cloneSession(session) + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + updated, err := service.SetSessionWorkdir(context.Background(), session.ID, "sub") + if err != nil { + t.Fatalf("SetSessionWorkdir() error = %v", err) + } + if updated.Workdir != target { + t.Fatalf("expected updated workdir %q, got %q", target, updated.Workdir) + } + + loaded, err := service.LoadSession(context.Background(), session.ID) + if err != nil { + t.Fatalf("LoadSession() error = %v", err) + } + if loaded.Workdir != target { + t.Fatalf("expected in-memory workdir %q, got %q", target, loaded.Workdir) + } + + another := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + reloaded, err := another.LoadSession(context.Background(), session.ID) + if err != nil { + t.Fatalf("LoadSession() with new service error = %v", err) + } + if strings.TrimSpace(reloaded.Workdir) != "" { + t.Fatalf("expected session workdir not to persist across process lifetime, got %q", reloaded.Workdir) + } + + _, err = service.SetSessionWorkdir(context.Background(), "", "sub") + if err == nil || !containsError(err, "session id is empty") { + t.Fatalf("expected empty session id error, got %v", err) + } +} + func newRuntimeConfigManager(t *testing.T) *config.Manager { t.Helper() - restoreRuntimeEnv(t, config.OpenAIDefaultAPIKeyEnv) - if err := os.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key"); err != nil { + + apiKeyEnv := runtimeTestAPIKeyEnv(t) + restoreRuntimeEnv(t, apiKeyEnv) + if err := os.Setenv(apiKeyEnv, "test-key"); err != nil { t.Fatalf("set env: %v", err) } - manager := config.NewManager(config.NewLoader(t.TempDir(), config.DefaultConfig())) + + defaults := config.DefaultConfig() + selected := config.NormalizeProviderName(defaults.SelectedProvider) + for i := range defaults.Providers { + if config.NormalizeProviderName(defaults.Providers[i].Name) == selected { + defaults.Providers[i].APIKeyEnv = apiKeyEnv + break + } + } + + manager := config.NewManager(config.NewLoader(t.TempDir(), defaults)) if _, err := manager.Load(context.Background()); err != nil { t.Fatalf("load config: %v", err) } if err := manager.Update(context.Background(), func(cfg *config.Config) error { - cfg.Workdir = t.TempDir() cfg.ToolTimeoutSec = 1 cfg.MaxLoops = 4 return nil @@ -1251,6 +1412,36 @@ func newRuntimeConfigManager(t *testing.T) *config.Manager { return manager } +func runtimeTestAPIKeyEnv(t *testing.T) string { + t.Helper() + + const fallback = "NEOCODE_RUNTIME_TEST_API_KEY" + name := strings.TrimSpace(t.Name()) + if name == "" { + return fallback + } + + var b strings.Builder + b.Grow(len(name)) + for _, r := range name { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r - ('a' - 'A')) + case r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + + suffix := strings.Trim(b.String(), "_") + if suffix == "" { + suffix = "CASE" + } + + return "NEOCODE_RUNTIME_TEST_API_KEY_" + suffix +} + func restoreRuntimeEnv(t *testing.T, key string) { t.Helper() value, ok := os.LookupEnv(key) @@ -1343,6 +1534,87 @@ func containsError(err error, target string) bool { return err != nil && strings.Contains(err.Error(), target) } +func TestWorkdirHelperFunctions(t *testing.T) { + t.Run("effectiveSessionWorkdir prefers session value", func(t *testing.T) { + if got := effectiveSessionWorkdir(" /session ", "/default"); got != "/session" { + t.Fatalf("expected session workdir, got %q", got) + } + if got := effectiveSessionWorkdir("", " /default "); got != "/default" { + t.Fatalf("expected default workdir, got %q", got) + } + }) + + t.Run("resolve workdir handles empty relative absolute and invalid cases", func(t *testing.T) { + defaultDir := t.TempDir() + currentDir := t.TempDir() + relativeTarget := filepath.Join(currentDir, "nested") + if err := os.MkdirAll(relativeTarget, 0o755); err != nil { + t.Fatalf("mkdir relative target: %v", err) + } + absoluteTarget := t.TempDir() + + got, err := resolveWorkdirForSession(defaultDir, "", "") + if err != nil || got != filepath.Clean(defaultDir) { + t.Fatalf("expected default dir %q, got %q / %v", filepath.Clean(defaultDir), got, err) + } + + got, err = resolveWorkdirForSession(defaultDir, currentDir, "nested") + if err != nil || got != filepath.Clean(relativeTarget) { + t.Fatalf("expected relative target %q, got %q / %v", filepath.Clean(relativeTarget), got, err) + } + + got, err = resolveWorkdirForSession(defaultDir, currentDir, absoluteTarget) + if err != nil || got != filepath.Clean(absoluteTarget) { + t.Fatalf("expected absolute target %q, got %q / %v", filepath.Clean(absoluteTarget), got, err) + } + + _, err = resolveWorkdirForSession("", "", "") + if err == nil || !containsError(err, "workdir is empty") { + t.Fatalf("expected empty workdir error, got %v", err) + } + + filePath := filepath.Join(defaultDir, "note.txt") + if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + _, err = normalizeExistingWorkdir(filePath) + if err == nil || !containsError(err, "is not a directory") { + t.Fatalf("expected non-directory error, got %v", err) + } + }) +} + +func TestServiceSetSessionWorkdirNoopDoesNotSave(t *testing.T) { + manager := newRuntimeConfigManager(t) + defaultWorkdir := t.TempDir() + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Workdir = defaultWorkdir + return nil + }); err != nil { + t.Fatalf("update default workdir: %v", err) + } + + store := newMemoryStore() + target := t.TempDir() + session := newSessionWithWorkdir("noop", target) + store.sessions[session.ID] = cloneSession(session) + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + + beforeSaves := store.saves + updated, err := service.SetSessionWorkdir(context.Background(), session.ID, target) + if err != nil { + t.Fatalf("SetSessionWorkdir() error = %v", err) + } + if updated.Workdir != target { + t.Fatalf("expected unchanged workdir %q, got %q", target, updated.Workdir) + } + if store.saves != beforeSaves { + t.Fatalf("expected no extra save on noop update, saves before=%d after=%d", beforeSaves, store.saves) + } +} + func TestIsRetryableProviderError(t *testing.T) { t.Parallel() diff --git a/internal/runtime/session.go b/internal/runtime/session.go index 0e8b41a3..d58df944 100644 --- a/internal/runtime/session.go +++ b/internal/runtime/session.go @@ -22,6 +22,7 @@ type Session struct { Title string `json:"title"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + Workdir string `json:"-"` Messages []provider.Message `json:"messages"` } @@ -165,12 +166,17 @@ func (s *JSONSessionStore) filePath(id string) string { } func newSession(title string) Session { + return newSessionWithWorkdir(title, "") +} + +func newSessionWithWorkdir(title string, workdir string) Session { now := time.Now() return Session{ ID: newID("session"), Title: sanitizeTitle(title), CreatedAt: now, UpdatedAt: now, + Workdir: strings.TrimSpace(workdir), Messages: []provider.Message{}, } } diff --git a/internal/runtime/session_test.go b/internal/runtime/session_test.go index 1981719c..a82f1215 100644 --- a/internal/runtime/session_test.go +++ b/internal/runtime/session_test.go @@ -32,6 +32,7 @@ func TestJSONSessionStoreSaveLoadAndListSummaries(t *testing.T) { Title: "New Session", CreatedAt: time.Now().Add(-30 * time.Minute), UpdatedAt: time.Now(), + Workdir: t.TempDir(), Messages: []provider.Message{ {Role: "user", Content: "new"}, }, @@ -51,10 +52,22 @@ func TestJSONSessionStoreSaveLoadAndListSummaries(t *testing.T) { if loaded.Title != older.Title { t.Fatalf("expected title %q, got %q", older.Title, loaded.Title) } + if loaded.Workdir != "" { + t.Fatalf("expected workdir to stay in-memory only, got %q", loaded.Workdir) + } if len(loaded.Messages) != 2 || loaded.Messages[1].Content != "world" { t.Fatalf("unexpected loaded messages: %+v", loaded.Messages) } + rawPath := filepath.Join(baseDir, sessionsDirName, newer.ID+".json") + raw, err := os.ReadFile(rawPath) + if err != nil { + t.Fatalf("read saved session: %v", err) + } + if strings.Contains(string(raw), "\"workdir\"") { + t.Fatalf("expected persisted session file to exclude workdir, got:\n%s", string(raw)) + } + mustWriteRuntimeFile(t, filepath.Join(baseDir, sessionsDirName, "invalid.json"), "{invalid") if err := os.MkdirAll(filepath.Join(baseDir, sessionsDirName, "directory"), 0o755); err != nil { t.Fatalf("mkdir stray directory: %v", err) diff --git a/internal/runtime/workdir_branch_test.go b/internal/runtime/workdir_branch_test.go new file mode 100644 index 00000000..0edc39ef --- /dev/null +++ b/internal/runtime/workdir_branch_test.go @@ -0,0 +1,97 @@ +package runtime + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSessionWorkdirKeyAndMemoryMap(t *testing.T) { + t.Parallel() + + serviceA := &Service{} + serviceB := &Service{} + + keyA := serviceA.sessionWorkdirKey("session-1") + keyB := serviceB.sessionWorkdirKey("session-1") + if keyA == keyB { + t.Fatalf("expected unique key per service instance, got %q", keyA) + } + + if got := serviceA.sessionWorkdir("session-1", "/fallback"); got != "/fallback" { + t.Fatalf("expected fallback workdir, got %q", got) + } + + target := t.TempDir() + serviceA.setSessionWorkdir("session-1", target) + if got := serviceA.sessionWorkdir("session-1", "/fallback"); got != target { + t.Fatalf("expected mapped workdir %q, got %q", target, got) + } +} + +func TestResolveWorkdirForSessionAndNormalizeErrors(t *testing.T) { + t.Parallel() + + base := t.TempDir() + current := t.TempDir() + relativeDir := filepath.Join(current, "child") + if err := os.MkdirAll(relativeDir, 0o755); err != nil { + t.Fatalf("mkdir relative dir: %v", err) + } + absoluteDir := t.TempDir() + + got, err := resolveWorkdirForSession(base, "", "") + if err != nil || got != filepath.Clean(base) { + t.Fatalf("expected base workdir %q, got %q / %v", filepath.Clean(base), got, err) + } + + got, err = resolveWorkdirForSession(base, current, "child") + if err != nil || got != filepath.Clean(relativeDir) { + t.Fatalf("expected relative workdir %q, got %q / %v", filepath.Clean(relativeDir), got, err) + } + + got, err = resolveWorkdirForSession(base, current, absoluteDir) + if err != nil || got != filepath.Clean(absoluteDir) { + t.Fatalf("expected absolute workdir %q, got %q / %v", filepath.Clean(absoluteDir), got, err) + } + + _, err = resolveWorkdirForSession("", "", "") + if err == nil || !strings.Contains(err.Error(), "workdir is empty") { + t.Fatalf("expected empty workdir error, got %v", err) + } + + _, err = normalizeExistingWorkdir(filepath.Join(base, "missing")) + if err == nil || !strings.Contains(err.Error(), "resolve workdir") { + t.Fatalf("expected missing path error, got %v", err) + } + + filePath := filepath.Join(base, "note.txt") + if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + _, err = normalizeExistingWorkdir(filePath) + if err == nil || !strings.Contains(err.Error(), "is not a directory") { + t.Fatalf("expected non-directory error, got %v", err) + } +} + +func TestLoadSessionUsesFallbackWorkdirWhenMemoryMissing(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + session := newSession("fallback") + session.Workdir = t.TempDir() + store.sessions[session.ID] = cloneSession(session) + + service := NewWithFactory(manager, nil, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + loaded, err := service.LoadSession(context.Background(), session.ID) + if err != nil { + t.Fatalf("LoadSession() error = %v", err) + } + if loaded.Workdir != session.Workdir { + t.Fatalf("expected fallback to persisted workdir %q, got %q", session.Workdir, loaded.Workdir) + } +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 94355d36..74a7ae05 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -20,6 +20,7 @@ const ( slashCommandStatus = "/status" slashCommandProvider = "/provider" slashCommandModelPick = "/model" + slashCommandCWD = "/cwd" slashUsageHelp = "/help" slashUsageExit = "/exit" @@ -27,6 +28,7 @@ const ( slashUsageStatus = "/status" slashUsageProvider = "/provider" slashUsageModel = "/model" + slashUsageWorkdir = "/cwd" commandMenuTitle = "Commands" providerPickerTitle = "Select Provider" @@ -113,6 +115,7 @@ var builtinSlashCommands = []slashCommand{ {Usage: slashUsageHelp, Description: "Show slash command help"}, {Usage: slashUsageClear, Description: "Clear the current draft transcript"}, {Usage: slashUsageStatus, Description: "Show current session and agent status"}, + {Usage: slashUsageWorkdir, Description: "Show or set current session workspace root (/cwd [path])"}, {Usage: slashUsageProvider, Description: "Open the interactive provider picker"}, {Usage: slashUsageModel, Description: "Open the interactive model picker"}, {Usage: slashUsageExit, Description: "Exit NeoCode"}, @@ -404,3 +407,18 @@ func splitFirstWord(input string) (string, string) { } return input[:index], strings.TrimSpace(input[index+1:]) } + +func isWorkspaceSlashCommand(raw string) bool { + command, _ := splitFirstWord(strings.ToLower(strings.TrimSpace(raw))) + return command == slashCommandCWD +} + +func parseWorkspaceSlashCommand(raw string) (string, error) { + command, args := splitFirstWord(strings.TrimSpace(raw)) + switch strings.ToLower(command) { + case slashCommandCWD: + return strings.TrimSpace(args), nil + default: + return "", fmt.Errorf("unknown command %q", command) + } +} diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go index 903bdaa9..a47b8669 100644 --- a/internal/tui/commands_test.go +++ b/internal/tui/commands_test.go @@ -26,6 +26,7 @@ func TestExecuteLocalCommand(t *testing.T) { slashUsageHelp, slashUsageClear, slashUsageStatus, + slashUsageWorkdir, slashUsageProvider, slashUsageModel, slashUsageExit, @@ -186,6 +187,29 @@ func defaultTestStatusSnapshot(manager *config.Manager) statusSnapshot { } func TestCommandHelperFunctions(t *testing.T) { + t.Run("workspace slash parser supports aliases", func(t *testing.T) { + if !isWorkspaceSlashCommand("/cwd ./tmp") { + t.Fatalf("expected /cwd to be recognized") + } + if isWorkspaceSlashCommand("/status") { + t.Fatalf("expected non-workspace slash command to be ignored") + } + args, err := parseWorkspaceSlashCommand("/cwd") + if err != nil || args != "" { + t.Fatalf("expected empty args for /cwd, got %q / %v", args, err) + } + args, err = parseWorkspaceSlashCommand("/cwd ./tmp") + if err != nil || args != "./tmp" { + t.Fatalf("expected ./tmp, got %q / %v", args, err) + } + if _, err := parseWorkspaceSlashCommand("/workspace ./tmp"); err == nil { + t.Fatalf("expected /workspace to be rejected") + } + if _, err := parseWorkspaceSlashCommand("/status"); err == nil { + t.Fatalf("expected unknown slash command to return error") + } + }) + t.Run("splitFirstWord handles empty and remainder", func(t *testing.T) { if first, rest := splitFirstWord(" "); first != "" || rest != "" { t.Fatalf("expected empty split, got %q / %q", first, rest) diff --git a/internal/tui/input_features.go b/internal/tui/input_features.go index 5391d4f6..e5f11ac2 100644 --- a/internal/tui/input_features.go +++ b/internal/tui/input_features.go @@ -56,9 +56,9 @@ func extractWorkspaceCommand(input string) (string, error) { return command, nil } -func runWorkspaceCommand(configManager *config.Manager, raw string) tea.Cmd { +func runWorkspaceCommand(configManager *config.Manager, workdir string, raw string) tea.Cmd { return func() tea.Msg { - command, output, err := executeWorkspaceCommand(context.Background(), configManager, raw) + command, output, err := executeWorkspaceCommand(context.Background(), configManager, workdir, raw) return workspaceCommandResultMsg{ command: command, output: output, @@ -67,22 +67,26 @@ func runWorkspaceCommand(configManager *config.Manager, raw string) tea.Cmd { } } -func executeWorkspaceCommand(ctx context.Context, configManager *config.Manager, raw string) (string, string, error) { +func executeWorkspaceCommand(ctx context.Context, configManager *config.Manager, workdir string, raw string) (string, string, error) { command, err := extractWorkspaceCommand(raw) if err != nil { return "", "", err } cfg := configManager.Get() - output, execErr := workspaceCommandExecutor(ctx, cfg, command) + output, execErr := workspaceCommandExecutor(ctx, cfg, workdir, command) return command, output, execErr } -func defaultWorkspaceCommandExecutor(ctx context.Context, cfg config.Config, command string) (string, error) { +func defaultWorkspaceCommandExecutor(ctx context.Context, cfg config.Config, workdir string, command string) (string, error) { command = strings.TrimSpace(command) if command == "" { return "", errors.New("command is empty") } + targetWorkdir := strings.TrimSpace(workdir) + if targetWorkdir == "" { + targetWorkdir = cfg.Workdir + } timeoutSec := cfg.ToolTimeoutSec if timeoutSec <= 0 { @@ -94,7 +98,7 @@ func defaultWorkspaceCommandExecutor(ctx context.Context, cfg config.Config, com args := shellArgs(cfg.Shell, command) cmd := exec.CommandContext(runCtx, args[0], args[1:]...) - cmd.Dir = cfg.Workdir + cmd.Dir = targetWorkdir output, err := cmd.CombinedOutput() text := sanitizeWorkspaceOutput(output) diff --git a/internal/tui/input_features_test.go b/internal/tui/input_features_test.go index 5014e5ad..eaac62a6 100644 --- a/internal/tui/input_features_test.go +++ b/internal/tui/input_features_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + goruntime "runtime" "strings" "testing" @@ -11,6 +12,40 @@ import ( ) func TestWorkspaceCommandHelpers(t *testing.T) { + t.Run("execute workspace command forwards explicit workdir", func(t *testing.T) { + manager := newTestConfigManager(t) + capturedWorkdir := "" + capturedCommand := "" + previous := workspaceCommandExecutor + t.Cleanup(func() { workspaceCommandExecutor = previous }) + workspaceCommandExecutor = func(ctx context.Context, cfg config.Config, workdir string, command string) (string, error) { + capturedWorkdir = workdir + capturedCommand = command + return "ok", nil + } + + target := t.TempDir() + command, output, err := executeWorkspaceCommand(context.Background(), manager, target, "& git status") + if err != nil { + t.Fatalf("executeWorkspaceCommand() error = %v", err) + } + if command != "git status" || output != "ok" { + t.Fatalf("unexpected execute result command=%q output=%q", command, output) + } + if capturedWorkdir != target || capturedCommand != "git status" { + t.Fatalf("expected forwarded workdir=%q command=%q, got workdir=%q command=%q", target, "git status", capturedWorkdir, capturedCommand) + } + + msg := runWorkspaceCommand(manager, target, "& git status")() + result, ok := msg.(workspaceCommandResultMsg) + if !ok { + t.Fatalf("expected workspaceCommandResultMsg, got %T", msg) + } + if result.err != nil || result.command != "git status" || result.output != "ok" { + t.Fatalf("unexpected runWorkspaceCommand result: %+v", result) + } + }) + t.Run("extract workspace command validates prefix and body", func(t *testing.T) { if _, err := extractWorkspaceCommand("git status"); err == nil { t.Fatalf("expected missing prefix to fail") @@ -46,11 +81,36 @@ func TestWorkspaceCommandHelpers(t *testing.T) { }) t.Run("default workspace command executor rejects empty commands", func(t *testing.T) { - output, err := defaultWorkspaceCommandExecutor(context.Background(), config.Config{}, " ") + output, err := defaultWorkspaceCommandExecutor(context.Background(), config.Config{}, "", " ") if err == nil || !strings.Contains(err.Error(), "empty") || output != "" { t.Fatalf("expected empty command error, got output=%q err=%v", output, err) } }) + + t.Run("default workspace command executor falls back to cfg workdir", func(t *testing.T) { + workdir := t.TempDir() + cfg := config.Config{ + Workdir: workdir, + ToolTimeoutSec: 5, + } + command := "pwd" + if goruntime.GOOS == "windows" { + cfg.Shell = "powershell" + command = "$PWD.Path" + } else { + cfg.Shell = "sh" + } + + output, err := defaultWorkspaceCommandExecutor(context.Background(), cfg, "", command) + if err != nil { + t.Fatalf("defaultWorkspaceCommandExecutor() error = %v", err) + } + normalizedOutput := strings.ToLower(filepath.Clean(strings.TrimSpace(output))) + normalizedWorkdir := strings.ToLower(filepath.Clean(workdir)) + if !strings.Contains(normalizedOutput, normalizedWorkdir) { + t.Fatalf("expected output %q to contain resolved workdir %q", output, workdir) + } + }) } func TestWorkspaceFileHelpers(t *testing.T) { diff --git a/internal/tui/update.go b/internal/tui/update.go index dc4b6349..74b8601f 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "os" + "path/filepath" "strings" "time" @@ -33,6 +35,11 @@ type localCommandResultMsg struct { providerChanged bool modelChanged bool } +type sessionWorkdirResultMsg struct { + notice string + workdir string + err error +} const ( composerMinHeight = 1 @@ -141,6 +148,25 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.appendActivity("command", typed.notice, "", false) } return a, tea.Batch(cmds...) + case sessionWorkdirResultMsg: + if typed.err != nil { + a.state.ExecutionError = typed.err.Error() + a.state.StatusText = typed.err.Error() + a.appendActivity("workspace", "Workspace command failed", typed.err.Error(), true) + return a, tea.Batch(cmds...) + } + + a.state.ExecutionError = "" + a.state.StatusText = typed.notice + a.state.CurrentWorkdir = strings.TrimSpace(typed.workdir) + if err := a.refreshFileCandidates(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("workspace", "Failed to refresh workspace files", err.Error(), true) + return a, tea.Batch(cmds...) + } + a.appendActivity("workspace", typed.notice, "", false) + return a, tea.Batch(cmds...) case workspaceCommandResultMsg: if typed.command == "" && typed.err != nil { a.state.ExecutionError = typed.err.Error() @@ -292,6 +318,12 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te } if strings.HasPrefix(input, slashPrefix) { + if isWorkspaceSlashCommand(input) { + a.state.StatusText = statusApplyingCommand + a.state.ExecutionError = "" + cmds = append(cmds, runSessionWorkdirCommand(a.runtime, a.state.ActiveSessionID, a.state.CurrentWorkdir, input)) + return a, tea.Batch(cmds...) + } a.state.StatusText = statusApplyingCommand cmds = append(cmds, runLocalCommand(a.configManager, a.providerSvc, a.currentStatusSnapshot(), input)) return a, tea.Batch(cmds...) @@ -308,7 +340,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.state.StatusText = statusRunningCommand a.state.ExecutionError = "" a.appendActivity("command", "Running command", command, false) - cmds = append(cmds, runWorkspaceCommand(a.configManager, input)) + cmds = append(cmds, runWorkspaceCommand(a.configManager, a.state.CurrentWorkdir, input)) return a, tea.Batch(cmds...) } @@ -320,7 +352,11 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.state.CurrentTool = "" a.activeMessages = append(a.activeMessages, provider.Message{Role: roleUser, Content: input}) a.rebuildTranscript() - cmds = append(cmds, runAgent(a.runtime, a.state.ActiveSessionID, input)) + requestedWorkdir := "" + if strings.TrimSpace(a.state.ActiveSessionID) == "" { + requestedWorkdir = a.state.CurrentWorkdir + } + cmds = append(cmds, runAgent(a.runtime, a.state.ActiveSessionID, requestedWorkdir, input)) return a, tea.Batch(cmds...) } } @@ -514,6 +550,7 @@ func (a *App) refreshMessages() error { a.activeMessages = session.Messages a.activities = nil a.state.ActiveSessionTitle = session.Title + a.state.CurrentWorkdir = selectSessionWorkdir(session.Workdir, a.configManager.Get().Workdir) return nil } @@ -554,7 +591,9 @@ func (a *App) syncActiveSessionTitle() { func (a *App) syncConfigState(cfg config.Config) { a.state.CurrentProvider = cfg.SelectedProvider a.state.CurrentModel = cfg.CurrentModel - a.state.CurrentWorkdir = cfg.Workdir + if strings.TrimSpace(a.state.CurrentWorkdir) == "" { + a.state.CurrentWorkdir = cfg.Workdir + } } func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) bool { @@ -1056,6 +1095,11 @@ func (a *App) startDraftSession() { a.state.CurrentTool = "" a.input.Reset() a.state.InputText = "" + a.state.CurrentWorkdir = a.configManager.Get().Workdir + if err := a.refreshFileCandidates(); err != nil { + a.state.ExecutionError = err.Error() + a.appendActivity("workspace", "Failed to refresh workspace files", err.Error(), true) + } a.focus = panelInput a.applyFocus() a.resizeComponents() @@ -1082,9 +1126,101 @@ func ListenForRuntimeEvent(sub <-chan agentruntime.RuntimeEvent) tea.Cmd { } } -func runAgent(runtime agentruntime.Runtime, sessionID string, content string) tea.Cmd { +func runAgent(runtime agentruntime.Runtime, sessionID string, workdir string, content string) tea.Cmd { return func() tea.Msg { - err := runtime.Run(context.Background(), agentruntime.UserInput{SessionID: sessionID, Content: content}) + err := runtime.Run(context.Background(), agentruntime.UserInput{ + SessionID: sessionID, + Content: content, + Workdir: workdir, + }) return runFinishedMsg{err: err} } } + +func runSessionWorkdirCommand( + runtime agentruntime.Runtime, + sessionID string, + currentWorkdir string, + raw string, +) tea.Cmd { + return func() tea.Msg { + requested, err := parseWorkspaceSlashCommand(raw) + if err != nil { + return sessionWorkdirResultMsg{err: err} + } + if strings.TrimSpace(requested) == "" { + workdir := strings.TrimSpace(currentWorkdir) + if workdir == "" { + return sessionWorkdirResultMsg{err: fmt.Errorf("usage: /cwd ")} + } + return sessionWorkdirResultMsg{ + notice: fmt.Sprintf("[System] Current workspace is %s.", workdir), + workdir: workdir, + } + } + + if strings.TrimSpace(sessionID) == "" { + workdir, err := resolveWorkspacePath(currentWorkdir, requested) + if err != nil { + return sessionWorkdirResultMsg{err: err} + } + return sessionWorkdirResultMsg{ + notice: fmt.Sprintf("[System] Draft workspace switched to %s.", workdir), + workdir: workdir, + } + } + + session, err := runtime.SetSessionWorkdir(context.Background(), sessionID, requested) + if err != nil { + return sessionWorkdirResultMsg{err: err} + } + workdir := strings.TrimSpace(session.Workdir) + if workdir == "" { + workdir = strings.TrimSpace(currentWorkdir) + } + return sessionWorkdirResultMsg{ + notice: fmt.Sprintf("[System] Session workspace switched to %s.", workdir), + workdir: workdir, + } + } +} + +func resolveWorkspacePath(base string, requested string) (string, error) { + base = strings.TrimSpace(base) + if base == "" { + workingDir, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("workspace: resolve current directory: %w", err) + } + base = workingDir + } + + target := strings.TrimSpace(requested) + if target == "" { + target = "." + } + if !filepath.IsAbs(target) { + target = filepath.Join(base, target) + } + + absolute, err := filepath.Abs(target) + if err != nil { + return "", fmt.Errorf("workspace: resolve path: %w", err) + } + info, err := os.Stat(absolute) + if err != nil { + return "", fmt.Errorf("workspace: resolve path: %w", err) + } + if !info.IsDir() { + return "", fmt.Errorf("workspace: %q is not a directory", absolute) + } + return filepath.Clean(absolute), nil +} + +func selectSessionWorkdir(sessionWorkdir string, defaultWorkdir string) string { + workdir := strings.TrimSpace(sessionWorkdir) + if workdir != "" { + return workdir + } + return strings.TrimSpace(defaultWorkdir) +} diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 0cf9500f..9154a2a9 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "errors" + "os" + "path/filepath" "regexp" "strings" "testing" @@ -22,15 +24,18 @@ import ( ) type stubRuntime struct { - runInputs []agentruntime.UserInput - events chan agentruntime.RuntimeEvent - sessions []agentruntime.SessionSummary - loads map[string]agentruntime.Session - runErr error - listErr error - loadErr error - cancelCalls int - cancelResult bool + runInputs []agentruntime.UserInput + events chan agentruntime.RuntimeEvent + sessions []agentruntime.SessionSummary + loads map[string]agentruntime.Session + runErr error + listErr error + loadErr error + setWorkdirErr error + setResult *agentruntime.Session + setCalls int + cancelCalls int + cancelResult bool } type stubMarkdownRenderer struct { @@ -90,6 +95,23 @@ func (r *stubRuntime) LoadSession(ctx context.Context, id string) (agentruntime. return agentruntime.Session{}, nil } +func (r *stubRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentruntime.Session, error) { + r.setCalls++ + if r.setWorkdirErr != nil { + return agentruntime.Session{}, r.setWorkdirErr + } + if r.setResult != nil { + return *r.setResult, nil + } + session, ok := r.loads[sessionID] + if !ok { + session = agentruntime.Session{ID: sessionID} + } + session.Workdir = strings.TrimSpace(workdir) + r.loads[sessionID] = session + return session, nil +} + func TestAppUpdateComposerCommands(t *testing.T) { tests := []struct { name string @@ -173,6 +195,291 @@ func TestAppUpdateComposerCommands(t *testing.T) { } } +func TestAppUpdateWorkspaceSlashCommands(t *testing.T) { + t.Run("draft workspace command updates local current workdir", func(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + target := t.TempDir() + app.input.SetValue("/cwd " + target) + app.state.InputText = app.input.Value() + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + for _, msg := range collectTeaMessages(cmd) { + model, follow := app.Update(msg) + app = model.(App) + _ = collectTeaMessages(follow) + } + + if len(runtime.runInputs) != 0 { + t.Fatalf("expected slash command not to call runtime.Run, got %+v", runtime.runInputs) + } + if runtime.setCalls != 0 { + t.Fatalf("expected draft workspace change not to call SetSessionWorkdir") + } + if app.state.CurrentWorkdir != target { + t.Fatalf("expected current workdir %q, got %q", target, app.state.CurrentWorkdir) + } + if !strings.Contains(app.state.StatusText, "Draft workspace switched") { + t.Fatalf("expected draft workspace switch status, got %q", app.state.StatusText) + } + }) + + t.Run("session workspace command updates runtime session workdir", func(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + sessionID := "session-workdir" + runtime.loads[sessionID] = agentruntime.Session{ID: sessionID, Workdir: t.TempDir()} + + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + app.state.ActiveSessionID = sessionID + target := t.TempDir() + + app.input.SetValue("/cwd " + target) + app.state.InputText = app.input.Value() + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + for _, msg := range collectTeaMessages(cmd) { + model, follow := app.Update(msg) + app = model.(App) + _ = collectTeaMessages(follow) + } + + if runtime.setCalls != 1 { + t.Fatalf("expected SetSessionWorkdir to be called once, got %d", runtime.setCalls) + } + if app.state.CurrentWorkdir != target { + t.Fatalf("expected current workdir %q, got %q", target, app.state.CurrentWorkdir) + } + if !strings.Contains(app.state.StatusText, "Session workspace switched") { + t.Fatalf("expected session workspace switch status, got %q", app.state.StatusText) + } + }) +} + +func TestRunSessionWorkdirCommandBranches(t *testing.T) { + t.Run("invalid slash command returns parser error", func(t *testing.T) { + msg := runSessionWorkdirCommand(newStubRuntime(), "", "", "/status")() + result, ok := msg.(sessionWorkdirResultMsg) + if !ok { + t.Fatalf("expected sessionWorkdirResultMsg, got %T", msg) + } + if result.err == nil || !strings.Contains(result.err.Error(), "unknown command") { + t.Fatalf("expected unknown command error, got %+v", result) + } + }) + + t.Run("empty workspace query without current workdir returns usage", func(t *testing.T) { + msg := runSessionWorkdirCommand(newStubRuntime(), "", "", "/cwd")() + result := msg.(sessionWorkdirResultMsg) + if result.err == nil || !strings.Contains(result.err.Error(), "usage: /cwd ") { + t.Fatalf("expected usage error, got %+v", result) + } + }) + + t.Run("empty workspace query with current workdir shows current directory", func(t *testing.T) { + current := t.TempDir() + msg := runSessionWorkdirCommand(newStubRuntime(), "", current, "/cwd")() + result := msg.(sessionWorkdirResultMsg) + if result.err != nil { + t.Fatalf("unexpected error: %v", result.err) + } + if result.workdir != current || !strings.Contains(result.notice, "Current workspace is") { + t.Fatalf("expected current workspace message, got %+v", result) + } + }) + + t.Run("session workdir change surfaces runtime error", func(t *testing.T) { + runtime := newStubRuntime() + runtime.setWorkdirErr = errors.New("set workdir failed") + msg := runSessionWorkdirCommand(runtime, "session-1", t.TempDir(), "/cwd ./subdir")() + result := msg.(sessionWorkdirResultMsg) + if result.err == nil || !strings.Contains(result.err.Error(), "set workdir failed") { + t.Fatalf("expected set workdir error, got %+v", result) + } + }) + + t.Run("session workdir fallback uses current workdir when runtime returns empty", func(t *testing.T) { + current := t.TempDir() + runtime := newStubRuntime() + runtime.setResult = &agentruntime.Session{ID: "session-1", Workdir: ""} + msg := runSessionWorkdirCommand(runtime, "session-1", current, "/cwd ./subdir")() + result := msg.(sessionWorkdirResultMsg) + if result.err != nil { + t.Fatalf("unexpected error: %v", result.err) + } + if result.workdir != current { + t.Fatalf("expected fallback workdir %q, got %q", current, result.workdir) + } + }) + + t.Run("session workdir change uses runtime returned workdir when available", func(t *testing.T) { + current := t.TempDir() + target := t.TempDir() + runtime := newStubRuntime() + runtime.setResult = &agentruntime.Session{ID: "session-1", Workdir: target} + msg := runSessionWorkdirCommand(runtime, "session-1", current, "/cwd ./subdir")() + result := msg.(sessionWorkdirResultMsg) + if result.err != nil { + t.Fatalf("unexpected error: %v", result.err) + } + if result.workdir != target || !strings.Contains(result.notice, "Session workspace switched") { + t.Fatalf("expected runtime returned workdir %q, got %+v", target, result) + } + }) + + t.Run("draft workspace change returns resolve error for missing path", func(t *testing.T) { + msg := runSessionWorkdirCommand(newStubRuntime(), "", t.TempDir(), "/cwd ./missing-path")() + result := msg.(sessionWorkdirResultMsg) + if result.err == nil || !strings.Contains(strings.ToLower(result.err.Error()), "resolve path") { + t.Fatalf("expected resolve path error, got %+v", result) + } + }) +} + +func TestResolveWorkspacePathAndSelector(t *testing.T) { + t.Run("resolve from empty base falls back to process cwd", func(t *testing.T) { + resolved, err := resolveWorkspacePath("", ".") + if err != nil { + t.Fatalf("resolveWorkspacePath() error = %v", err) + } + expected, err := filepath.Abs(".") + if err != nil { + t.Fatalf("filepath.Abs(.) error = %v", err) + } + if resolved != filepath.Clean(expected) { + t.Fatalf("expected %q, got %q", filepath.Clean(expected), resolved) + } + }) + + t.Run("resolve relative path from base", func(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "sub") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("mkdir target: %v", err) + } + resolved, err := resolveWorkspacePath(base, "sub") + if err != nil { + t.Fatalf("resolveWorkspacePath() error = %v", err) + } + if resolved != filepath.Clean(target) { + t.Fatalf("expected %q, got %q", filepath.Clean(target), resolved) + } + }) + + t.Run("resolve workspace path rejects non-directory", func(t *testing.T) { + base := t.TempDir() + file := filepath.Join(base, "note.txt") + if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + _, err := resolveWorkspacePath(base, "note.txt") + if err == nil || !strings.Contains(err.Error(), "is not a directory") { + t.Fatalf("expected non-directory error, got %v", err) + } + }) + + t.Run("resolve workspace path returns error for missing target", func(t *testing.T) { + base := t.TempDir() + _, err := resolveWorkspacePath(base, "missing-dir") + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "resolve path") { + t.Fatalf("expected missing path error, got %v", err) + } + }) + + t.Run("select session workdir prefers session value", func(t *testing.T) { + if got := selectSessionWorkdir(" /session ", "/default"); got != "/session" { + t.Fatalf("expected trimmed session workdir, got %q", got) + } + if got := selectSessionWorkdir("", " /default "); got != "/default" { + t.Fatalf("expected fallback default workdir, got %q", got) + } + }) +} + +func TestAppUpdateSessionWorkdirResultMessage(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + model, cmd := app.Update(sessionWorkdirResultMsg{ + notice: "ok", + workdir: filepath.Join(t.TempDir(), "missing-dir"), + }) + app = model.(App) + _ = collectTeaMessages(cmd) + if app.state.ExecutionError == "" || !strings.Contains(strings.ToLower(app.state.ExecutionError), "missing-dir") { + t.Fatalf("expected refresh workspace file error, got %q", app.state.ExecutionError) + } + if app.state.StatusText == "ok" { + t.Fatalf("expected status text to switch to refresh error, got %q", app.state.StatusText) + } +} + +func TestRunAgentWorkdirForwarding(t *testing.T) { + t.Run("draft run forwards current workdir", func(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + target := t.TempDir() + app.state.CurrentWorkdir = target + app.input.SetValue("hello") + app.state.InputText = "hello" + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + _ = collectTeaMessages(cmd) + + if len(runtime.runInputs) != 1 { + t.Fatalf("expected one runtime input, got %d", len(runtime.runInputs)) + } + if runtime.runInputs[0].Workdir != target { + t.Fatalf("expected draft run workdir %q, got %q", target, runtime.runInputs[0].Workdir) + } + }) + + t.Run("session run uses persisted session workdir", func(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.state.ActiveSessionID = "session-1" + app.state.CurrentWorkdir = t.TempDir() + app.input.SetValue("hello") + app.state.InputText = "hello" + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + _ = collectTeaMessages(cmd) + + if len(runtime.runInputs) != 1 { + t.Fatalf("expected one runtime input, got %d", len(runtime.runInputs)) + } + if runtime.runInputs[0].Workdir != "" { + t.Fatalf("expected session run to omit workdir override, got %q", runtime.runInputs[0].Workdir) + } + }) +} + func TestAppUpdateModelPickerAndRuntimeMessages(t *testing.T) { tests := []struct { name string @@ -570,7 +877,7 @@ func TestTUIStandaloneHelpers(t *testing.T) { } runtime := newStubRuntime() - runMsg := runAgent(runtime, "session-x", "hello")() + runMsg := runAgent(runtime, "session-x", "", "hello")() if _, ok := runMsg.(runFinishedMsg); !ok { t.Fatalf("expected runFinishedMsg") } @@ -1950,7 +2257,7 @@ func TestRenderMessageContentShowsPlaceholderWhenRendererMissing(t *testing.T) { func TestWorkspaceCommandAndFileReferenceFlow(t *testing.T) { previousExecutor := workspaceCommandExecutor t.Cleanup(func() { workspaceCommandExecutor = previousExecutor }) - workspaceCommandExecutor = func(ctx context.Context, cfg config.Config, command string) (string, error) { + workspaceCommandExecutor = func(ctx context.Context, cfg config.Config, workdir string, command string) (string, error) { return "stubbed output for " + command, nil }