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