diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 07d3e01b..5a3d7cf4 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -77,7 +77,7 @@ tools: |------|------|--------|------| | `selected_provider` | string | `openai` | 当前选择的 provider 名称 | | `current_model` | string | 取决于 provider | 当前选择的模型名称 | -| `workdir` | string | `.` (当前目录) | 工作目录的绝对路径 | +| `workdir` | string | `.` (当前目录) | 工作目录的绝对路径;启动时要求路径已存在且必须是目录 | | `shell` | string | `bash` (Linux/Mac)
`powershell` (Windows) | Shell 类型 | | `max_loops` | int | `8` | Agent 推理循环最大轮数 | @@ -93,6 +93,7 @@ tools: **自动管理**: - 首次启动时自动创建默认配置 - `workdir` 自动转换为绝对路径 +- `workdir` 必须指向已存在的目录 - 无效配置会在启动时报错 **最小持久化**: diff --git a/docs/session-persistence-design.md b/docs/session-persistence-design.md index 032fabbd..df071add 100644 --- a/docs/session-persistence-design.md +++ b/docs/session-persistence-design.md @@ -12,7 +12,7 @@ NeoCode 当前使用本地 JSON 文件持久化会话,以保持实现简单、 - 默认目录按工作区隔离:`~/.neocode/projects//sessions/` - 工作区哈希基于启动阶段确定的工作区根目录生成 -- `session.Workdir` 表示会话当前实际执行命令使用的目录,可被运行期命令修改,但不参与分桶 +- `session.Workdir` 表示会话最近一次运行实际使用的目录,由启动 `workdir` 或请求级覆盖值写回,但不参与分桶 - 旧的全局 `~/.neocode/sessions/` 开发期数据不迁移、不回读 ## 数据模型 diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 63b3a51e..9295172f 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -2,9 +2,6 @@ package app import ( "context" - "fmt" - "os" - "path/filepath" "strings" "time" @@ -86,6 +83,8 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er return RuntimeBundle{}, err } + // Session Store 绑定到启动时的 workdir 哈希分桶,整个应用生命周期内不可变。 + // 这意味着所有会话都归属到启动时指定的项目目录下,运行时不会因配置变更而迁移存储位置。 sessionStore := agentsession.NewStore(loader.BaseDir(), cfg.Workdir) runtimeSvc := agentruntime.NewWithFactory( manager, @@ -139,26 +138,7 @@ func bootstrapDefaultConfig(opts BootstrapOptions) (*config.Config, error) { // resolveBootstrapWorkdir 将 CLI 传入的工作区解析为存在的绝对目录。 func resolveBootstrapWorkdir(workdir string) (string, error) { - trimmed := strings.TrimSpace(workdir) - if trimmed == "" { - return "", fmt.Errorf("app: workdir is empty") - } - - absolute, err := filepath.Abs(trimmed) - if err != nil { - return "", fmt.Errorf("app: resolve workdir %q: %w", workdir, err) - } - absolute = filepath.Clean(absolute) - - info, err := os.Stat(absolute) - if err != nil { - return "", fmt.Errorf("app: resolve workdir %q: %w", workdir, err) - } - if !info.IsDir() { - return "", fmt.Errorf("app: workdir %q is not a directory", absolute) - } - - return absolute, nil + return agentsession.ResolveExistingDir(workdir) } func buildToolRegistry(cfg config.Config) (*tools.Registry, error) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3d79682b..a340f94d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -431,6 +431,30 @@ func TestConfigValidateFailures(t *testing.T) { }, expectErr: "workdir must be absolute", }, + { + name: "non-existent workdir", + config: func() *Config { + cfg := testDefaultConfig() + cfg.ApplyDefaultsFrom(*testDefaultConfig()) + cfg.Workdir = filepath.Join(t.TempDir(), "does-not-exist") + return cfg + }(), + expectErr: "workdir does not exist", + }, + { + name: "workdir is a file", + config: func() *Config { + cfg := testDefaultConfig() + cfg.ApplyDefaultsFrom(*testDefaultConfig()) + filePath := filepath.Join(t.TempDir(), "a-file.txt") + if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil { + t.Fatalf("setup: %v", err) + } + cfg.Workdir = filePath + return cfg + }(), + expectErr: "workdir is not a directory", + }, { name: "selected provider model empty", config: func() *Config { diff --git a/internal/config/model.go b/internal/config/model.go index 9c0f1643..8f8061b8 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -11,6 +11,7 @@ import ( "neo-code/internal/provider" providertypes "neo-code/internal/provider/types" + agentsession "neo-code/internal/session" ) const ( @@ -256,6 +257,12 @@ func (c *Config) Validate() error { if !filepath.IsAbs(c.Workdir) { return fmt.Errorf("config: workdir must be absolute, got %q", c.Workdir) } + if _, err := agentsession.ResolveExistingDir(c.Workdir); err != nil { + if strings.Contains(err.Error(), "is not a directory") { + return fmt.Errorf("config: workdir is not a directory: %q", c.Workdir) + } + return fmt.Errorf("config: workdir does not exist: %q", c.Workdir) + } if selected.Source != ProviderSourceCustom && strings.TrimSpace(selected.Model) == "" { return fmt.Errorf("config: selected provider %q has empty model", selected.Name) } diff --git a/internal/gateway/contracts.go b/internal/gateway/contracts.go index 88eba546..4a91e558 100644 --- a/internal/gateway/contracts.go +++ b/internal/gateway/contracts.go @@ -161,8 +161,6 @@ type RuntimePort interface { ListSessions(ctx context.Context) ([]SessionSummary, error) // LoadSession 加载指定会话详情。 LoadSession(ctx context.Context, id string) (Session, error) - // SetSessionWorkdir 更新会话工作目录。 - SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (Session, error) } // Gateway 定义网关主契约。 diff --git a/internal/gateway/contracts_test.go b/internal/gateway/contracts_test.go index e4a4e50c..6dd69dcf 100644 --- a/internal/gateway/contracts_test.go +++ b/internal/gateway/contracts_test.go @@ -33,8 +33,4 @@ func (s *runtimePortCompileStub) LoadSession(_ context.Context, _ string) (Sessi return Session{}, nil } -func (s *runtimePortCompileStub) SetSessionWorkdir(_ context.Context, _, _ string) (Session, error) { - return Session{}, nil -} - var _ RuntimePort = (*runtimePortCompileStub)(nil) diff --git a/internal/gateway/types.go b/internal/gateway/types.go index 281c8132..34807ba8 100644 --- a/internal/gateway/types.go +++ b/internal/gateway/types.go @@ -28,8 +28,6 @@ const ( FrameActionListSessions FrameAction = "list_sessions" // FrameActionLoadSession 表示加载指定会话详情。 FrameActionLoadSession FrameAction = "load_session" - // FrameActionSetSessionWorkdir 表示设置会话工作目录。 - FrameActionSetSessionWorkdir FrameAction = "set_session_workdir" // FrameActionResolvePermission 表示提交一次权限审批决策。 FrameActionResolvePermission FrameAction = "resolve_permission" ) diff --git a/internal/gateway/validate.go b/internal/gateway/validate.go index 3be96857..d83d1a15 100644 --- a/internal/gateway/validate.go +++ b/internal/gateway/validate.go @@ -36,13 +36,6 @@ func validateRequestFrame(frame MessageFrame) *FrameError { if strings.TrimSpace(frame.SessionID) == "" { return NewMissingRequiredFieldError("session_id") } - case FrameActionSetSessionWorkdir: - if strings.TrimSpace(frame.SessionID) == "" { - return NewMissingRequiredFieldError("session_id") - } - if strings.TrimSpace(frame.Workdir) == "" { - return NewMissingRequiredFieldError("workdir") - } case FrameActionResolvePermission: return validateResolvePermissionFrame(frame) case FrameActionCancel, FrameActionListSessions: @@ -180,7 +173,6 @@ func isValidFrameAction(action FrameAction) bool { FrameActionCancel, FrameActionListSessions, FrameActionLoadSession, - FrameActionSetSessionWorkdir, FrameActionResolvePermission: return true default: diff --git a/internal/gateway/validate_test.go b/internal/gateway/validate_test.go index d1e86c92..48267aa0 100644 --- a/internal/gateway/validate_test.go +++ b/internal/gateway/validate_test.go @@ -72,26 +72,6 @@ func TestValidateFrame_BasicRules(t *testing.T) { wantCode: ErrorCodeMissingRequiredField.String(), wantField: "session_id", }, - { - name: "set_session_workdir missing workdir", - frame: MessageFrame{ - Type: FrameTypeRequest, - Action: FrameActionSetSessionWorkdir, - SessionID: "sess_1", - }, - wantCode: ErrorCodeMissingRequiredField.String(), - wantField: "workdir", - }, - { - name: "set_session_workdir valid", - frame: MessageFrame{ - Type: FrameTypeRequest, - Action: FrameActionSetSessionWorkdir, - SessionID: "sess_1", - Workdir: "/workspace", - }, - wantNil: true, - }, { name: "resolve_permission valid struct payload", frame: MessageFrame{ diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 2a89957c..148c46d7 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -6,7 +6,6 @@ import ( "fmt" "log" "math/rand/v2" - "os" "path/filepath" "sort" "strings" @@ -118,7 +117,6 @@ type Runtime interface { Events() <-chan RuntimeEvent ListSessions(ctx context.Context) ([]agentsession.Summary, error) LoadSession(ctx context.Context, id string) (agentsession.Session, error) - SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) } type UserInput struct { @@ -423,34 +421,6 @@ func (s *Service) LoadSession(ctx context.Context, id string) (agentsession.Sess return session, nil } -func (s *Service) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { - sessionID = strings.TrimSpace(sessionID) - if sessionID == "" { - return agentsession.Session{}, errors.New("runtime: session id is empty") - } - - session, err := s.sessionStore.Load(ctx, sessionID) - if err != nil { - return agentsession.Session{}, err - } - - cfg := s.configManager.Get() - resolved, err := resolveWorkdirForSession(cfg.Workdir, session.Workdir, workdir) - if err != nil { - return agentsession.Session{}, err - } - if session.Workdir == resolved { - return session, nil - } - - session.Workdir = resolved - session.UpdatedAt = time.Now() - if err := s.sessionStore.Save(ctx, &session); err != nil { - return agentsession.Session{}, err - } - return session, nil -} - func (s *Service) loadOrCreateSession( ctx context.Context, sessionID string, @@ -894,44 +864,22 @@ func (v permissionEventView) toResolvedPayload() PermissionResolvedPayload { } func effectiveSessionWorkdir(sessionWorkdir string, defaultWorkdir string) string { - workdir := strings.TrimSpace(sessionWorkdir) - if workdir != "" { - return workdir - } - return strings.TrimSpace(defaultWorkdir) + return agentsession.EffectiveWorkdir(sessionWorkdir, defaultWorkdir) } func resolveWorkdirForSession(defaultWorkdir string, currentWorkdir string, requestedWorkdir string) (string, error) { base := effectiveSessionWorkdir(currentWorkdir, defaultWorkdir) if strings.TrimSpace(requestedWorkdir) == "" { - return normalizeExistingWorkdir(base) + return agentsession.ResolveExistingDir(base) } target := strings.TrimSpace(requestedWorkdir) if !filepath.IsAbs(target) { target = filepath.Join(base, target) } - return normalizeExistingWorkdir(target) + return agentsession.ResolveExistingDir(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 + return agentsession.ResolveExistingDir(workdir) } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index acb72e97..8fad9bda 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -2478,58 +2478,6 @@ func TestServiceRunUsesInputWorkdirForNewSession(t *testing.T) { } } -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 := agentsession.New("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 reloaded.Workdir != target { - t.Fatalf("expected session workdir to persist across service 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 { return newRuntimeConfigManagerWithProviderEnvs(t, nil) } @@ -2764,37 +2712,6 @@ func TestWorkdirHelperFunctions(t *testing.T) { }) } -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 := agentsession.NewWithWorkdir("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() @@ -2934,43 +2851,6 @@ func TestLoadSessionReturnsStoreError(t *testing.T) { } } -func TestSetSessionWorkdirReturnsStoreError(t *testing.T) { - t.Parallel() - - manager := newRuntimeConfigManager(t) - store := newMemoryStore() - service := NewWithFactory(manager, nil, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) - - _, err := service.SetSessionWorkdir(context.Background(), "missing", t.TempDir()) - if err == nil || !containsError(err, "not found") { - t.Fatalf("expected load error from SetSessionWorkdir, got %v", err) - } -} - -func TestSetSessionWorkdirReturnsResolveError(t *testing.T) { - t.Parallel() - - 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 config: %v", err) - } - - store := newMemoryStore() - session := agentsession.New("set bad workdir") - session.ID = "session-set-bad-workdir" - store.sessions[session.ID] = cloneSession(session) - - service := NewWithFactory(manager, nil, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) - _, err := service.SetSessionWorkdir(context.Background(), session.ID, filepath.Join(defaultWorkdir, "missing-dir")) - if err == nil || !containsError(err, "resolve workdir") { - t.Fatalf("expected resolve workdir error, got %v", err) - } -} - func TestServiceRunFailsWhenInitialUserMessageSaveFails(t *testing.T) { t.Parallel() diff --git a/internal/session/workspace.go b/internal/session/workspace.go index dd244204..01bb5ec9 100644 --- a/internal/session/workspace.go +++ b/internal/session/workspace.go @@ -3,6 +3,8 @@ package session import ( "crypto/sha1" "encoding/hex" + "fmt" + "os" "path/filepath" goruntime "runtime" "strings" @@ -50,3 +52,37 @@ func normalizeWorkspaceRoot(workspaceRoot string) string { } return filepath.Clean(trimmed) } + +// EffectiveWorkdir 优先返回会话工作目录,缺失时回退到默认工作目录。 +// 供 runtime、TUI 等上层模块统一调用,避免在多处重复实现回退逻辑。 +func EffectiveWorkdir(sessionWorkdir string, defaultWorkdir string) string { + workdir := strings.TrimSpace(sessionWorkdir) + if workdir != "" { + return workdir + } + return strings.TrimSpace(defaultWorkdir) +} + +// ResolveExistingDir 将路径解析为存在的绝对目录路径,用于启动校验和运行时路径规范化。 +// 要求路径非空、可解析为绝对路径、存在且为目录。 +func ResolveExistingDir(path string) (string, error) { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return "", fmt.Errorf("workdir is empty") + } + + absolute, err := filepath.Abs(trimmed) + if err != nil { + return "", fmt.Errorf("resolve workdir %q: %w", trimmed, err) + } + + info, err := os.Stat(absolute) + if err != nil { + return "", fmt.Errorf("resolve workdir %q: %w", trimmed, err) + } + if !info.IsDir() { + return "", fmt.Errorf("workdir %q is not a directory", absolute) + } + + return filepath.Clean(absolute), nil +} diff --git a/internal/session/workspace_test.go b/internal/session/workspace_test.go new file mode 100644 index 00000000..37f131c9 --- /dev/null +++ b/internal/session/workspace_test.go @@ -0,0 +1,90 @@ +package session + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEffectiveWorkdir(t *testing.T) { + tests := []struct { + name string + sessionWorkdir string + defaultWorkdir string + want string + }{ + {"prefer session workdir", "/session", "/default", "/session"}, + {"fallback to default", "", "/default", "/default"}, + {"both empty", "", "", ""}, + {"session with whitespace", " /session ", "/default", "/session"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := EffectiveWorkdir(tt.sessionWorkdir, tt.defaultWorkdir); got != tt.want { + t.Errorf("EffectiveWorkdir() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestResolveExistingDir(t *testing.T) { + t.Run("resolves absolute directory", func(t *testing.T) { + dir := t.TempDir() + got, err := ResolveExistingDir(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != filepath.Clean(dir) { + t.Fatalf("expected %q, got %q", filepath.Clean(dir), got) + } + }) + + t.Run("resolves relative directory", func(t *testing.T) { + base := t.TempDir() + sub := filepath.Join(base, "sub") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + got, err := ResolveExistingDir(sub) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != filepath.Clean(sub) { + t.Fatalf("expected %q, got %q", filepath.Clean(sub), got) + } + }) + + t.Run("rejects empty", func(t *testing.T) { + _, err := ResolveExistingDir("") + if err == nil || !strings.Contains(err.Error(), "workdir is empty") { + t.Fatalf("expected empty error, got %v", err) + } + }) + + t.Run("rejects whitespace", func(t *testing.T) { + _, err := ResolveExistingDir(" ") + if err == nil || !strings.Contains(err.Error(), "workdir is empty") { + t.Fatalf("expected empty error, got %v", err) + } + }) + + t.Run("rejects non-existent path", func(t *testing.T) { + _, err := ResolveExistingDir(filepath.Join(t.TempDir(), "missing")) + if err == nil { + t.Fatalf("expected error for missing path") + } + }) + + t.Run("rejects file path", func(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "note.txt") + if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + _, err := ResolveExistingDir(filePath) + if err == nil || !strings.Contains(err.Error(), "is not a directory") { + t.Fatalf("expected not-a-directory error, got %v", err) + } + }) +} diff --git a/internal/tui/bootstrap/builder_test.go b/internal/tui/bootstrap/builder_test.go index deeb87e2..f2440e65 100644 --- a/internal/tui/bootstrap/builder_test.go +++ b/internal/tui/bootstrap/builder_test.go @@ -43,10 +43,6 @@ func (r *testRuntime) LoadSession(ctx context.Context, id string) (agentsession. return agentsession.Session{}, nil } -func (r *testRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { - return agentsession.Session{}, nil -} - type testProviderService struct{} func (s *testProviderService) ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) { @@ -226,10 +222,6 @@ func (r noopRuntime) LoadSession(ctx context.Context, id string) (agentsession.S return agentsession.Session{}, nil } -func (r noopRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { - return agentsession.Session{}, nil -} - type noopProviderService struct{} func (s noopProviderService) ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) { diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index 9e813f39..5d3dc5fd 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -46,7 +46,6 @@ type runFinishedMsg = tuistate.RunFinishedMsg type modelCatalogRefreshMsg = tuistate.ModelCatalogRefreshMsg type compactFinishedMsg = tuistate.CompactFinishedMsg type localCommandResultMsg = tuistate.LocalCommandResultMsg -type sessionWorkdirResultMsg = tuistate.SessionWorkdirResultMsg type workspaceCommandResultMsg = tuistate.WorkspaceCommandResultMsg type permissionResolutionFinishedMsg = tuistate.PermissionResolutionFinishedMsg @@ -206,9 +205,10 @@ func newApp(container tuibootstrap.Container) (App, error) { app := App{ state: tuistate.UIState{ - StatusText: statusReady, - CurrentProvider: cfg.SelectedProvider, - CurrentModel: cfg.CurrentModel, + StatusText: statusReady, + CurrentProvider: cfg.SelectedProvider, + CurrentModel: cfg.CurrentModel, + // Workdir 在启动阶段由 config 校验过,此处直接使用。 CurrentWorkdir: cfg.Workdir, ActiveSessionTitle: draftSessionTitle, Focus: panelInput, diff --git a/internal/tui/core/app/commands.go b/internal/tui/core/app/commands.go index ac954835..5ec52502 100644 --- a/internal/tui/core/app/commands.go +++ b/internal/tui/core/app/commands.go @@ -24,7 +24,6 @@ const ( slashCommandStatus = "/status" slashCommandProvider = "/provider" slashCommandModelPick = "/model" - slashCommandCWD = "/cwd" slashUsageHelp = "/help" slashUsageExit = "/exit" @@ -33,7 +32,6 @@ const ( slashUsageStatus = "/status" slashUsageProvider = "/provider" slashUsageModel = "/model" - slashUsageWorkdir = "/cwd" commandMenuTitle = "Suggestions" providerPickerTitle = "Select Provider" @@ -109,7 +107,6 @@ var builtinSlashCommands = []slashCommand{ {Usage: slashUsageClear, Description: "Clear the current draft transcript"}, {Usage: slashUsageCompact, Description: "Compact the current session context"}, {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"}, @@ -400,11 +397,3 @@ func slashHelpText() string { func splitFirstWord(input string) (string, string) { return tuicommands.SplitFirstWord(input) } - -func isWorkspaceSlashCommand(raw string) bool { - return tuicommands.IsWorkspaceSlashCommand(raw, slashCommandCWD) -} - -func parseWorkspaceSlashCommand(raw string) (string, error) { - return tuicommands.ParseWorkspaceSlashCommand(raw, slashCommandCWD) -} diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index 192ceabe..9fb117a7 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -17,11 +17,10 @@ import ( "neo-code/internal/config" providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" + agentsession "neo-code/internal/session" "neo-code/internal/tools" - tuicommands "neo-code/internal/tui/core/commands" tuistatus "neo-code/internal/tui/core/status" tuiutils "neo-code/internal/tui/core/utils" - tuiworkspace "neo-code/internal/tui/core/workspace" tuiservices "neo-code/internal/tui/services" tuistate "neo-code/internal/tui/state" ) @@ -187,25 +186,6 @@ 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() @@ -392,12 +372,6 @@ 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...) @@ -430,7 +404,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.rebuildTranscript() runID := fmt.Sprintf("run-%d", a.now().UnixNano()) a.state.ActiveRunID = runID - requestedWorkdir := tuiutils.RequestedWorkdirForRun(a.state.ActiveSessionID, a.state.CurrentWorkdir) + requestedWorkdir := tuiutils.RequestedWorkdirForRun(a.state.CurrentWorkdir) cmds = append(cmds, runAgent(a.runtime, runID, a.state.ActiveSessionID, requestedWorkdir, input)) return a, tea.Batch(cmds...) } @@ -699,7 +673,7 @@ func (a *App) refreshMessages() error { a.activeMessages = session.Messages a.clearActivities() a.state.ActiveSessionTitle = session.Title - a.state.CurrentWorkdir = tuiworkspace.SelectSessionWorkdir(session.Workdir, a.configManager.Get().Workdir) + a.setCurrentWorkdir(agentsession.EffectiveWorkdir(session.Workdir, a.configManager.Get().Workdir)) a.refreshRuntimeSourceSnapshot() return nil } @@ -742,7 +716,7 @@ func (a *App) syncConfigState(cfg config.Config) { a.state.CurrentProvider = cfg.SelectedProvider a.state.CurrentModel = cfg.CurrentModel if strings.TrimSpace(a.state.CurrentWorkdir) == "" { - a.state.CurrentWorkdir = cfg.Workdir + a.setCurrentWorkdir(cfg.Workdir) } } @@ -875,7 +849,7 @@ func runtimeEventRunContextHandler(a *App, event agentruntime.RuntimeEvent) bool a.state.CurrentModel = mapped.Model } if strings.TrimSpace(mapped.Workdir) != "" { - a.state.CurrentWorkdir = mapped.Workdir + a.setCurrentWorkdir(mapped.Workdir) } return false } @@ -1715,9 +1689,6 @@ func (a *App) runSlashCommandSelection(command string) tea.Cmd { default: a.state.StatusText = statusApplyingCommand a.state.ExecutionError = "" - if isWorkspaceSlashCommand(command) { - return runSessionWorkdirCommand(a.runtime, a.state.ActiveSessionID, a.state.CurrentWorkdir, command) - } return runLocalCommand(a.configManager, a.providerSvc, a.currentStatusSnapshot(), command) } } @@ -1748,7 +1719,7 @@ func (a *App) startDraftSession() { a.clearRunProgress() a.input.Reset() a.state.InputText = "" - a.state.CurrentWorkdir = a.configManager.Get().Workdir + a.setCurrentWorkdir(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) @@ -1812,30 +1783,6 @@ func runResolvePermission( ) } -func runSessionWorkdirCommand( - runtime agentruntime.Runtime, - sessionID string, - currentWorkdir string, - raw string, -) tea.Cmd { - return func() tea.Msg { - result := tuicommands.ExecuteSessionWorkdirCommand( - runtime, - sessionID, - currentWorkdir, - raw, - parseWorkspaceSlashCommand, - tuiworkspace.ResolveWorkspacePath, - tuiworkspace.SelectSessionWorkdir, - ) - return sessionWorkdirResultMsg{ - Notice: result.Notice, - Workdir: result.Workdir, - Err: result.Err, - } - } -} - // runCompact 鍦ㄧ嫭绔嬪懡浠や腑瑙﹀彂 runtime compact锛屽苟鎶婄粨鏋滃洖浼犵粰 TUI銆 func runCompact(runtime agentruntime.Runtime, sessionID string) tea.Cmd { return tuiservices.RunCompactCmd( @@ -1849,3 +1796,13 @@ func runCompact(runtime agentruntime.Runtime, sessionID string) tea.Cmd { func (a App) isBusy() bool { return tuiutils.IsBusy(a.state.IsAgentRunning, a.state.IsCompacting) } + +// setCurrentWorkdir 统一设置当前工作目录,仅接受非空白且为绝对路径的值。 +// 非法值会被静默忽略,防止 runtime 事件或异常输入污染 UI 状态。 +func (a *App) setCurrentWorkdir(workdir string) { + trimmed := strings.TrimSpace(workdir) + if trimmed == "" || !filepath.IsAbs(trimmed) { + return + } + a.state.CurrentWorkdir = trimmed +} diff --git a/internal/tui/core/app/update_permission_test.go b/internal/tui/core/app/update_permission_test.go index d340f226..6f42fc75 100644 --- a/internal/tui/core/app/update_permission_test.go +++ b/internal/tui/core/app/update_permission_test.go @@ -53,10 +53,6 @@ func (r *permissionTestRuntime) LoadSession(ctx context.Context, id string) (age return agentsession.Session{}, nil } -func (r *permissionTestRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { - return agentsession.Session{}, nil -} - func newPermissionTestApp(runtime agentruntime.Runtime) *App { input := textarea.New() spin := spinner.New() diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index a5d47680..b88140f8 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -3,6 +3,7 @@ package tui import ( "context" "errors" + "path/filepath" "strings" "testing" @@ -56,6 +57,7 @@ type stubRuntime struct { resolveCalls []agentruntime.PermissionResolutionInput resolveErr error cancelInvoked bool + loadSessionFn func(context.Context, string) (agentsession.Session, error) } func newStubRuntime() *stubRuntime { @@ -89,13 +91,12 @@ func (s *stubRuntime) ListSessions(ctx context.Context) ([]agentsession.Summary, } func (s *stubRuntime) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { + if s.loadSessionFn != nil { + return s.loadSessionFn(ctx, id) + } return agentsession.NewWithWorkdir("draft", ""), nil } -func (s *stubRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { - return agentsession.NewWithWorkdir("draft", workdir), nil -} - func newTestApp(t *testing.T) (App, *stubRuntime) { t.Helper() @@ -914,10 +915,11 @@ func TestRuntimeEventUserMessageHandler(t *testing.T) { func TestRuntimeEventRunContextHandler(t *testing.T) { app, _ := newTestApp(t) + workdir := t.TempDir() payload := tuiservices.RuntimeRunContextPayload{ Provider: "p1", Model: "m1", - Workdir: "/tmp", + Workdir: workdir, } event := agentruntime.RuntimeEvent{RunID: "run-2", SessionID: "s1", Payload: payload} handled := runtimeEventRunContextHandler(&app, event) @@ -927,6 +929,49 @@ func TestRuntimeEventRunContextHandler(t *testing.T) { if app.state.CurrentProvider != "p1" || app.state.CurrentModel != "m1" { t.Fatalf("expected provider/model to update") } + if app.state.CurrentWorkdir != workdir { + t.Fatalf("expected workdir %q, got %q", workdir, app.state.CurrentWorkdir) + } +} + +func TestRefreshMessagesUsesSessionWorkdirWhenPresent(t *testing.T) { + app, runtime := newTestApp(t) + app.state.ActiveSessionID = "session-1" + sessionWorkdir := t.TempDir() + runtime.loadSessionFn = func(ctx context.Context, id string) (agentsession.Session, error) { + session := agentsession.NewWithWorkdir("persisted", sessionWorkdir) + session.ID = id + session.Messages = []providertypes.Message{{Role: roleAssistant, Content: "hello"}} + return session, nil + } + + if err := app.refreshMessages(); err != nil { + t.Fatalf("refreshMessages() error = %v", err) + } + if app.state.CurrentWorkdir != sessionWorkdir { + t.Fatalf("expected session workdir %q, got %q", sessionWorkdir, app.state.CurrentWorkdir) + } + if app.state.ActiveSessionTitle != "persisted" { + t.Fatalf("expected session title to refresh, got %q", app.state.ActiveSessionTitle) + } +} + +func TestRefreshMessagesFallsBackToConfiguredWorkdir(t *testing.T) { + app, runtime := newTestApp(t) + app.state.ActiveSessionID = "session-1" + configWorkdir := app.configManager.Get().Workdir + runtime.loadSessionFn = func(ctx context.Context, id string) (agentsession.Session, error) { + session := agentsession.NewWithWorkdir("persisted", "") + session.ID = id + return session, nil + } + + if err := app.refreshMessages(); err != nil { + t.Fatalf("refreshMessages() error = %v", err) + } + if app.state.CurrentWorkdir != configWorkdir { + t.Fatalf("expected configured workdir %q, got %q", configWorkdir, app.state.CurrentWorkdir) + } } func TestRuntimeEventToolStatusHandler(t *testing.T) { @@ -1209,23 +1254,8 @@ func TestRunSlashCommandSelectionModelRefreshError(t *testing.T) { } } -func TestRunSlashCommandSelectionWorkspaceAndLocal(t *testing.T) { +func TestRunSlashCommandSelectionLocalCommand(t *testing.T) { app, _ := newTestApp(t) - app.state.ActiveSessionID = "" - app.state.CurrentWorkdir = t.TempDir() - - workspaceCmd := app.runSlashCommandSelection("/cwd") - if workspaceCmd == nil { - t.Fatalf("expected workspace slash cmd") - } - workspaceMsg := workspaceCmd() - workspaceResult, ok := workspaceMsg.(sessionWorkdirResultMsg) - if !ok { - t.Fatalf("expected sessionWorkdirResultMsg, got %T", workspaceMsg) - } - if workspaceResult.Err != nil { - t.Fatalf("expected no workspace error, got %v", workspaceResult.Err) - } localCmd := app.runSlashCommandSelection(slashCommandStatus) if localCmd == nil { @@ -1326,3 +1356,39 @@ func TestStartDraftSessionResetsRunState(t *testing.T) { t.Fatalf("expected activities to be cleared") } } + +func TestSetCurrentWorkdir(t *testing.T) { + app, _ := newTestApp(t) + + t.Run("accepts absolute path", func(t *testing.T) { + dir := t.TempDir() + app.setCurrentWorkdir(dir) + if app.state.CurrentWorkdir != filepath.Clean(dir) { + t.Fatalf("expected %q, got %q", filepath.Clean(dir), app.state.CurrentWorkdir) + } + }) + + t.Run("ignores empty", func(t *testing.T) { + app.state.CurrentWorkdir = "/original" + app.setCurrentWorkdir("") + if app.state.CurrentWorkdir != "/original" { + t.Fatalf("expected no change, got %q", app.state.CurrentWorkdir) + } + }) + + t.Run("ignores whitespace", func(t *testing.T) { + app.state.CurrentWorkdir = "/original" + app.setCurrentWorkdir(" ") + if app.state.CurrentWorkdir != "/original" { + t.Fatalf("expected no change, got %q", app.state.CurrentWorkdir) + } + }) + + t.Run("ignores relative path", func(t *testing.T) { + app.state.CurrentWorkdir = "/original" + app.setCurrentWorkdir("relative/path") + if app.state.CurrentWorkdir != "/original" { + t.Fatalf("expected no change, got %q", app.state.CurrentWorkdir) + } + }) +} diff --git a/internal/tui/core/commands/parser.go b/internal/tui/core/commands/parser.go index 1ffac80e..8f368092 100644 --- a/internal/tui/core/commands/parser.go +++ b/internal/tui/core/commands/parser.go @@ -1,9 +1,6 @@ package commands -import ( - "fmt" - "strings" -) +import "strings" // SlashCommand 描述单个 slash 命令定义。 type SlashCommand struct { @@ -60,18 +57,3 @@ func SplitFirstWord(input string) (string, string) { } return input[:index], strings.TrimSpace(input[index+1:]) } - -// IsWorkspaceSlashCommand 判断是否为工作区命令(例如 /cwd)。 -func IsWorkspaceSlashCommand(raw string, commandName string) bool { - command, _ := SplitFirstWord(strings.ToLower(strings.TrimSpace(raw))) - return command == strings.ToLower(strings.TrimSpace(commandName)) -} - -// ParseWorkspaceSlashCommand 解析工作区命令参数,非目标命令时返回错误。 -func ParseWorkspaceSlashCommand(raw string, commandName string) (string, error) { - command, args := SplitFirstWord(strings.TrimSpace(raw)) - if strings.ToLower(command) != strings.ToLower(strings.TrimSpace(commandName)) { - return "", fmt.Errorf("unknown command %q", command) - } - return strings.TrimSpace(args), nil -} diff --git a/internal/tui/core/commands/parser_test.go b/internal/tui/core/commands/parser_test.go index a43915c4..93232e4f 100644 --- a/internal/tui/core/commands/parser_test.go +++ b/internal/tui/core/commands/parser_test.go @@ -43,24 +43,3 @@ func TestSplitFirstWord(t *testing.T) { t.Fatalf("expected empty split for blank input, got first=%q rest=%q", first, rest) } } - -func TestWorkspaceSlashCommandHelpers(t *testing.T) { - if !IsWorkspaceSlashCommand("/cwd ./tmp", "/cwd") { - t.Fatalf("expected /cwd to be recognized") - } - if IsWorkspaceSlashCommand("/status", "/cwd") { - t.Fatalf("did not expect /status as workspace command") - } - - args, err := ParseWorkspaceSlashCommand("/cwd ./tmp", "/cwd") - if err != nil { - t.Fatalf("ParseWorkspaceSlashCommand() error = %v", err) - } - if args != "./tmp" { - t.Fatalf("expected args ./tmp, got %q", args) - } - - if _, err := ParseWorkspaceSlashCommand("/status", "/cwd"); err == nil { - t.Fatalf("expected parse error for non-workspace command") - } -} diff --git a/internal/tui/core/commands/workspace.go b/internal/tui/core/commands/workspace.go deleted file mode 100644 index 08be31d1..00000000 --- a/internal/tui/core/commands/workspace.go +++ /dev/null @@ -1,70 +0,0 @@ -package commands - -import ( - "context" - "fmt" - "strings" - - agentsession "neo-code/internal/session" -) - -// SessionWorkdirSetter 定义设置会话工作目录所需的最小 runtime 能力。 -type SessionWorkdirSetter interface { - SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) -} - -// SessionWorkdirCommandResult 表示工作目录命令执行结果。 -type SessionWorkdirCommandResult struct { - Notice string - Workdir string - Err error -} - -// ExecuteSessionWorkdirCommand 执行 /cwd 命令的核心流程,返回统一结果结构。 -func ExecuteSessionWorkdirCommand( - runtime SessionWorkdirSetter, - sessionID string, - currentWorkdir string, - raw string, - parseCommand func(string) (string, error), - resolveWorkspacePath func(string, string) (string, error), - selectSessionWorkdir func(string, string) string, -) SessionWorkdirCommandResult { - requested, err := parseCommand(raw) - if err != nil { - return SessionWorkdirCommandResult{Err: err} - } - - if strings.TrimSpace(requested) == "" { - workdir := strings.TrimSpace(currentWorkdir) - if workdir == "" { - return SessionWorkdirCommandResult{Err: fmt.Errorf("usage: /cwd ")} - } - return SessionWorkdirCommandResult{ - Notice: fmt.Sprintf("[System] Current workspace is %s.", workdir), - Workdir: workdir, - } - } - - if strings.TrimSpace(sessionID) == "" { - workdir, err := resolveWorkspacePath(currentWorkdir, requested) - if err != nil { - return SessionWorkdirCommandResult{Err: err} - } - return SessionWorkdirCommandResult{ - Notice: fmt.Sprintf("[System] Draft workspace switched to %s.", workdir), - Workdir: workdir, - } - } - - session, err := runtime.SetSessionWorkdir(context.Background(), sessionID, requested) - if err != nil { - return SessionWorkdirCommandResult{Err: err} - } - - workdir := selectSessionWorkdir(session.Workdir, currentWorkdir) - return SessionWorkdirCommandResult{ - Notice: fmt.Sprintf("[System] Session workspace switched to %s.", workdir), - Workdir: workdir, - } -} diff --git a/internal/tui/core/commands/workspace_test.go b/internal/tui/core/commands/workspace_test.go deleted file mode 100644 index 4ed48934..00000000 --- a/internal/tui/core/commands/workspace_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package commands - -import ( - "context" - "errors" - "os" - "path/filepath" - "strings" - "testing" - - agentsession "neo-code/internal/session" - tuiworkspace "neo-code/internal/tui/core/workspace" -) - -type stubSessionWorkdirSetter struct { - session agentsession.Session - err error - calls int -} - -func (s *stubSessionWorkdirSetter) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { - s.calls++ - if s.err != nil { - return agentsession.Session{}, s.err - } - return s.session, nil -} - -func TestExecuteSessionWorkdirCommand(t *testing.T) { - parse := func(raw string) (string, error) { - raw = strings.TrimSpace(raw) - if raw == "/bad" { - return "", errors.New("unknown command") - } - if raw == "/cwd" { - return "", nil - } - if strings.HasPrefix(raw, "/cwd ") { - return strings.TrimSpace(strings.TrimPrefix(raw, "/cwd ")), nil - } - return "", errors.New("unknown command") - } - - t.Run("parse error", func(t *testing.T) { - result := ExecuteSessionWorkdirCommand(&stubSessionWorkdirSetter{}, "", "", "/bad", parse, tuiworkspace.ResolveWorkspacePath, tuiworkspace.SelectSessionWorkdir) - if result.Err == nil { - t.Fatalf("expected parse error") - } - }) - - t.Run("empty requested without current workdir", func(t *testing.T) { - result := ExecuteSessionWorkdirCommand(&stubSessionWorkdirSetter{}, "", "", "/cwd", parse, tuiworkspace.ResolveWorkspacePath, tuiworkspace.SelectSessionWorkdir) - if result.Err == nil || !strings.Contains(result.Err.Error(), "usage: /cwd ") { - t.Fatalf("expected usage error, got %+v", result) - } - }) - - t.Run("empty requested with current workdir", func(t *testing.T) { - current := t.TempDir() - result := ExecuteSessionWorkdirCommand(&stubSessionWorkdirSetter{}, "", current, "/cwd", parse, tuiworkspace.ResolveWorkspacePath, tuiworkspace.SelectSessionWorkdir) - if result.Err != nil { - t.Fatalf("unexpected error: %v", result.Err) - } - if result.Workdir != current || !strings.Contains(result.Notice, "Current workspace is") { - t.Fatalf("unexpected result: %+v", result) - } - }) - - t.Run("draft session resolves requested path", func(t *testing.T) { - base := t.TempDir() - target := filepath.Join(base, "sub") - if err := ensureDir(target); err != nil { - t.Fatalf("mkdir target: %v", err) - } - result := ExecuteSessionWorkdirCommand(&stubSessionWorkdirSetter{}, "", base, "/cwd sub", parse, tuiworkspace.ResolveWorkspacePath, tuiworkspace.SelectSessionWorkdir) - if result.Err != nil { - t.Fatalf("unexpected error: %v", result.Err) - } - if !strings.Contains(result.Notice, "Draft workspace switched") { - t.Fatalf("unexpected notice: %q", result.Notice) - } - }) - - t.Run("runtime error", func(t *testing.T) { - stub := &stubSessionWorkdirSetter{err: errors.New("set workdir failed")} - result := ExecuteSessionWorkdirCommand(stub, "session-1", t.TempDir(), "/cwd sub", parse, tuiworkspace.ResolveWorkspacePath, tuiworkspace.SelectSessionWorkdir) - if result.Err == nil || !strings.Contains(result.Err.Error(), "set workdir failed") { - t.Fatalf("expected runtime error, got %+v", result) - } - }) - - t.Run("runtime empty workdir fallback", func(t *testing.T) { - current := t.TempDir() - stub := &stubSessionWorkdirSetter{session: agentsession.Session{ID: "session-1", Workdir: ""}} - result := ExecuteSessionWorkdirCommand(stub, "session-1", current, "/cwd sub", parse, tuiworkspace.ResolveWorkspacePath, tuiworkspace.SelectSessionWorkdir) - 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) - } - }) -} - -func ensureDir(path string) error { - return os.MkdirAll(path, 0o755) -} diff --git a/internal/tui/core/utils/view_helpers.go b/internal/tui/core/utils/view_helpers.go index 58c0373c..4f2c691f 100644 --- a/internal/tui/core/utils/view_helpers.go +++ b/internal/tui/core/utils/view_helpers.go @@ -22,12 +22,10 @@ func PickerLabelFromMode(mode tuistate.PickerMode) string { } } -// RequestedWorkdirForRun 在发起 run 时计算应转发的工作目录。 -func RequestedWorkdirForRun(activeSessionID string, currentWorkdir string) string { - if strings.TrimSpace(activeSessionID) == "" { - return currentWorkdir - } - return "" +// RequestedWorkdirForRun 在发起 run 时返回应转发给 runtime 的工作目录。 +// `/cwd` 移除后,TUI 内部始终以当前界面工作目录作为本次运行的覆盖值。 +func RequestedWorkdirForRun(currentWorkdir string) string { + return strings.TrimSpace(currentWorkdir) } // IsBusy 统一判断当前是否存在进行中的 agent 或 compact 操作。 diff --git a/internal/tui/core/utils/view_helpers_test.go b/internal/tui/core/utils/view_helpers_test.go index d700f1fc..1c8d191b 100644 --- a/internal/tui/core/utils/view_helpers_test.go +++ b/internal/tui/core/utils/view_helpers_test.go @@ -29,18 +29,18 @@ func TestPickerLabelFromMode(t *testing.T) { func TestRequestedWorkdirForRun(t *testing.T) { tests := []struct { - name string - activeSessionID string - currentWorkdir string - want string + name string + currentWorkdir string + want string }{ - {"empty session returns current", "", "/home/user", "/home/user"}, - {"active session returns empty", "session-1", "/home/user", ""}, + {"returns current workdir", "/home/user", "/home/user"}, + {"trims whitespace", " /home/user ", "/home/user"}, + {"empty stays empty", " ", ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := RequestedWorkdirForRun(tt.activeSessionID, tt.currentWorkdir); got != tt.want { + if got := RequestedWorkdirForRun(tt.currentWorkdir); got != tt.want { t.Errorf("RequestedWorkdirForRun() = %v, want %v", got, tt.want) } }) diff --git a/internal/tui/core/workspace/resolver.go b/internal/tui/core/workspace/resolver.go deleted file mode 100644 index ccf6c103..00000000 --- a/internal/tui/core/workspace/resolver.go +++ /dev/null @@ -1,50 +0,0 @@ -package workspace - -import ( - "fmt" - "os" - "path/filepath" - "strings" -) - -// ResolveWorkspacePath 解析并校验工作区路径,确保返回存在且可用的目录绝对路径。 -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 -} - -// SelectSessionWorkdir 优先返回会话工作目录,缺失时回退到默认工作目录。 -func SelectSessionWorkdir(sessionWorkdir string, defaultWorkdir string) string { - workdir := strings.TrimSpace(sessionWorkdir) - if workdir != "" { - return workdir - } - return strings.TrimSpace(defaultWorkdir) -} diff --git a/internal/tui/core/workspace/resolver_test.go b/internal/tui/core/workspace/resolver_test.go deleted file mode 100644 index 0d1d9621..00000000 --- a/internal/tui/core/workspace/resolver_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package workspace - -import ( - "os" - "path/filepath" - "testing" -) - -func TestResolveWorkspacePath(t *testing.T) { - base := t.TempDir() - subdir := filepath.Join(base, "sub") - if err := os.MkdirAll(subdir, 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - - tests := []struct { - name string - base string - requested string - check func(t *testing.T, got string) - wantErr bool - }{ - {"resolve absolute path", base, subdir, func(t *testing.T, got string) { - if got != subdir { - t.Errorf("expected %v, got %v", subdir, got) - } - }, false}, - {"resolve relative path", base, "sub", func(t *testing.T, got string) { - if got != subdir { - t.Errorf("expected %v, got %v", subdir, got) - } - }, false}, - {"empty base uses cwd", "", ".", func(t *testing.T, got string) { - cwd, err := os.Getwd() - if err != nil { - t.Fatalf("getwd: %v", err) - } - if got != cwd { - t.Errorf("expected %v, got %v", cwd, got) - } - }, false}, - {"empty requested uses dot", base, "", func(t *testing.T, got string) { - if got != base { - t.Errorf("expected %v, got %v", base, got) - } - }, false}, - {"non-existent path", base, "nonexistent", func(t *testing.T, got string) {}, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := ResolveWorkspacePath(tt.base, tt.requested) - if (err != nil) != tt.wantErr { - t.Errorf("ResolveWorkspacePath() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !tt.wantErr && tt.check != nil { - tt.check(t, got) - } - }) - } -} - -func TestSelectSessionWorkdir(t *testing.T) { - tests := []struct { - name string - sessionWorkdir string - defaultWorkdir string - want string - }{ - {"prefer session workdir", "/session", "/default", "/session"}, - {"fallback to default", "", "/default", "/default"}, - {"both empty", "", "", ""}, - {"session with whitespace", " /session ", "/default", "/session"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := SelectSessionWorkdir(tt.sessionWorkdir, tt.defaultWorkdir); got != tt.want { - t.Errorf("SelectSessionWorkdir() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/internal/tui/state/messages.go b/internal/tui/state/messages.go index 8be2f0e7..3eb3cd25 100644 --- a/internal/tui/state/messages.go +++ b/internal/tui/state/messages.go @@ -45,13 +45,6 @@ type LocalCommandResultMsg struct { ModelChanged bool } -// SessionWorkdirResultMsg 表示会话工作目录命令结果。 -type SessionWorkdirResultMsg struct { - Notice string - Workdir string - Err error -} - // WorkspaceCommandResultMsg 表示工作区命令执行结果。 type WorkspaceCommandResultMsg struct { Command string