diff --git a/AGENTS.md b/AGENTS.md index 1002316b..0d6919fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,7 @@ - `internal/runtime`:ReAct 主循环、事件流、Prompt 编排、token 累积与自动压缩触发。 - `internal/session`:会话领域模型、存储抽象与 JSON 持久化实现。 - `internal/tools`:工具契约、注册表、参数校验和具体工具实现。 +- `internal/workspace`:工作区路径规范化、Git 根目录识别与 `workspaceRoot/workdir` 解析。 - `internal/tui`:Bubble Tea 状态机、渲染层、Slash Command 和事件桥接。 - `docs`:架构、配置、事件流、会话持久化等说明文档。 @@ -38,6 +39,7 @@ - `runtime` 负责会话编排、消息上下文流转、tool schema 传递、tool call 识别、tool result 回灌、token 累积、事件派发和停止条件。runtime 不替 context 做压缩决策。 - `session` 负责会话领域模型与 JSON 持久化,包括 token 累计值的持久化。 - `tools` 负责统一的 `schema + execute + result` 协议,以及参数校验、错误包装和输出格式收敛。 +- `workspace` 负责统一的工作区解析语义,优先识别 Git 顶层仓库并输出稳定的 `workspaceRoot`。 - `tui` 只消费 runtime 事件并负责展示,不直接调用 provider,不直接执行 tools。 ## 4. AI 修改代码时的执行流程 diff --git a/README.md b/README.md index cc30f1a9..2315cfc6 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ go run ./cmd/neocode - **`internal/runtime`** — ReAct 主循环与事件流编排(不直接承载会话存储实现;不再导出会话模型与存储类型) - **`internal/session`** — 会话模型、会话存储抽象与 JSON 持久化实现(统一对外暴露 `Session` / `Summary` / `Store`) - **`internal/tools`** — 工具注册表与具体工具实现 +- **`internal/workspace`** — 统一工作区解析与 Git 根目录识别 - **`internal/tui`** — 终端 UI、交互体验、事件桥接 - **`internal/app`** — 应用装配与依赖注入 @@ -204,7 +205,8 @@ go run ./cmd/neocode --workdir /path/to/workspace 说明: - `--workdir` 只影响当前进程,不会写回 `config.yaml` -- 当前工作区会同时用于工具执行根目录与 session 存储分桶 +- 启动路径会先解析为 `workdir`,再优先向上回溯 Git 顶层仓库作为 `workspaceRoot` +- 工具沙箱和 session 存储分桶固定绑定到 `workspaceRoot`,不会因 `/cwd` 切到子目录而缩小边界 - session 历史现在按工作区隔离存储,不同工作区默认互不可见 [![Contributors](https://hub-io-mcells-projects.vercel.app/r/1024XEngineer/neo-code)](https://github.com/1024XEngineer/neo-code/graphs/contributors) diff --git a/docs/session-persistence-design.md b/docs/session-persistence-design.md index fb22d26d..032fabbd 100644 --- a/docs/session-persistence-design.md +++ b/docs/session-persistence-design.md @@ -1,44 +1,66 @@ # Session 持久化设计 -## 模块职责与收口边界 -- `internal/session`:承载会话领域模型、存储抽象与 JSON 持久化实现,是唯一的会话持久化实现归属层 -- `internal/runtime`:只依赖 `internal/session` 提供的抽象与模型,负责会话保存时机与主循环编排,不再维护会话存储实现细节 -- `internal/tui`:仅消费 runtime 暴露的会话数据,不直接执行会话持久化 +## 模块职责与边界 + +- `internal/session` 是会话领域模型、存储抽象与 JSON 持久化实现的唯一归属层。 +- `internal/runtime` 负责决定保存时机、恢复会话状态和编排主循环,不承载文件存储细节。 +- `internal/tui` 只消费 runtime 暴露的会话数据,不直接读写会话文件。 ## 存储策略 -NeoCode 在 MVP 阶段使用 JSON 文件持久化 Session,以保持本地优先、易于调试和跨平台可移植。 + +NeoCode 当前使用本地 JSON 文件持久化会话,以保持实现简单、可调试且跨平台可移植。 + +- 默认目录按工作区隔离:`~/.neocode/projects//sessions/` +- 工作区哈希基于启动阶段确定的工作区根目录生成 +- `session.Workdir` 表示会话当前实际执行命令使用的目录,可被运行期命令修改,但不参与分桶 +- 旧的全局 `~/.neocode/sessions/` 开发期数据不迁移、不回读 ## 数据模型 -- `Session`:完整消息历史以及 `id`、`title`、`updated_at`、`token_input_total`、`token_output_total` 等元信息 -- `Summary`:用于侧边栏的轻量摘要结构(原 `SessionSummary` 命名已统一收口为 `Summary`) -### Token 持久化 -- `token_input_total` 和 `token_output_total` 分别记录会话累计输入和输出 token。 -- 使用 `omitempty` 标签,确保旧版 JSON 文件正常加载(零值不序列化)。 -- runtime 在每次 provider 调用后更新 session 的 token 字段,随 session save 一起持久化。 -- 会话加载时,runtime 从 session 恢复 token 计数器;新建会话时计数器清零。 -- 自动压缩成功后 token 计数器重置为零,并持久化到 session。 +`internal/session.Session` 持久化以下核心字段: + +- `id`、`title` +- `provider`、`model` +- `created_at`、`updated_at` +- `workdir` +- `messages` +- `token_input_total` +- `token_output_total` + +其中: + +- `provider` / `model` 记录最近一次成功运行会话时使用的配置,供 compact 等流程优先复用 +- `token_input_total` / `token_output_total` 分别表示会话累计输入与输出 token +- token 字段使用 `omitempty`,以兼容旧版 session JSON 文件 -## 加载策略 -- `ListSummaries` 只读取渲染侧边栏所需的基础信息 -- `Load` 仅在用户真正进入某个会话时读取完整消息历史 -- `Save` 通过临时文件原子写入完整 Session +`internal/session.Summary` 只保留会话列表渲染所需的轻量字段,不加载完整消息历史。 -## 命名策略 -- 新会话默认展示为 `New Session` -- 一旦持久化,runtime 会根据首轮用户消息生成简短标题 +## 读写行为 + +- `Save` 使用“临时文件 + 原子替换”写入完整会话 JSON +- `Load` 在用户真正进入某个会话时读取完整历史 +- `ListSummaries` 只解析摘要字段,并按 `updated_at` 倒序返回 + +## Token 计数持久化 + +- runtime 在 provider 调用完成后更新 session 的累计 token 字段 +- 会话保存时,token 计数随 session 一起持久化 +- 会话重新加载时,runtime 从 session 恢复累计 token +- 自动 compact 成功后,runtime 会重置累计 token,并将重置后的值持久化 + +## 保存时机 + +- 用户消息提交后保存 +- assistant 完整回复后保存 +- 每个工具结果完成后保存 +- 避免在高频 UI 刷新路径中直接做磁盘 I/O ## 并发约束 -- `internal/session` 中的 Store 实现必须自行保护共享访问 -- 真正的保存时机由 runtime 决定,TUI 不负责直接触发磁盘写入 -## 兼容性与演进说明 -- 会话持久化能力已从 runtime 侧实现中彻底收口到 `internal/session` -- 新增会话存储实现时,应优先在 `internal/session` 内扩展并通过接口注入 runtime,避免跨层实现 +- `internal/session` 的存储实现自行保护共享访问 +- 保存时机统一由 runtime 决定,TUI 不直接触发磁盘写入 -## 工作区隔离 +## 演进约束 -- session 现按工作区隔离存储,目录规则为 `~/.neocode/projects//sessions/` -- 工作区哈希基于启动时确定的工作区根目录生成,而不是基于 `session.Workdir` -- `session.Workdir` 仍表示该会话当前实际执行命令时使用的目录,可被 `/cwd` 修改 -- 旧的全局 `~/.neocode/sessions/` 开发期数据不迁移、不回读 +- 新增存储实现时,应优先在 `internal/session` 内扩展并通过接口注入 +- 不应把持久化逻辑重新分散到 `runtime`、`tui` 或其他上层模块 diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 29fa456a..c15080ff 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -3,8 +3,6 @@ package app import ( "context" "fmt" - "os" - "path/filepath" "strings" "time" @@ -22,6 +20,8 @@ import ( "neo-code/internal/tools/filesystem" "neo-code/internal/tools/webfetch" "neo-code/internal/tui" + tuibootstrap "neo-code/internal/tui/bootstrap" + agentworkspace "neo-code/internal/workspace" ) const utf8CodePage = 65001 @@ -42,6 +42,8 @@ type RuntimeBundle struct { ConfigManager *config.Manager Runtime agentruntime.Runtime ProviderSelection *config.SelectionService + WorkspaceRoot string + Workdir string } // EnsureConsoleUTF8 负责在 Windows 控制台中尽量启用 UTF-8 编码。 @@ -76,8 +78,21 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er } cfg := manager.Get() + workspaceResolution, err := agentworkspace.Resolve(cfg.Workdir) + if err != nil { + return RuntimeBundle{}, err + } + if cfg.Workdir != workspaceResolution.Workdir { + if err := manager.Update(ctx, func(cfg *config.Config) error { + cfg.Workdir = workspaceResolution.Workdir + return nil + }); err != nil { + return RuntimeBundle{}, err + } + cfg = manager.Get() + } - toolRegistry, err := buildToolRegistry(cfg) + toolRegistry, err := buildToolRegistry(cfg, workspaceResolution.WorkspaceRoot) if err != nil { return RuntimeBundle{}, err } @@ -86,9 +101,11 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er return RuntimeBundle{}, err } - sessionStore := agentsession.NewStore(loader.BaseDir(), cfg.Workdir) - runtimeSvc := agentruntime.NewWithFactory( + sessionStore := agentsession.NewStore(loader.BaseDir(), workspaceResolution.WorkspaceRoot) + runtimeSvc := agentruntime.NewWithWorkspace( manager, + workspaceResolution.WorkspaceRoot, + workspaceResolution.Workdir, toolManager, sessionStore, providerRegistry, @@ -100,6 +117,8 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er ConfigManager: manager, Runtime: runtimeSvc, ProviderSelection: providerSelection, + WorkspaceRoot: workspaceResolution.WorkspaceRoot, + Workdir: workspaceResolution.Workdir, }, nil } @@ -110,7 +129,28 @@ func NewProgram(ctx context.Context, opts BootstrapOptions) (*tea.Program, error return nil, err } - tuiApp, err := tui.New(&bundle.Config, bundle.ConfigManager, bundle.Runtime, bundle.ProviderSelection) + tuiApp, err := tui.NewWithBootstrap(tuibootstrap.Options{ + Config: &bundle.Config, + ConfigManager: bundle.ConfigManager, + Runtime: bundle.Runtime, + ProviderService: bundle.ProviderSelection, + WorkspaceRoot: bundle.WorkspaceRoot, + Workdir: bundle.Workdir, + RebuildWorkspace: func(ctx context.Context, requestedPath string) (tuibootstrap.WorkspaceBinding, error) { + rebuilt, err := BuildRuntime(ctx, BootstrapOptions{Workdir: requestedPath}) + if err != nil { + return tuibootstrap.WorkspaceBinding{}, err + } + return tuibootstrap.WorkspaceBinding{ + Config: rebuilt.Config, + ConfigManager: rebuilt.ConfigManager, + Runtime: rebuilt.Runtime, + ProviderService: rebuilt.ProviderSelection, + WorkspaceRoot: rebuilt.WorkspaceRoot, + Workdir: rebuilt.Workdir, + }, nil + }, + }) if err != nil { return nil, err } @@ -139,36 +179,24 @@ func bootstrapDefaultConfig(opts BootstrapOptions) (*config.Config, error) { // resolveBootstrapWorkdir 将 CLI 传入的工作区解析为存在的绝对目录。 func resolveBootstrapWorkdir(workdir string) (string, error) { - trimmed := strings.TrimSpace(workdir) - if trimmed == "" { + if strings.TrimSpace(workdir) == "" { return "", fmt.Errorf("app: workdir is empty") } - - absolute, err := filepath.Abs(trimmed) + resolved, err := agentworkspace.Resolve(workdir) 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 resolved.Workdir, nil } -func buildToolRegistry(cfg config.Config) (*tools.Registry, error) { +func buildToolRegistry(cfg config.Config, workspaceRoot string) (*tools.Registry, error) { toolRegistry := tools.NewRegistry() - toolRegistry.Register(filesystem.New(cfg.Workdir)) - toolRegistry.Register(filesystem.NewWrite(cfg.Workdir)) - toolRegistry.Register(filesystem.NewGrep(cfg.Workdir)) - toolRegistry.Register(filesystem.NewGlob(cfg.Workdir)) - toolRegistry.Register(filesystem.NewEdit(cfg.Workdir)) - toolRegistry.Register(bash.New(cfg.Workdir, cfg.Shell, time.Duration(cfg.ToolTimeoutSec)*time.Second)) + toolRegistry.Register(filesystem.New(workspaceRoot)) + toolRegistry.Register(filesystem.NewWrite(workspaceRoot)) + toolRegistry.Register(filesystem.NewGrep(workspaceRoot)) + toolRegistry.Register(filesystem.NewGlob(workspaceRoot)) + toolRegistry.Register(filesystem.NewEdit(workspaceRoot)) + toolRegistry.Register(bash.New(workspaceRoot, cfg.Shell, time.Duration(cfg.ToolTimeoutSec)*time.Second)) toolRegistry.Register(webfetch.New(webfetch.Config{ Timeout: time.Duration(cfg.ToolTimeoutSec) * time.Second, MaxResponseBytes: cfg.Tools.WebFetch.MaxResponseBytes, diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index c896cc8a..91ea4346 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -22,6 +22,16 @@ import ( "neo-code/internal/tools/mcp" ) +func replaceRegisterMCPStdioServerHook(t *testing.T, fn registerMCPStdioServerFunc) { + t.Helper() + + original := registerMCPStdioServerHook.Load().(registerMCPStdioServerFunc) + registerMCPStdioServerHook.Store(fn) + t.Cleanup(func() { + registerMCPStdioServerHook.Store(original) + }) +} + func TestNewProgram(t *testing.T) { disableBuiltinProviderAPIKeys(t) @@ -91,7 +101,7 @@ func TestBuildToolRegistryUsesWebFetchConfig(t *testing.T) { cfg.Workdir = t.TempDir() cfg.Tools.WebFetch.MaxResponseBytes = 4 - registry, err := buildToolRegistry(cfg) + registry, err := buildToolRegistry(cfg, cfg.Workdir) if err != nil { t.Fatalf("buildToolRegistry() error = %v", err) } @@ -142,14 +152,12 @@ func TestBuildMCPRegistryFromConfig(t *testing.T) { }, } - originalRegister := registerMCPStdioServer - t.Cleanup(func() { registerMCPStdioServer = originalRegister }) - registerMCPStdioServer = func(registry *mcp.Registry, cfg config.Config, server config.MCPServerConfig) error { + replaceRegisterMCPStdioServerHook(t, func(registry *mcp.Registry, cfg config.Config, server config.MCPServerConfig) error { if err := registry.RegisterServer(server.ID, "stdio", server.Version, stubClient); err != nil { return err } return registry.RefreshServerTools(context.Background(), server.ID) - } + }) registry, err := buildMCPRegistry(cfg) if err != nil { @@ -292,9 +300,7 @@ func TestBuildToolRegistryIncludesMCPFromConfig(t *testing.T) { }, } - originalRegister := registerMCPStdioServer - t.Cleanup(func() { registerMCPStdioServer = originalRegister }) - registerMCPStdioServer = func(registry *mcp.Registry, cfg config.Config, server config.MCPServerConfig) error { + replaceRegisterMCPStdioServerHook(t, func(registry *mcp.Registry, cfg config.Config, server config.MCPServerConfig) error { client := &stubMCPServerClient{ tools: []mcp.ToolDescriptor{ {Name: "search", Description: "search docs", InputSchema: map[string]any{"type": "object"}}, @@ -304,9 +310,9 @@ func TestBuildToolRegistryIncludesMCPFromConfig(t *testing.T) { return err } return registry.RefreshServerTools(context.Background(), server.ID) - } + }) - registry, err := buildToolRegistry(cfg) + registry, err := buildToolRegistry(cfg, cfg.Workdir) if err != nil { t.Fatalf("buildToolRegistry() error = %v", err) } @@ -339,7 +345,7 @@ func TestBuildToolRegistryReturnsMCPSourceError(t *testing.T) { }, } - _, err := buildToolRegistry(cfg) + _, err := buildToolRegistry(cfg, cfg.Workdir) if err == nil || !strings.Contains(strings.ToLower(err.Error()), "unsupported mcp source") { t.Fatalf("expected unsupported mcp source error, got %v", err) } @@ -444,11 +450,9 @@ func TestBuildMCPRegistryRegisterError(t *testing.T) { {ID: "docs", Enabled: true, Source: "stdio"}, } - originalRegister := registerMCPStdioServer - t.Cleanup(func() { registerMCPStdioServer = originalRegister }) - registerMCPStdioServer = func(registry *mcp.Registry, cfg config.Config, server config.MCPServerConfig) error { + replaceRegisterMCPStdioServerHook(t, func(registry *mcp.Registry, cfg config.Config, server config.MCPServerConfig) error { return errors.New("register failed") - } + }) _, err := buildMCPRegistry(cfg) if err == nil || !strings.Contains(err.Error(), "register failed") { @@ -471,9 +475,7 @@ func TestBuildMCPRegistryRollbackRegisteredServersOnFailure(t *testing.T) { "search": new(bool), } - originalRegister := registerMCPStdioServer - t.Cleanup(func() { registerMCPStdioServer = originalRegister }) - registerMCPStdioServer = func(registry *mcp.Registry, cfg config.Config, server config.MCPServerConfig) error { + replaceRegisterMCPStdioServerHook(t, func(registry *mcp.Registry, cfg config.Config, server config.MCPServerConfig) error { client := &closeableStubMCPServerClient{closed: closedByServer[strings.TrimSpace(server.ID)]} if err := registry.RegisterServer(server.ID, "stdio", server.Version, client); err != nil { return err @@ -482,7 +484,7 @@ func TestBuildMCPRegistryRollbackRegisteredServersOnFailure(t *testing.T) { return errors.New("search register failed") } return nil - } + }) registry, err := buildMCPRegistry(cfg) if err == nil || !strings.Contains(err.Error(), "search register failed") { @@ -608,6 +610,37 @@ func TestBuildRuntimeUsesWorkdirOverride(t *testing.T) { } } +func TestBuildRuntimeTracksWorkspaceRootAcrossNestedOverride(t *testing.T) { + disableBuiltinProviderAPIKeys(t) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + repoRoot := filepath.Join(t.TempDir(), "repo") + nested := filepath.Join(repoRoot, "pkg", "nested") + if err := os.MkdirAll(filepath.Join(repoRoot, ".git"), 0o755); err != nil { + t.Fatalf("mkdir git marker: %v", err) + } + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatalf("mkdir nested: %v", err) + } + + bundle, err := BuildRuntime(context.Background(), BootstrapOptions{Workdir: nested}) + if err != nil { + t.Fatalf("BuildRuntime() error = %v", err) + } + if bundle.Workdir != nested { + t.Fatalf("expected workdir %q, got %q", nested, bundle.Workdir) + } + if bundle.WorkspaceRoot != repoRoot { + t.Fatalf("expected workspace root %q, got %q", repoRoot, bundle.WorkspaceRoot) + } + if bundle.ConfigManager.Get().Workdir != nested { + t.Fatalf("expected config manager workdir %q, got %q", nested, bundle.ConfigManager.Get().Workdir) + } +} + func TestBuildRuntimeRejectsInvalidWorkdirOverride(t *testing.T) { disableBuiltinProviderAPIKeys(t) @@ -705,6 +738,31 @@ func TestResolveBootstrapWorkdirRejectsEmptyAndFile(t *testing.T) { } } +func TestResolveBootstrapWorkdirResolvesRelativeCurrentDirectory(t *testing.T) { + previous, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + + workdir := t.TempDir() + if err := os.Chdir(workdir); err != nil { + t.Fatalf("chdir workdir: %v", err) + } + t.Cleanup(func() { + if chdirErr := os.Chdir(previous); chdirErr != nil { + t.Fatalf("restore cwd: %v", chdirErr) + } + }) + + resolved, err := resolveBootstrapWorkdir(".") + if err != nil { + t.Fatalf("resolveBootstrapWorkdir(.) error = %v", err) + } + if resolved != workdir { + t.Fatalf("expected resolved workdir %q, got %q", workdir, resolved) + } +} + func TestEnsureConsoleUTF8SetsOutputThenInput(t *testing.T) { originalOutput := setConsoleOutputCodePage originalInput := setConsoleInputCodePage diff --git a/internal/app/mcp_bootstrap.go b/internal/app/mcp_bootstrap.go index d0253b0c..21c0db7c 100644 --- a/internal/app/mcp_bootstrap.go +++ b/internal/app/mcp_bootstrap.go @@ -6,14 +6,33 @@ import ( "os" "path/filepath" "strings" + "sync/atomic" "time" "neo-code/internal/config" "neo-code/internal/tools/mcp" ) -var newMCPStdioClient = mcp.NewStdIOClient -var registerMCPStdioServer = defaultRegisterMCPStdioServer +type newMCPStdioClientFunc func(cfg mcp.StdioClientConfig) (*mcp.StdIOClient, error) +type registerMCPStdioServerFunc func(registry *mcp.Registry, cfg config.Config, server config.MCPServerConfig) error + +var newMCPStdioClientHook atomic.Value +var registerMCPStdioServerHook atomic.Value + +func init() { + newMCPStdioClientHook.Store(newMCPStdioClientFunc(mcp.NewStdIOClient)) + registerMCPStdioServerHook.Store(registerMCPStdioServerFunc(defaultRegisterMCPStdioServer)) +} + +// currentNewMCPStdioClient 返回当前用于创建 stdio client 的 hook,便于测试安全替换。 +func currentNewMCPStdioClient() newMCPStdioClientFunc { + return newMCPStdioClientHook.Load().(newMCPStdioClientFunc) +} + +// currentRegisterMCPStdioServer 返回当前用于注册 stdio server 的 hook,避免并行测试共享裸全局函数。 +func currentRegisterMCPStdioServer() registerMCPStdioServerFunc { + return registerMCPStdioServerHook.Load().(registerMCPStdioServerFunc) +} // buildMCPRegistry 按配置构建并初始化 MCP registry;若无启用 server 则返回 nil。 func buildMCPRegistry(cfg config.Config) (*mcp.Registry, error) { @@ -33,7 +52,7 @@ func buildMCPRegistry(cfg config.Config) (*mcp.Registry, error) { switch strings.ToLower(strings.TrimSpace(server.Source)) { case "", "stdio": - if err := registerMCPStdioServer(registry, cfg, server); err != nil { + if err := currentRegisterMCPStdioServer()(registry, cfg, server); err != nil { rollbackMCPServers(registry, append(registeredServerIDs, strings.TrimSpace(server.ID))) return nil, fmt.Errorf("app: register mcp server %q: %w", strings.TrimSpace(server.ID), err) } @@ -68,7 +87,7 @@ func defaultRegisterMCPStdioServer(registry *mcp.Registry, cfg config.Config, se } workdir := resolveMCPServerWorkdir(cfg.Workdir, server.Stdio.Workdir) - client, err := newMCPStdioClient(mcp.StdioClientConfig{ + client, err := currentNewMCPStdioClient()(mcp.StdioClientConfig{ Command: strings.TrimSpace(server.Stdio.Command), Args: append([]string(nil), server.Stdio.Args...), Env: env, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d2d37542..021dece0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1224,214 +1224,3 @@ func TestAutoCompactConfigContextConfigValidate(t *testing.T) { t.Fatalf("expected error to contain 'auto_compact', got %v", err) } } - -// ---- selection.go 工具函数覆盖 ---- - -func TestDescriptorFromRawModel(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - raw map[string]any - want ModelDescriptor - wantOK bool - }{ - { - name: "empty map returns false", - raw: map[string]any{}, - wantOK: false, - }, - { - name: "id from model field", - raw: map[string]any{ - "model": "gpt-4.1", - }, - want: ModelDescriptor{ID: "gpt-4.1", Name: "gpt-4.1"}, - wantOK: true, - }, - { - name: "full descriptor", - raw: map[string]any{ - "id": "gpt-4.1", - "display_name": "GPT-4.1", - "description": "desc", - "context_window": 128000, - "max_output_tokens": 16384, - }, - want: ModelDescriptor{ - ID: "gpt-4.1", - Name: "GPT-4.1", - Description: "desc", - ContextWindow: 128000, - MaxOutputTokens: 16384, - }, - wantOK: true, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got, ok := DescriptorFromRawModel(tt.raw) - if ok != tt.wantOK { - t.Fatalf("expected ok=%v, got ok=%v", tt.wantOK, ok) - } - if !tt.wantOK { - return - } - if got.ID != tt.want.ID { - t.Fatalf("expected ID=%q, got %q", tt.want.ID, got.ID) - } - if got.Name != tt.want.Name { - t.Fatalf("expected Name=%q, got %q", tt.want.Name, got.Name) - } - if got.Description != tt.want.Description { - t.Fatalf("expected Description=%q, got %q", tt.want.Description, got.Description) - } - if got.ContextWindow != tt.want.ContextWindow { - t.Fatalf("expected ContextWindow=%d, got %d", tt.want.ContextWindow, got.ContextWindow) - } - if got.MaxOutputTokens != tt.want.MaxOutputTokens { - t.Fatalf("expected MaxOutputTokens=%d, got %d", tt.want.MaxOutputTokens, got.MaxOutputTokens) - } - }) - } -} - -func TestMergeModelDescriptors(t *testing.T) { - t.Parallel() - - a := []ModelDescriptor{{ID: "m1", Name: "Model1"}} - b := []ModelDescriptor{{ID: "m2", Name: "Model2"}, {ID: "m1", Description: "fallback"}} - - merged := MergeModelDescriptors(a, b) - if len(merged) != 2 { - t.Fatalf("expected 2 descriptors, got %d", len(merged)) - } - - // m1 from first source should keep its Name, get description from second - var m1 *ModelDescriptor - for i := range merged { - if merged[i].ID == "m1" { - m1 = &merged[i] - break - } - } - if m1 == nil { - t.Fatalf("expected m1 to be present") - } - if m1.Name != "Model1" { - t.Fatalf("expected Name=Model1 from first source, got %q", m1.Name) - } - if m1.Description != "fallback" { - t.Fatalf("expected Description=fallback from second source, got %q", m1.Description) - } -} - -func TestDescriptorsFromIDs(t *testing.T) { - t.Parallel() - - result := DescriptorsFromIDs([]string{"gpt-4.1", "gpt-4.1-mini"}) - if len(result) != 2 { - t.Fatalf("expected 2 descriptors, got %d", len(result)) - } - if result[0].ID != "gpt-4.1" { - t.Fatalf("expected first ID=gpt-4.1, got %q", result[0].ID) - } - if result[1].Name != "gpt-4.1-mini" { - t.Fatalf("expected second Name=gpt-4.1-mini, got %q", result[1].Name) - } -} - -func TestFirstNonEmptyString(t *testing.T) { - t.Parallel() - - if got := firstNonEmptyString("", " ", "hello", "world"); got != "hello" { - t.Fatalf("expected hello, got %q", got) - } - if got := firstNonEmptyString("", " "); got != "" { - t.Fatalf("expected empty, got %q", got) - } -} - -func TestFirstPositiveInt(t *testing.T) { - t.Parallel() - - if got := firstPositiveInt(0, -1, 42, 100); got != 42 { - t.Fatalf("expected 42, got %d", got) - } - if got := firstPositiveInt(int32(5)); got != 5 { - t.Fatalf("expected 5, got %d", got) - } - if got := firstPositiveInt(int64(10)); got != 10 { - t.Fatalf("expected 10, got %d", got) - } - if got := firstPositiveInt(float64(3.14)); got != 3 { - t.Fatalf("expected 3, got %d", got) - } - if got := firstPositiveInt(0, -5); got != 0 { - t.Fatalf("expected 0 when none positive, got %d", got) - } -} - -func TestBoolMapValue(t *testing.T) { - t.Parallel() - - result := boolMapValue(map[string]any{"a": true, "b": "notbool", "c": false}) - if len(result) != 2 { - t.Fatalf("expected 2 entries, got %d", len(result)) - } - if !result["a"] { - t.Fatalf("expected a=true") - } - if result["c"] { - t.Fatalf("expected c=false") - } - - if result := boolMapValue("not a map"); result != nil { - t.Fatalf("expected nil for non-map, got %v", result) - } -} - -func TestMergeStringBoolMaps(t *testing.T) { - t.Parallel() - - primary := map[string]bool{"a": true} - secondary := map[string]bool{"b": false, "a": false} - - result := mergeStringBoolMaps(primary, secondary) - if !result["a"] { - t.Fatalf("expected a=true (primary should win)") - } - if result["b"] { - t.Fatalf("expected b=false") - } - - if result := mergeStringBoolMaps(nil, nil); result != nil { - t.Fatalf("expected nil for both empty") - } -} - -func TestMergeModelDescriptorFallback(t *testing.T) { - t.Parallel() - - primary := ModelDescriptor{ID: "m1"} - secondary := ModelDescriptor{ - Name: "Fallback", - Description: "desc", - ContextWindow: 8000, - MaxOutputTokens: 4096, - } - - result := mergeModelDescriptor(primary, secondary) - if result.Name != "Fallback" { - t.Fatalf("expected Name=Fallback from secondary, got %q", result.Name) - } - if result.ContextWindow != 8000 { - t.Fatalf("expected ContextWindow=8000 from secondary, got %d", result.ContextWindow) - } - if result.MaxOutputTokens != 4096 { - t.Fatalf("expected MaxOutputTokens=4096 from secondary, got %d", result.MaxOutputTokens) - } -} diff --git a/internal/config/model.go b/internal/config/model.go index 9b8d80f1..4281b4b1 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -17,7 +17,7 @@ const ( DefaultWebFetchMaxResponseBytes int64 = 256 * 1024 DefaultCompactManualKeepRecentMessages = 10 DefaultCompactMaxSummaryChars = 1200 - DefaultAutoCompactInputTokenThreshold = 100000 + DefaultAutoCompactInputTokenThreshold = 100000 ) const ( diff --git a/internal/config/selection_helpers_test.go b/internal/config/selection_helpers_test.go new file mode 100644 index 00000000..cc07f36c --- /dev/null +++ b/internal/config/selection_helpers_test.go @@ -0,0 +1,211 @@ +package config + +import "testing" + +func TestDescriptorFromRawModel(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw map[string]any + want ModelDescriptor + wantOK bool + }{ + { + name: "empty map returns false", + raw: map[string]any{}, + wantOK: false, + }, + { + name: "id from model field", + raw: map[string]any{ + "model": "gpt-4.1", + }, + want: ModelDescriptor{ID: "gpt-4.1", Name: "gpt-4.1"}, + wantOK: true, + }, + { + name: "full descriptor", + raw: map[string]any{ + "id": "gpt-4.1", + "display_name": "GPT-4.1", + "description": "desc", + "context_window": 128000, + "max_output_tokens": 16384, + }, + want: ModelDescriptor{ + ID: "gpt-4.1", + Name: "GPT-4.1", + Description: "desc", + ContextWindow: 128000, + MaxOutputTokens: 16384, + }, + wantOK: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, ok := DescriptorFromRawModel(tt.raw) + if ok != tt.wantOK { + t.Fatalf("expected ok=%v, got ok=%v", tt.wantOK, ok) + } + if !tt.wantOK { + return + } + if got.ID != tt.want.ID { + t.Fatalf("expected ID=%q, got %q", tt.want.ID, got.ID) + } + if got.Name != tt.want.Name { + t.Fatalf("expected Name=%q, got %q", tt.want.Name, got.Name) + } + if got.Description != tt.want.Description { + t.Fatalf("expected Description=%q, got %q", tt.want.Description, got.Description) + } + if got.ContextWindow != tt.want.ContextWindow { + t.Fatalf("expected ContextWindow=%d, got %d", tt.want.ContextWindow, got.ContextWindow) + } + if got.MaxOutputTokens != tt.want.MaxOutputTokens { + t.Fatalf("expected MaxOutputTokens=%d, got %d", tt.want.MaxOutputTokens, got.MaxOutputTokens) + } + }) + } +} + +func TestMergeModelDescriptors(t *testing.T) { + t.Parallel() + + a := []ModelDescriptor{{ID: "m1", Name: "Model1"}} + b := []ModelDescriptor{{ID: "m2", Name: "Model2"}, {ID: "m1", Description: "fallback"}} + + merged := MergeModelDescriptors(a, b) + if len(merged) != 2 { + t.Fatalf("expected 2 descriptors, got %d", len(merged)) + } + + var m1 *ModelDescriptor + for i := range merged { + if merged[i].ID == "m1" { + m1 = &merged[i] + break + } + } + if m1 == nil { + t.Fatalf("expected m1 to be present") + } + if m1.Name != "Model1" { + t.Fatalf("expected Name=Model1 from first source, got %q", m1.Name) + } + if m1.Description != "fallback" { + t.Fatalf("expected Description=fallback from second source, got %q", m1.Description) + } +} + +func TestDescriptorsFromIDs(t *testing.T) { + t.Parallel() + + result := DescriptorsFromIDs([]string{"gpt-4.1", "gpt-4.1-mini"}) + if len(result) != 2 { + t.Fatalf("expected 2 descriptors, got %d", len(result)) + } + if result[0].ID != "gpt-4.1" { + t.Fatalf("expected first ID=gpt-4.1, got %q", result[0].ID) + } + if result[1].Name != "gpt-4.1-mini" { + t.Fatalf("expected second Name=gpt-4.1-mini, got %q", result[1].Name) + } +} + +func TestFirstNonEmptyString(t *testing.T) { + t.Parallel() + + if got := firstNonEmptyString("", " ", "hello", "world"); got != "hello" { + t.Fatalf("expected hello, got %q", got) + } + if got := firstNonEmptyString("", " "); got != "" { + t.Fatalf("expected empty, got %q", got) + } +} + +func TestFirstPositiveInt(t *testing.T) { + t.Parallel() + + if got := firstPositiveInt(0, -1, 42, 100); got != 42 { + t.Fatalf("expected 42, got %d", got) + } + if got := firstPositiveInt(int32(5)); got != 5 { + t.Fatalf("expected 5, got %d", got) + } + if got := firstPositiveInt(int64(10)); got != 10 { + t.Fatalf("expected 10, got %d", got) + } + if got := firstPositiveInt(float64(3.14)); got != 3 { + t.Fatalf("expected 3, got %d", got) + } + if got := firstPositiveInt(0, -5); got != 0 { + t.Fatalf("expected 0 when none positive, got %d", got) + } +} + +func TestBoolMapValue(t *testing.T) { + t.Parallel() + + result := boolMapValue(map[string]any{"a": true, "b": "notbool", "c": false}) + if len(result) != 2 { + t.Fatalf("expected 2 entries, got %d", len(result)) + } + if !result["a"] { + t.Fatalf("expected a=true") + } + if result["c"] { + t.Fatalf("expected c=false") + } + + if result := boolMapValue("not a map"); result != nil { + t.Fatalf("expected nil for non-map, got %v", result) + } +} + +func TestMergeStringBoolMaps(t *testing.T) { + t.Parallel() + + primary := map[string]bool{"a": true} + secondary := map[string]bool{"b": false, "a": false} + + result := mergeStringBoolMaps(primary, secondary) + if !result["a"] { + t.Fatalf("expected a=true (primary should win)") + } + if result["b"] { + t.Fatalf("expected b=false") + } + + if result := mergeStringBoolMaps(nil, nil); result != nil { + t.Fatalf("expected nil for both empty") + } +} + +func TestMergeModelDescriptorFallback(t *testing.T) { + t.Parallel() + + primary := ModelDescriptor{ID: "m1"} + secondary := ModelDescriptor{ + Name: "Fallback", + Description: "desc", + ContextWindow: 8000, + MaxOutputTokens: 4096, + } + + result := mergeModelDescriptor(primary, secondary) + if result.Name != "Fallback" { + t.Fatalf("expected Name=Fallback from secondary, got %q", result.Name) + } + if result.ContextWindow != 8000 { + t.Fatalf("expected ContextWindow=8000 from secondary, got %d", result.ContextWindow) + } + if result.MaxOutputTokens != 4096 { + t.Fatalf("expected MaxOutputTokens=4096 from secondary, got %d", result.MaxOutputTokens) + } +} diff --git a/internal/gateway/contracts.go b/internal/gateway/contracts.go index 88eba546..81d4c905 100644 --- a/internal/gateway/contracts.go +++ b/internal/gateway/contracts.go @@ -161,8 +161,8 @@ 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) + // UpdateSessionWorkdir 更新会话工作目录。 + UpdateSessionWorkdir(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..f761b597 100644 --- a/internal/gateway/contracts_test.go +++ b/internal/gateway/contracts_test.go @@ -33,7 +33,7 @@ func (s *runtimePortCompileStub) LoadSession(_ context.Context, _ string) (Sessi return Session{}, nil } -func (s *runtimePortCompileStub) SetSessionWorkdir(_ context.Context, _, _ string) (Session, error) { +func (s *runtimePortCompileStub) UpdateSessionWorkdir(_ context.Context, _, _ string) (Session, error) { return Session{}, nil } diff --git a/internal/runtime/compact.go b/internal/runtime/compact.go index 3a9e426d..8cfc90b8 100644 --- a/internal/runtime/compact.go +++ b/internal/runtime/compact.go @@ -111,7 +111,7 @@ func (s *Service) runCompactForSession( result, err := runner.Run(ctx, contextcompact.Input{ Mode: mode, SessionID: session.ID, - Workdir: effectiveSessionWorkdir(session.Workdir, cfg.Workdir), + Workdir: effectiveSessionWorkdir(session.Workdir, s.workdir), Messages: session.Messages, Config: cfg.Context.Compact, }) diff --git a/internal/runtime/permission.go b/internal/runtime/permission.go index 00a0fb76..f7ec3101 100644 --- a/internal/runtime/permission.go +++ b/internal/runtime/permission.go @@ -95,11 +95,12 @@ func (s *Service) ResolvePermission(ctx context.Context, input PermissionResolut // executeToolCallWithPermission 执行工具调用并处理 ask 决策的显式审批闭环。 func (s *Service) executeToolCallWithPermission(ctx context.Context, input permissionExecutionInput) (tools.ToolResult, error) { callInput := tools.ToolCallInput{ - ID: input.Call.ID, - Name: input.Call.Name, - Arguments: []byte(input.Call.Arguments), - Workdir: input.Workdir, - SessionID: input.SessionID, + ID: input.Call.ID, + Name: input.Call.Name, + Arguments: []byte(input.Call.Arguments), + WorkspaceRoot: s.workspaceRoot, + Workdir: input.Workdir, + SessionID: input.SessionID, EmitChunk: func(chunk []byte) error { if err := ctx.Err(); err != nil { return err diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 4e5a79d2..1a423f50 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -118,7 +118,7 @@ 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) + UpdateSessionWorkdir(ctx context.Context, sessionID string, requestedPath string) (agentsession.Session, error) } type UserInput struct { @@ -147,14 +147,42 @@ type Service struct { activeRunCancel context.CancelFunc // 当前活跃 Run 的取消函数。 sessionInputTokens int // 当前会话累计输入 token。 sessionOutputTokens int // 当前会话累计输出 token。 + workspaceRoot string // 当前 runtime 绑定的工作区根目录。 + workdir string // 当前 runtime 默认工作目录。 } +// NewWithFactory 保留测试和兼容场景使用,工作区根目录默认退化为当前配置 workdir。 func NewWithFactory( configManager *config.Manager, toolManager tools.Manager, sessionStore agentsession.Store, providerFactory ProviderFactory, contextBuilder agentcontext.Builder, +) *Service { + workdir := "" + if configManager != nil { + workdir = strings.TrimSpace(configManager.Get().Workdir) + } + return NewWithWorkspace( + configManager, + workdir, + workdir, + toolManager, + sessionStore, + providerFactory, + contextBuilder, + ) +} + +// NewWithWorkspace 使用显式 workspaceRoot/workdir 构建绑定到单工作区的 runtime。 +func NewWithWorkspace( + configManager *config.Manager, + workspaceRoot string, + workdir string, + toolManager tools.Manager, + sessionStore agentsession.Store, + providerFactory ProviderFactory, + contextBuilder agentcontext.Builder, ) *Service { if providerFactory == nil { providerFactory = provider.NewRegistry() @@ -173,6 +201,8 @@ func NewWithFactory( providerFactory: providerFactory, contextBuilder: contextBuilder, events: make(chan RuntimeEvent, 128), + workspaceRoot: normalizeWorkspaceRoot(workspaceRoot), + workdir: strings.TrimSpace(workdir), } } @@ -192,8 +222,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { return errors.New("runtime: input content is empty") } - initialCfg := s.configManager.Get() - session, err := s.loadOrCreateSession(ctx, input.SessionID, input.Content, initialCfg.Workdir, input.Workdir) + session, err := s.loadOrCreateSession(ctx, input.SessionID, input.Content, input.Workdir) if err != nil { return s.handleRunError(ctx, input.RunID, input.SessionID, err) } @@ -219,7 +248,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { } cfg := s.configManager.Get() - activeWorkdir := effectiveSessionWorkdir(session.Workdir, cfg.Workdir) + activeWorkdir := effectiveSessionWorkdir(session.Workdir, s.workdir) maxLoops := cfg.MaxLoops if maxLoops <= 0 { maxLoops = 8 @@ -422,7 +451,8 @@ 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) { +// UpdateSessionWorkdir 在当前 runtime 绑定的 workspace 内更新会话工作目录。 +func (s *Service) UpdateSessionWorkdir(ctx context.Context, sessionID string, requestedPath string) (agentsession.Session, error) { sessionID = strings.TrimSpace(sessionID) if sessionID == "" { return agentsession.Session{}, errors.New("runtime: session id is empty") @@ -433,8 +463,7 @@ func (s *Service) SetSessionWorkdir(ctx context.Context, sessionID string, workd return agentsession.Session{}, err } - cfg := s.configManager.Get() - resolved, err := resolveWorkdirForSession(cfg.Workdir, session.Workdir, workdir) + resolved, err := s.resolveSessionWorkdir(session.Workdir, requestedPath) if err != nil { return agentsession.Session{}, err } @@ -454,11 +483,10 @@ func (s *Service) loadOrCreateSession( ctx context.Context, sessionID string, title string, - defaultWorkdir string, requestedWorkdir string, ) (agentsession.Session, error) { if strings.TrimSpace(sessionID) == "" { - sessionWorkdir, err := resolveWorkdirForSession(defaultWorkdir, "", requestedWorkdir) + sessionWorkdir, err := s.resolveSessionWorkdir("", requestedWorkdir) if err != nil { return agentsession.Session{}, err } @@ -476,7 +504,7 @@ func (s *Service) loadOrCreateSession( return session, nil } - resolved, err := resolveWorkdirForSession(defaultWorkdir, session.Workdir, requestedWorkdir) + resolved, err := s.resolveSessionWorkdir(session.Workdir, requestedWorkdir) if err != nil { return agentsession.Session{}, err } @@ -870,37 +898,96 @@ func effectiveSessionWorkdir(sessionWorkdir string, defaultWorkdir string) strin return strings.TrimSpace(defaultWorkdir) } -func resolveWorkdirForSession(defaultWorkdir string, currentWorkdir string, requestedWorkdir string) (string, error) { - base := effectiveSessionWorkdir(currentWorkdir, defaultWorkdir) - if strings.TrimSpace(requestedWorkdir) == "" { - return normalizeExistingWorkdir(base) +// resolveSessionWorkdir 负责在当前 runtime 绑定的 workspace 内解析会话工作目录。 +func (s *Service) resolveSessionWorkdir(currentWorkdir string, requestedWorkdir string) (string, error) { + base := effectiveSessionWorkdir(currentWorkdir, s.workdir) + resolved, err := normalizeRequestedWorkdir(base, requestedWorkdir) + if err != nil { + return "", fmt.Errorf("runtime: resolve workdir: %w", err) + } + if strings.TrimSpace(s.workspaceRoot) != "" && !isWithinWorkspaceRoot(s.workspaceRoot, resolved) { + return "", fmt.Errorf( + "runtime: requested workdir %q escapes workspace root %q", + resolved, + s.workspaceRoot, + ) + } + return resolved, nil +} + +func normalizeRequestedWorkdir(base string, requestedWorkdir string) (string, error) { + base = strings.TrimSpace(base) + if base == "" { + return "", errors.New("workdir is empty") } target := strings.TrimSpace(requestedWorkdir) - if !filepath.IsAbs(target) { + if target == "" { + target = base + } else if !filepath.IsAbs(target) { target = filepath.Join(base, target) } - return normalizeExistingWorkdir(target) + + absolute, err := filepath.Abs(target) + if err != nil { + return "", err + } + info, err := os.Stat(absolute) + if err != nil { + return "", err + } + if !info.IsDir() { + return "", fmt.Errorf("workdir %q is not a directory", absolute) + } + return filepath.Clean(absolute), nil +} + +func isWithinWorkspaceRoot(workspaceRoot string, target string) bool { + root := strings.TrimSpace(workspaceRoot) + if root == "" { + return false + } + target, err := canonicalWorkspacePath(target) + if err != nil { + return false + } + relative, err := filepath.Rel(root, target) + if err != nil { + return false + } + return relative == "." || (relative != ".." && !strings.HasPrefix(relative, ".."+string(os.PathSeparator))) } -func normalizeExistingWorkdir(workdir string) (string, error) { - trimmed := strings.TrimSpace(workdir) +// normalizeWorkspaceRoot 在 runtime 构建时尽量把工作区根目录规整为稳定可比较的绝对路径。 +func normalizeWorkspaceRoot(workspaceRoot string) string { + trimmed := strings.TrimSpace(workspaceRoot) if trimmed == "" { - return "", errors.New("runtime: workdir is empty") + return "" + } + canonical, err := canonicalWorkspacePath(trimmed) + if err == nil { + return canonical + } + absolute, absErr := filepath.Abs(trimmed) + if absErr == nil { + return filepath.Clean(absolute) } + return filepath.Clean(trimmed) +} +// canonicalWorkspacePath 返回用于工作区边界比较的规范绝对路径,并解析已存在路径上的符号链接。 +func canonicalWorkspacePath(path string) (string, error) { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return "", errors.New("path is empty") + } absolute, err := filepath.Abs(trimmed) if err != nil { - return "", fmt.Errorf("runtime: resolve workdir: %w", err) + return "", err } - - info, err := os.Stat(absolute) + resolved, err := filepath.EvalSymlinks(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 "", err } - - return filepath.Clean(absolute), nil + return filepath.Clean(resolved), nil } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 0868ccdb..e7a1fdba 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -2427,7 +2427,10 @@ func TestServiceRunUsesSessionWorkdirForContextAndTools(t *testing.T) { func TestServiceRunUsesInputWorkdirForNewSession(t *testing.T) { manager := newRuntimeConfigManager(t) defaultWorkdir := t.TempDir() - draftRoot := t.TempDir() + draftRoot := filepath.Join(defaultWorkdir, "draft") + if err := os.MkdirAll(draftRoot, 0o755); err != nil { + t.Fatalf("mkdir draft root: %v", err) + } if err := manager.Update(context.Background(), func(cfg *config.Config) error { cfg.Workdir = defaultWorkdir return nil @@ -2463,7 +2466,7 @@ func TestServiceRunUsesInputWorkdirForNewSession(t *testing.T) { } } -func TestServiceSetSessionWorkdir(t *testing.T) { +func TestServiceUpdateSessionWorkdir(t *testing.T) { manager := newRuntimeConfigManager(t) defaultWorkdir := t.TempDir() target := filepath.Join(defaultWorkdir, "sub") @@ -2484,9 +2487,9 @@ func TestServiceSetSessionWorkdir(t *testing.T) { 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") + updated, err := service.UpdateSessionWorkdir(context.Background(), session.ID, "sub") if err != nil { - t.Fatalf("SetSessionWorkdir() error = %v", err) + t.Fatalf("UpdateSessionWorkdir() error = %v", err) } if updated.Workdir != target { t.Fatalf("expected updated workdir %q, got %q", target, updated.Workdir) @@ -2509,7 +2512,7 @@ func TestServiceSetSessionWorkdir(t *testing.T) { t.Fatalf("expected session workdir to persist across service lifetime, got %q", reloaded.Workdir) } - _, err = service.SetSessionWorkdir(context.Background(), "", "sub") + _, err = service.UpdateSessionWorkdir(context.Background(), "", "sub") if err == nil || !containsError(err, "session id is empty") { t.Fatalf("expected empty session id error, got %v", err) } @@ -2718,23 +2721,25 @@ func TestWorkdirHelperFunctions(t *testing.T) { } absoluteTarget := t.TempDir() - got, err := resolveWorkdirForSession(defaultDir, "", "") + service := &Service{workdir: defaultDir} + got, err := service.resolveSessionWorkdir("", "") if err != nil || got != filepath.Clean(defaultDir) { t.Fatalf("expected default dir %q, got %q / %v", filepath.Clean(defaultDir), got, err) } - got, err = resolveWorkdirForSession(defaultDir, currentDir, "nested") + got, err = service.resolveSessionWorkdir(currentDir, "nested") if err != nil || got != filepath.Clean(relativeTarget) { t.Fatalf("expected relative target %q, got %q / %v", filepath.Clean(relativeTarget), got, err) } - got, err = resolveWorkdirForSession(defaultDir, currentDir, absoluteTarget) + got, err = service.resolveSessionWorkdir(currentDir, absoluteTarget) if err != nil || got != filepath.Clean(absoluteTarget) { t.Fatalf("expected absolute target %q, got %q / %v", filepath.Clean(absoluteTarget), got, err) } - _, err = resolveWorkdirForSession("", "", "") - if err == nil || !containsError(err, "workdir is empty") { + service = &Service{} + _, err = service.resolveSessionWorkdir("", "") + if err == nil || !containsError(err, "resolve workdir") { t.Fatalf("expected empty workdir error, got %v", err) } @@ -2742,14 +2747,15 @@ func TestWorkdirHelperFunctions(t *testing.T) { if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil { t.Fatalf("write file: %v", err) } - _, err = normalizeExistingWorkdir(filePath) + service = &Service{workdir: defaultDir} + _, err = service.resolveSessionWorkdir("", filePath) if err == nil || !containsError(err, "is not a directory") { t.Fatalf("expected non-directory error, got %v", err) } }) } -func TestServiceSetSessionWorkdirNoopDoesNotSave(t *testing.T) { +func TestServiceUpdateSessionWorkdirNoopDoesNotSave(t *testing.T) { manager := newRuntimeConfigManager(t) defaultWorkdir := t.TempDir() if err := manager.Update(context.Background(), func(cfg *config.Config) error { @@ -2760,7 +2766,10 @@ func TestServiceSetSessionWorkdirNoopDoesNotSave(t *testing.T) { } store := newMemoryStore() - target := t.TempDir() + target := filepath.Join(defaultWorkdir, "target") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("mkdir target: %v", err) + } session := agentsession.NewWithWorkdir("noop", target) store.sessions[session.ID] = cloneSession(session) registry := tools.NewRegistry() @@ -2768,9 +2777,9 @@ func TestServiceSetSessionWorkdirNoopDoesNotSave(t *testing.T) { service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) beforeSaves := store.saves - updated, err := service.SetSessionWorkdir(context.Background(), session.ID, target) + updated, err := service.UpdateSessionWorkdir(context.Background(), session.ID, target) if err != nil { - t.Fatalf("SetSessionWorkdir() error = %v", err) + t.Fatalf("UpdateSessionWorkdir() error = %v", err) } if updated.Workdir != target { t.Fatalf("expected unchanged workdir %q, got %q", target, updated.Workdir) @@ -2919,20 +2928,20 @@ func TestLoadSessionReturnsStoreError(t *testing.T) { } } -func TestSetSessionWorkdirReturnsStoreError(t *testing.T) { +func TestUpdateSessionWorkdirReturnsStoreError(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()) + _, err := service.UpdateSessionWorkdir(context.Background(), "missing", t.TempDir()) if err == nil || !containsError(err, "not found") { - t.Fatalf("expected load error from SetSessionWorkdir, got %v", err) + t.Fatalf("expected load error from UpdateSessionWorkdir, got %v", err) } } -func TestSetSessionWorkdirReturnsResolveError(t *testing.T) { +func TestUpdateSessionWorkdirReturnsResolveError(t *testing.T) { t.Parallel() manager := newRuntimeConfigManager(t) @@ -2950,7 +2959,7 @@ func TestSetSessionWorkdirReturnsResolveError(t *testing.T) { 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")) + _, err := service.UpdateSessionWorkdir(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) } diff --git a/internal/runtime/workdir_branch_test.go b/internal/runtime/workdir_branch_test.go index 089a3cd0..1efd30e6 100644 --- a/internal/runtime/workdir_branch_test.go +++ b/internal/runtime/workdir_branch_test.go @@ -20,28 +20,31 @@ func TestResolveWorkdirForSessionAndNormalizeErrors(t *testing.T) { t.Fatalf("mkdir relative dir: %v", err) } absoluteDir := t.TempDir() + service := &Service{workdir: base} - got, err := resolveWorkdirForSession(base, "", "") + got, err := service.resolveSessionWorkdir("", "") if err != nil || got != filepath.Clean(base) { t.Fatalf("expected base workdir %q, got %q / %v", filepath.Clean(base), got, err) } - got, err = resolveWorkdirForSession(base, current, "child") + got, err = service.resolveSessionWorkdir(current, "child") if err != nil || got != filepath.Clean(relativeDir) { t.Fatalf("expected relative workdir %q, got %q / %v", filepath.Clean(relativeDir), got, err) } - got, err = resolveWorkdirForSession(base, current, absoluteDir) + got, err = service.resolveSessionWorkdir(current, absoluteDir) if err != nil || got != filepath.Clean(absoluteDir) { t.Fatalf("expected absolute workdir %q, got %q / %v", filepath.Clean(absoluteDir), got, err) } - _, err = resolveWorkdirForSession("", "", "") - if err == nil || !strings.Contains(err.Error(), "workdir is empty") { + service = &Service{} + _, err = service.resolveSessionWorkdir("", "") + if err == nil || !strings.Contains(err.Error(), "resolve workdir") { t.Fatalf("expected empty workdir error, got %v", err) } - _, err = normalizeExistingWorkdir(filepath.Join(base, "missing")) + service = &Service{workdir: base} + _, err = service.resolveSessionWorkdir("", filepath.Join(base, "missing")) if err == nil || !strings.Contains(err.Error(), "resolve workdir") { t.Fatalf("expected missing path error, got %v", err) } @@ -50,7 +53,7 @@ func TestResolveWorkdirForSessionAndNormalizeErrors(t *testing.T) { if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil { t.Fatalf("write file: %v", err) } - _, err = normalizeExistingWorkdir(filePath) + _, err = service.resolveSessionWorkdir("", filePath) if err == nil || !strings.Contains(err.Error(), "is not a directory") { t.Fatalf("expected non-directory error, got %v", err) } @@ -74,3 +77,47 @@ func TestLoadSessionUsesFallbackWorkdirWhenMemoryMissing(t *testing.T) { t.Fatalf("expected fallback to persisted workdir %q, got %q", session.Workdir, loaded.Workdir) } } + +func TestNewWithFactoryDefaultsWorkspaceRootToWorkdir(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + service := NewWithFactory(manager, nil, newMemoryStore(), &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + want := strings.TrimSpace(manager.Get().Workdir) + if service.workspaceRoot != want { + t.Fatalf("expected workspace root %q, got %q", want, service.workspaceRoot) + } +} + +func TestResolveSessionWorkdirRejectsSymlinkEscape(t *testing.T) { + t.Parallel() + + workspaceRoot := t.TempDir() + outsideRoot := t.TempDir() + linkPath := filepath.Join(workspaceRoot, "link") + if err := os.Symlink(outsideRoot, linkPath); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + service := &Service{ + workspaceRoot: workspaceRoot, + workdir: workspaceRoot, + } + if _, err := service.resolveSessionWorkdir("", "link"); err == nil || !strings.Contains(err.Error(), "escapes workspace root") { + t.Fatalf("expected symlink escape error, got %v", err) + } +} + +func TestNormalizeWorkspaceRootFallsBackToAbsolutePathForMissingDirectory(t *testing.T) { + t.Parallel() + + missing := filepath.Join(t.TempDir(), "missing", "workspace") + got := normalizeWorkspaceRoot(missing) + want, err := filepath.Abs(missing) + if err != nil { + t.Fatalf("Abs() error = %v", err) + } + if got != filepath.Clean(want) { + t.Fatalf("expected normalized workspace root %q, got %q", filepath.Clean(want), got) + } +} diff --git a/internal/tools/bash/executor.go b/internal/tools/bash/executor.go index e8dc7247..d8058727 100644 --- a/internal/tools/bash/executor.go +++ b/internal/tools/bash/executor.go @@ -65,16 +65,22 @@ func (e *defaultSecurityExecutor) Execute( return tools.NewErrorResult("bash", tools.NormalizeErrorReason("bash", err), "", nil), err } - base := strings.TrimSpace(call.Workdir) - if base == "" { - base = e.root + root := strings.TrimSpace(call.WorkspaceRoot) + if root == "" { + root = e.root + } + currentWorkdir := strings.TrimSpace(call.Workdir) + if currentWorkdir == "" { + currentWorkdir = root } _, workdir, err := tools.ResolveWorkspaceTarget( call, security.TargetTypeDirectory, - base, + root, requestedWorkdir, - resolveWorkdir, + func(root string, requested string) (string, error) { + return resolveWorkdir(root, currentWorkdir, requested) + }, ) if err != nil { return tools.NewErrorResult("bash", tools.NormalizeErrorReason("bash", err), "", nil), err @@ -124,7 +130,7 @@ func shellCommand(shell string, command string) (string, []string) { return "sh", []string{"-lc", command} } -func resolveWorkdir(root string, requested string) (string, error) { +func resolveWorkdir(root string, current string, requested string) (string, error) { if strings.ContainsRune(root, '\x00') || strings.ContainsRune(requested, '\x00') { return "", errors.New("bash: invalid path contains NUL") } @@ -132,9 +138,15 @@ func resolveWorkdir(root string, requested string) (string, error) { if err != nil { return "", err } - target := requested - if strings.TrimSpace(target) == "" { + target := strings.TrimSpace(current) + if target == "" { target = base + } + if strings.TrimSpace(requested) != "" { + target = requested + if !filepath.IsAbs(target) { + target = filepath.Join(base, target) + } } else if !filepath.IsAbs(target) { target = filepath.Join(base, target) } diff --git a/internal/tools/bash/executor_test.go b/internal/tools/bash/executor_test.go index 45071e3a..6018eb81 100644 --- a/internal/tools/bash/executor_test.go +++ b/internal/tools/bash/executor_test.go @@ -185,7 +185,7 @@ func TestResolveWorkdir(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - target, err := resolveWorkdir(tt.root, tt.requested) + target, err := resolveWorkdir(tt.root, tt.root, tt.requested) if tt.expectErr != "" { if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tt.expectErr)) { t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) diff --git a/internal/tools/bash/helpers_test.go b/internal/tools/bash/helpers_test.go index 89b8be75..d2a2e4c9 100644 --- a/internal/tools/bash/helpers_test.go +++ b/internal/tools/bash/helpers_test.go @@ -124,7 +124,7 @@ func TestResolveWorkdirVariants(t *testing.T) { for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { - got, err := resolveWorkdir(root, tt.requested) + got, err := resolveWorkdir(root, root, tt.requested) if tt.expectErr != "" { if err == nil || !strings.Contains(err.Error(), tt.expectErr) { t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) diff --git a/internal/tools/filesystem/edit.go b/internal/tools/filesystem/edit.go index 631e1082..bdbf65f9 100644 --- a/internal/tools/filesystem/edit.go +++ b/internal/tools/filesystem/edit.go @@ -77,7 +77,7 @@ func (t *EditTool) Execute(ctx context.Context, input tools.ToolCallInput) (tool return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err } - root := effectiveRoot(t.root, input.Workdir) + root := effectiveRoot(t.root, input.WorkspaceRoot) root, target, err := tools.ResolveWorkspaceTarget( input, security.TargetTypePath, diff --git a/internal/tools/filesystem/glob.go b/internal/tools/filesystem/glob.go index d2858f3d..71da2300 100644 --- a/internal/tools/filesystem/glob.go +++ b/internal/tools/filesystem/glob.go @@ -72,7 +72,7 @@ func (t *GlobTool) Execute(ctx context.Context, input tools.ToolCallInput) (tool return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err } - root := effectiveRoot(t.root, input.Workdir) + root := effectiveRoot(t.root, input.WorkspaceRoot) searchRoot, err := resolveSearchDir(root, args.Dir) if err != nil { return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err diff --git a/internal/tools/filesystem/grep.go b/internal/tools/filesystem/grep.go index 5ef5c8fc..20383f87 100644 --- a/internal/tools/filesystem/grep.go +++ b/internal/tools/filesystem/grep.go @@ -80,7 +80,7 @@ func (t *GrepTool) Execute(ctx context.Context, input tools.ToolCallInput) (tool return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err } - root := effectiveRoot(t.root, input.Workdir) + root := effectiveRoot(t.root, input.WorkspaceRoot) searchRoot, err := resolveSearchDir(root, args.Dir) if err != nil { return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err diff --git a/internal/tools/filesystem/helpers.go b/internal/tools/filesystem/helpers.go index 929d74e1..49789989 100644 --- a/internal/tools/filesystem/helpers.go +++ b/internal/tools/filesystem/helpers.go @@ -16,8 +16,8 @@ const ( editToolName = tools.ToolNameFilesystemEdit ) -func effectiveRoot(defaultRoot string, workdir string) string { - base := strings.TrimSpace(workdir) +func effectiveRoot(defaultRoot string, workspaceRoot string) string { + base := strings.TrimSpace(workspaceRoot) if base == "" { base = defaultRoot } diff --git a/internal/tools/filesystem/read_file.go b/internal/tools/filesystem/read_file.go index f99de8ef..4932f284 100644 --- a/internal/tools/filesystem/read_file.go +++ b/internal/tools/filesystem/read_file.go @@ -65,7 +65,7 @@ func (t *ReadFileTool) Execute(ctx context.Context, input tools.ToolCallInput) ( return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err } - base := effectiveRoot(t.root, input.Workdir) + base := effectiveRoot(t.root, input.WorkspaceRoot) base, target, err := tools.ResolveWorkspaceTarget( input, diff --git a/internal/tools/filesystem/write_file.go b/internal/tools/filesystem/write_file.go index f68c6548..b86e1c78 100644 --- a/internal/tools/filesystem/write_file.go +++ b/internal/tools/filesystem/write_file.go @@ -68,7 +68,7 @@ func (t *WriteFileTool) Execute(ctx context.Context, input tools.ToolCallInput) return tools.NewErrorResult(t.Name(), tools.NormalizeErrorReason(t.Name(), err), "", nil), err } - base := effectiveRoot(t.root, input.Workdir) + base := effectiveRoot(t.root, input.WorkspaceRoot) _, target, err := tools.ResolveWorkspaceTarget( input, diff --git a/internal/tools/manager_test.go b/internal/tools/manager_test.go index 45e7de66..a2dca67b 100644 --- a/internal/tools/manager_test.go +++ b/internal/tools/manager_test.go @@ -994,6 +994,57 @@ func TestBuildPermissionAction(t *testing.T) { } } +func TestBuildPermissionActionUsesWorkspaceBindingForBash(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input ToolCallInput + wantWorkdir string + wantSandbox string + }{ + { + name: "prefers workspace root for session scope", + input: ToolCallInput{ + Name: "bash", + Arguments: []byte(`{"command":"pwd"}`), + Workdir: "/repo/app", + WorkspaceRoot: "/repo", + }, + wantWorkdir: "/repo", + wantSandbox: "/repo/app", + }, + { + name: "falls back to workdir when workspace root missing", + input: ToolCallInput{ + Name: "bash", + Arguments: []byte(`{"command":"pwd"}`), + Workdir: "/repo/app", + }, + wantWorkdir: "/repo/app", + wantSandbox: "/repo/app", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + action, err := buildPermissionAction(tt.input) + if err != nil { + t.Fatalf("buildPermissionAction() error = %v", err) + } + if action.Payload.Workdir != tt.wantWorkdir { + t.Fatalf("expected workdir %q, got %q", tt.wantWorkdir, action.Payload.Workdir) + } + if action.Payload.SandboxTarget != tt.wantSandbox { + t.Fatalf("expected sandbox target %q, got %q", tt.wantSandbox, action.Payload.SandboxTarget) + } + }) + } +} + func TestPermissionMapperHelpers(t *testing.T) { t.Parallel() diff --git a/internal/tools/permission_mapper.go b/internal/tools/permission_mapper.go index 946d1084..7a53c322 100644 --- a/internal/tools/permission_mapper.go +++ b/internal/tools/permission_mapper.go @@ -20,7 +20,7 @@ func buildPermissionAction(input ToolCallInput) (security.Action, error) { Resource: toolName, Operation: toolName, SessionID: input.SessionID, - Workdir: input.Workdir, + Workdir: workspaceRootForInput(input), }, } @@ -32,6 +32,9 @@ func buildPermissionAction(input ToolCallInput) (security.Action, error) { action.Payload.Target = extractStringArgument(input.Arguments, "command") action.Payload.SandboxTargetType = security.TargetTypeDirectory action.Payload.SandboxTarget = extractStringArgument(input.Arguments, "workdir") + if strings.TrimSpace(action.Payload.SandboxTarget) == "" { + action.Payload.SandboxTarget = input.Workdir + } case "filesystem_read_file": action.Type = security.ActionTypeRead action.Payload.Operation = "read_file" @@ -87,6 +90,14 @@ func buildPermissionAction(input ToolCallInput) (security.Action, error) { return action, nil } +func workspaceRootForInput(input ToolCallInput) string { + root := strings.TrimSpace(input.WorkspaceRoot) + if root != "" { + return root + } + return strings.TrimSpace(input.Workdir) +} + func mcpServerTarget(toolName string) string { parts := strings.Split(strings.TrimSpace(toolName), ".") if len(parts) < 2 { diff --git a/internal/tools/types.go b/internal/tools/types.go index 462ec8d9..6e30f286 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -30,6 +30,7 @@ type ToolCallInput struct { Name string Arguments []byte SessionID string + WorkspaceRoot string Workdir string WorkspacePlan *security.WorkspaceExecutionPlan // EmitChunk 为流式分片回调,语义见 ChunkEmitter 注释。 diff --git a/internal/tui/bootstrap/builder.go b/internal/tui/bootstrap/builder.go index 739b6970..225b5e5f 100644 --- a/internal/tui/bootstrap/builder.go +++ b/internal/tui/bootstrap/builder.go @@ -3,6 +3,7 @@ package bootstrap import ( "context" "fmt" + "strings" "neo-code/internal/config" agentruntime "neo-code/internal/runtime" @@ -17,23 +18,42 @@ type ProviderService interface { SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) } -// Options 定义 bootstrap 装配输入。 -type Options struct { - Config *config.Config +// WorkspaceBinding 描述一次工作区重建后需要回绑到 TUI 的依赖快照。 +type WorkspaceBinding struct { + Config config.Config ConfigManager *config.Manager Runtime agentruntime.Runtime ProviderService ProviderService - Mode Mode - Factory ServiceFactory + WorkspaceRoot string + Workdir string +} + +// RebuildWorkspaceFunc 定义跨工作区切换时的 bundle 重建入口。 +type RebuildWorkspaceFunc func(ctx context.Context, requestedPath string) (WorkspaceBinding, error) + +// Options 定义 bootstrap 装配输入。 +type Options struct { + Config *config.Config + ConfigManager *config.Manager + Runtime agentruntime.Runtime + ProviderService ProviderService + WorkspaceRoot string + Workdir string + RebuildWorkspace RebuildWorkspaceFunc + Mode Mode + Factory ServiceFactory } // Container 表示完成装配后供 TUI Core 使用的依赖集合。 type Container struct { - Config config.Config - ConfigManager *config.Manager - Runtime agentruntime.Runtime - ProviderService ProviderService - Mode Mode + Config config.Config + ConfigManager *config.Manager + Runtime agentruntime.Runtime + ProviderService ProviderService + WorkspaceRoot string + Workdir string + RebuildWorkspace RebuildWorkspaceFunc + Mode Mode } // Build 执行 TUI bootstrap 装配,并返回可注入到 App/Core 的容器。 @@ -50,6 +70,14 @@ func Build(options Options) (Container, error) { mode := NormalizeMode(options.Mode) cfg := resolveConfigSnapshot(options.Config, options.ConfigManager) + workspaceRoot := strings.TrimSpace(options.WorkspaceRoot) + if workspaceRoot == "" { + workspaceRoot = strings.TrimSpace(cfg.Workdir) + } + workdir := strings.TrimSpace(options.Workdir) + if workdir == "" { + workdir = strings.TrimSpace(cfg.Workdir) + } factory := options.Factory if factory == nil { @@ -73,11 +101,14 @@ func Build(options Options) (Container, error) { } return Container{ - Config: cfg, - ConfigManager: options.ConfigManager, - Runtime: runtimeSvc, - ProviderService: providerSvc, - Mode: mode, + Config: cfg, + ConfigManager: options.ConfigManager, + Runtime: runtimeSvc, + ProviderService: providerSvc, + WorkspaceRoot: workspaceRoot, + Workdir: workdir, + RebuildWorkspace: options.RebuildWorkspace, + Mode: mode, }, nil } diff --git a/internal/tui/bootstrap/builder_test.go b/internal/tui/bootstrap/builder_test.go index 71a9cf06..ec942982 100644 --- a/internal/tui/bootstrap/builder_test.go +++ b/internal/tui/bootstrap/builder_test.go @@ -41,7 +41,7 @@ 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) { +func (r *testRuntime) UpdateSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { return agentsession.Session{}, nil } @@ -84,6 +84,43 @@ func TestBuild(t *testing.T) { if container.ConfigManager != manager { t.Error("expected ConfigManager to be set") } + if container.WorkspaceRoot != container.Config.Workdir { + t.Fatalf("expected workspace root to default to config workdir %q, got %q", container.Config.Workdir, container.WorkspaceRoot) + } + if container.Workdir != container.Config.Workdir { + t.Fatalf("expected workdir to default to config workdir %q, got %q", container.Config.Workdir, container.Workdir) + } + }) + + t.Run("preserves explicit workspace binding", func(t *testing.T) { + manager := &config.Manager{} + runtime := &testRuntime{} + providerSvc := &testProviderService{} + rebuild := func(ctx context.Context, requestedPath string) (WorkspaceBinding, error) { + return WorkspaceBinding{}, nil + } + + container, err := Build(Options{ + Config: &config.Config{Workdir: "/config"}, + ConfigManager: manager, + Runtime: runtime, + ProviderService: providerSvc, + WorkspaceRoot: "/workspace-root", + Workdir: "/workspace-root/subdir", + RebuildWorkspace: rebuild, + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if container.WorkspaceRoot != "/workspace-root" { + t.Fatalf("expected workspace root to be preserved, got %q", container.WorkspaceRoot) + } + if container.Workdir != "/workspace-root/subdir" { + t.Fatalf("expected workdir to be preserved, got %q", container.Workdir) + } + if container.RebuildWorkspace == nil { + t.Fatalf("expected rebuild func to be preserved") + } }) t.Run("nil config manager", func(t *testing.T) { diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index 1a32a54c..f89b9e3f 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -46,6 +46,7 @@ type modelCatalogRefreshMsg = tuistate.ModelCatalogRefreshMsg type compactFinishedMsg = tuistate.CompactFinishedMsg type localCommandResultMsg = tuistate.LocalCommandResultMsg type sessionWorkdirResultMsg = tuistate.SessionWorkdirResultMsg +type workspaceRebuildFinishedMsg = tuistate.WorkspaceRebuildFinishedMsg type workspaceCommandResultMsg = tuistate.WorkspaceCommandResultMsg type permissionResolutionFinishedMsg = tuistate.PermissionResolutionFinishedMsg @@ -59,9 +60,11 @@ type ProviderController interface { // appServices 聚合 App 需要的服务依赖,避免与渲染状态混在同一层级。 type appServices struct { - configManager *config.Manager - providerSvc ProviderController - runtime agentruntime.Runtime + configManager *config.Manager + providerSvc ProviderController + runtime agentruntime.Runtime + workspaceRoot string + rebuildWorkspace tuibootstrap.RebuildWorkspaceFunc } // appComponents 聚合 Bubble Tea 组件与渲染器。 @@ -84,24 +87,28 @@ type appComponents struct { // appRuntimeState 聚合运行期易变字段,降低 App 顶层字段密度。 type appRuntimeState struct { - codeCopyBlocks map[int]string - pendingCopyID int - deferredEventCmd tea.Cmd - nowFn func() time.Time - lastInputEditAt time.Time - lastPasteLikeAt time.Time - inputBurstStart time.Time - inputBurstCount int - pasteMode bool - activeMessages []providertypes.Message - activities []tuistate.ActivityEntry - fileCandidates []string - modelRefreshID string - focus panel - runProgressValue float64 - runProgressKnown bool - runProgressLabel string - pendingPermission *permissionPromptState + codeCopyBlocks map[int]string + pendingCopyID int + deferredEventCmd tea.Cmd + runtimeListenerID uint64 + runtimeListenerCtx context.Context + runtimeListenerCancel context.CancelFunc + rebuildRequestID uint64 + nowFn func() time.Time + lastInputEditAt time.Time + lastPasteLikeAt time.Time + inputBurstStart time.Time + inputBurstCount int + pasteMode bool + activeMessages []providertypes.Message + activities []tuistate.ActivityEntry + fileCandidates []string + modelRefreshID string + focus panel + runProgressValue float64 + runProgressKnown bool + runProgressLabel string + pendingPermission *permissionPromptState } type App struct { @@ -201,6 +208,7 @@ func newApp(container tuibootstrap.Container) (App, error) { progressBar := progress.New(progress.WithDefaultGradient(), progress.WithoutPercentage()) progressBar.Width = 22 + runtimeListenerCtx, runtimeListenerCancel := context.WithCancel(context.Background()) app := App{ state: tuistate.UIState{ @@ -212,9 +220,11 @@ func newApp(container tuibootstrap.Container) (App, error) { Focus: panelInput, }, appServices: appServices{ - configManager: configManager, - providerSvc: providerSvc, - runtime: runtime, + configManager: configManager, + providerSvc: providerSvc, + runtime: runtime, + workspaceRoot: container.WorkspaceRoot, + rebuildWorkspace: container.RebuildWorkspace, }, appComponents: appComponents{ keys: keys, @@ -232,9 +242,12 @@ func newApp(container tuibootstrap.Container) (App, error) { markdownRenderer: markdownRenderer, }, appRuntimeState: appRuntimeState{ - codeCopyBlocks: make(map[int]string), - nowFn: time.Now, - focus: panelInput, + codeCopyBlocks: make(map[int]string), + runtimeListenerID: 1, + runtimeListenerCtx: runtimeListenerCtx, + runtimeListenerCancel: runtimeListenerCancel, + nowFn: time.Now, + focus: panelInput, }, width: 128, height: 40, @@ -272,7 +285,7 @@ func newApp(container tuibootstrap.Container) (App, error) { func (a App) Init() tea.Cmd { cmds := []tea.Cmd{ - ListenForRuntimeEvent(a.runtime.Events()), + ListenForRuntimeEvent(a.runtimeListenerCtx, a.runtimeListenerID, a.runtime.Events()), textarea.Blink, a.spinner.Tick, } diff --git a/internal/tui/core/app/app_test.go b/internal/tui/core/app/app_test.go new file mode 100644 index 00000000..6d475ee9 --- /dev/null +++ b/internal/tui/core/app/app_test.go @@ -0,0 +1,59 @@ +package tui + +import ( + "context" + "testing" + + "neo-code/internal/config" + tuibootstrap "neo-code/internal/tui/bootstrap" +) + +func TestNewDefaultsWorkspaceBindingForLegacyConstructor(t *testing.T) { + workdir := t.TempDir() + + app, err := New(&config.Config{ + Workdir: workdir, + SelectedProvider: "openai", + CurrentModel: "gpt-5", + Shell: "bash", + }, &config.Manager{}, &workspaceTestRuntime{}, &workspaceTestProvider{}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + if app.workspaceRoot != workdir { + t.Fatalf("expected workspace root %q, got %q", workdir, app.workspaceRoot) + } + if app.state.CurrentWorkdir != workdir { + t.Fatalf("expected current workdir %q, got %q", workdir, app.state.CurrentWorkdir) + } +} + +func TestNewWithBootstrapPreservesRebuildWorkspace(t *testing.T) { + workdir := t.TempDir() + rebuild := func(ctx context.Context, requestedPath string) (tuibootstrap.WorkspaceBinding, error) { + return tuibootstrap.WorkspaceBinding{Workdir: requestedPath}, nil + } + + app, err := NewWithBootstrap(tuibootstrap.Options{ + Config: &config.Config{ + Workdir: workdir, + SelectedProvider: "openai", + CurrentModel: "gpt-5", + Shell: "bash", + }, + ConfigManager: &config.Manager{}, + Runtime: &workspaceTestRuntime{}, + ProviderService: &workspaceTestProvider{}, + WorkspaceRoot: workdir, + Workdir: workdir, + RebuildWorkspace: rebuild, + }) + if err != nil { + t.Fatalf("NewWithBootstrap() error = %v", err) + } + + if app.rebuildWorkspace == nil { + t.Fatalf("expected rebuild workspace func to be set") + } +} diff --git a/internal/tui/core/app/commands.go b/internal/tui/core/app/commands.go index 9cf1c67a..799f4eb0 100644 --- a/internal/tui/core/app/commands.go +++ b/internal/tui/core/app/commands.go @@ -64,6 +64,7 @@ const ( statusDraft = "New draft" statusRunning = "Running" statusApplyingCommand = "Applying local command" + statusRebuildingWorkspace = "Rebuilding workspace" statusRunningCommand = "Running command" statusCommandDone = "Command finished" statusCompacting = "Compacting context" diff --git a/internal/tui/core/app/commands_test.go b/internal/tui/core/app/commands_test.go index cef5e8b4..93256e6f 100644 --- a/internal/tui/core/app/commands_test.go +++ b/internal/tui/core/app/commands_test.go @@ -63,6 +63,7 @@ func TestStatusConstants(t *testing.T) { {"statusDraft", statusDraft}, {"statusRunning", statusRunning}, {"statusApplyingCommand", statusApplyingCommand}, + {"statusRebuildingWorkspace", statusRebuildingWorkspace}, {"statusRunningCommand", statusRunningCommand}, {"statusCommandDone", statusCommandDone}, {"statusCompacting", statusCompacting}, diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index 0062908b..35d26fc3 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -18,12 +18,14 @@ import ( providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" "neo-code/internal/tools" + tuibootstrap "neo-code/internal/tui/bootstrap" 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" + agentworkspace "neo-code/internal/workspace" ) const ( @@ -54,6 +56,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.applyComponentLayout(true) return a, tea.Batch(cmds...) case RuntimeMsg: + if typed.SubscriptionID != a.runtimeListenerID { + return a, tea.Batch(cmds...) + } transcriptDirty := a.handleRuntimeEvent(typed.Event) if a.deferredEventCmd != nil { cmds = append(cmds, a.deferredEventCmd) @@ -64,9 +69,13 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if transcriptDirty { a.rebuildTranscript() } - cmds = append(cmds, ListenForRuntimeEvent(a.runtime.Events())) + cmds = append(cmds, ListenForRuntimeEvent(a.runtimeListenerCtx, a.runtimeListenerID, a.runtime.Events())) return a, tea.Batch(cmds...) case RuntimeClosedMsg: + if typed.SubscriptionID != a.runtimeListenerID { + return a, tea.Batch(cmds...) + } + a.cancelRuntimeListener() a.state.IsAgentRunning = false a.state.StreamingReply = false a.state.CurrentTool = "" @@ -194,6 +203,30 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.appendActivity("workspace", "Workspace command failed", typed.Err.Error(), true) return a, tea.Batch(cmds...) } + if typed.RequiresRebuild { + if a.isBusy() { + err := errors.New("workspace rebuild is blocked while agent or compact is running") + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("workspace", "Workspace command failed", err.Error(), true) + return a, tea.Batch(cmds...) + } + if a.rebuildWorkspace == nil { + err := errors.New("workspace rebuild is not configured") + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("workspace", "Workspace command failed", err.Error(), true) + return a, tea.Batch(cmds...) + } + a.state.ExecutionError = "" + a.state.IsRebuilding = true + a.rebuildRequestID++ + a.cancelRuntimeListener() + a.state.StatusText = statusRebuildingWorkspace + a.appendActivity("workspace", "Rebuilding workspace bundle", typed.Workdir, false) + cmds = append(cmds, runWorkspaceRebuild(a.rebuildRequestID, a.rebuildWorkspace, typed.Notice, typed.Workdir)) + return a, tea.Batch(cmds...) + } a.state.ExecutionError = "" a.state.StatusText = typed.Notice @@ -206,6 +239,36 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } a.appendActivity("workspace", typed.Notice, "", false) return a, tea.Batch(cmds...) + case workspaceRebuildFinishedMsg: + if typed.RebuildID != a.rebuildRequestID { + return a, tea.Batch(cmds...) + } + a.state.IsRebuilding = false + if typed.Err != nil { + cmds = append(cmds, a.failWorkspaceRebuild(typed.Err)...) + return a, tea.Batch(cmds...) + } + if err := a.applyWorkspaceBinding(typed.Binding); err != nil { + cmds = append(cmds, a.failWorkspaceRebuild(err)...) + return a, tea.Batch(cmds...) + } + a.startDraftSession() + if err := a.refreshWorkspaceBundleState(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("workspace", "Failed to refresh rebuilt workspace", err.Error(), true) + return a, tea.Batch(cmds...) + } + if cmd := a.requestModelCatalogRefresh(a.state.CurrentProvider); cmd != nil { + cmds = append(cmds, cmd) + } + if cmd := a.bindRuntimeListener(); cmd != nil { + cmds = append(cmds, cmd) + } + a.state.ExecutionError = "" + a.state.StatusText = typed.Notice + 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() @@ -384,7 +447,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te if isWorkspaceSlashCommand(input) { a.state.StatusText = statusApplyingCommand a.state.ExecutionError = "" - cmds = append(cmds, runSessionWorkdirCommand(a.runtime, a.state.ActiveSessionID, a.state.CurrentWorkdir, input)) + cmds = append(cmds, runSessionWorkdirCommand(a.runtime, a.state.ActiveSessionID, a.workspaceRoot, a.state.CurrentWorkdir, input)) return a, tea.Batch(cmds...) } a.state.StatusText = statusApplyingCommand @@ -1691,6 +1754,53 @@ func (a *App) startDraftSession() { a.rebuildTranscript() } +// applyWorkspaceBinding 将跨工作区重建后的依赖和状态重新绑定到当前 App。 +func (a *App) applyWorkspaceBinding(binding tuibootstrap.WorkspaceBinding) error { + if binding.ConfigManager == nil { + return errors.New("workspace binding config manager is nil") + } + if binding.Runtime == nil { + return errors.New("workspace binding runtime is nil") + } + if binding.ProviderService == nil { + return errors.New("workspace binding provider service is nil") + } + + a.configManager = binding.ConfigManager + a.providerSvc = binding.ProviderService + a.runtime = binding.Runtime + a.syncConfigState(binding.Config) + a.workspaceRoot = trimmedOrDefault(binding.WorkspaceRoot, binding.Config.Workdir) + a.state.CurrentWorkdir = trimmedOrDefault(binding.Workdir, binding.Config.Workdir) + return nil +} + +// refreshWorkspaceBundleState 刷新跨工作区重建后依赖于新 workdir 的 UI 数据与选择器状态。 +func (a *App) refreshWorkspaceBundleState() error { + if err := a.refreshSessions(); err != nil { + return fmt.Errorf("refresh sessions: %w", err) + } + if err := a.refreshProviderPicker(); err != nil { + return fmt.Errorf("refresh providers: %w", err) + } + if err := a.refreshModelPicker(); err != nil { + return fmt.Errorf("refresh models: %w", err) + } + if err := a.refreshFileCandidates(); err != nil { + return fmt.Errorf("refresh workspace files: %w", err) + } + a.selectCurrentProvider(a.state.CurrentProvider) + a.selectCurrentModel(a.state.CurrentModel) + return nil +} + +func trimmedOrDefault(primary, fallback string) string { + if trimmed := strings.TrimSpace(primary); trimmed != "" { + return trimmed + } + return strings.TrimSpace(fallback) +} + func (a *App) requestModelCatalogRefresh(providerID string) tea.Cmd { providerID = strings.TrimSpace(providerID) if providerID == "" || strings.EqualFold(a.modelRefreshID, providerID) { @@ -1701,11 +1811,14 @@ func (a *App) requestModelCatalogRefresh(providerID string) tea.Cmd { return runModelCatalogRefresh(a.providerSvc, providerID) } -func ListenForRuntimeEvent(sub <-chan agentruntime.RuntimeEvent) tea.Cmd { +func ListenForRuntimeEvent(ctx context.Context, subscriptionID uint64, sub <-chan agentruntime.RuntimeEvent) tea.Cmd { return tuiservices.ListenForRuntimeEventCmd( + ctx, sub, - func(event agentruntime.RuntimeEvent) tea.Msg { return RuntimeMsg{Event: event} }, - func() tea.Msg { return RuntimeClosedMsg{} }, + func(event agentruntime.RuntimeEvent) tea.Msg { + return RuntimeMsg{SubscriptionID: subscriptionID, Event: event} + }, + func() tea.Msg { return RuntimeClosedMsg{SubscriptionID: subscriptionID} }, ) } @@ -1747,6 +1860,7 @@ func runResolvePermission( func runSessionWorkdirCommand( runtime agentruntime.Runtime, sessionID string, + currentWorkspaceRoot string, currentWorkdir string, raw string, ) tea.Cmd { @@ -1754,20 +1868,72 @@ func runSessionWorkdirCommand( result := tuicommands.ExecuteSessionWorkdirCommand( runtime, sessionID, + currentWorkspaceRoot, currentWorkdir, raw, parseWorkspaceSlashCommand, - tuiworkspace.ResolveWorkspacePath, + agentworkspace.ResolveFrom, tuiworkspace.SelectSessionWorkdir, ) return sessionWorkdirResultMsg{ - Notice: result.Notice, - Workdir: result.Workdir, - Err: result.Err, + Notice: result.Notice, + Workdir: result.Workdir, + WorkspaceRoot: result.WorkspaceRoot, + RequiresRebuild: result.RequiresRebuild, + Err: result.Err, } } } +func runWorkspaceRebuild( + rebuildID uint64, + rebuild tuibootstrap.RebuildWorkspaceFunc, + notice string, + requestedPath string, +) tea.Cmd { + return func() tea.Msg { + binding, err := rebuild(context.Background(), requestedPath) + return workspaceRebuildFinishedMsg{ + RebuildID: rebuildID, + Notice: notice, + Binding: binding, + Err: err, + } + } +} + +// failWorkspaceRebuild 统一处理 workspace rebuild 失败后的状态恢复与 runtime 监听回绑。 +func (a *App) failWorkspaceRebuild(err error) []tea.Cmd { + cmds := make([]tea.Cmd, 0, 1) + if cmd := a.bindRuntimeListener(); cmd != nil { + cmds = append(cmds, cmd) + } + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("workspace", "Workspace rebuild failed", err.Error(), true) + return cmds +} + +// bindRuntimeListener 为当前 runtime 绑定新的可取消事件监听。 +func (a *App) bindRuntimeListener() tea.Cmd { + if a.runtime == nil { + return nil + } + a.cancelRuntimeListener() + a.runtimeListenerID++ + a.runtimeListenerCtx, a.runtimeListenerCancel = context.WithCancel(context.Background()) + return ListenForRuntimeEvent(a.runtimeListenerCtx, a.runtimeListenerID, a.runtime.Events()) +} + +// cancelRuntimeListener 取消当前 runtime 事件监听,避免旧订阅在重建期间继续回写 UI。 +func (a *App) cancelRuntimeListener() { + if a.runtimeListenerCancel != nil { + a.runtimeListenerCancel() + } + a.runtimeListenerCtx = nil + a.runtimeListenerCancel = nil +} + // runCompact 鍦ㄧ嫭绔嬪懡浠や腑瑙﹀彂 runtime compact锛屽苟鎶婄粨鏋滃洖浼犵粰 TUI銆 func runCompact(runtime agentruntime.Runtime, sessionID string) tea.Cmd { return tuiservices.RunCompactCmd( @@ -1779,5 +1945,5 @@ func runCompact(runtime agentruntime.Runtime, sessionID string) tea.Cmd { // isBusy 缁熶竴鍒ゆ柇褰撳墠鐣岄潰鏄惁瀛樺湪杩涜涓殑 agent 鎴?compact 鎿嶄綔銆 func (a App) isBusy() bool { - return tuiutils.IsBusy(a.state.IsAgentRunning, a.state.IsCompacting) + return tuiutils.IsBusy(a.state.IsAgentRunning, a.state.IsCompacting, a.state.IsRebuilding) } diff --git a/internal/tui/core/app/update_permission_test.go b/internal/tui/core/app/update_permission_test.go index d340f226..7aadbbe3 100644 --- a/internal/tui/core/app/update_permission_test.go +++ b/internal/tui/core/app/update_permission_test.go @@ -53,7 +53,7 @@ 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) { +func (r *permissionTestRuntime) UpdateSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { return agentsession.Session{}, nil } diff --git a/internal/tui/core/app/workspace_rebuild_test.go b/internal/tui/core/app/workspace_rebuild_test.go new file mode 100644 index 00000000..6f650d10 --- /dev/null +++ b/internal/tui/core/app/workspace_rebuild_test.go @@ -0,0 +1,392 @@ +package tui + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/viewport" + + "neo-code/internal/config" + agentruntime "neo-code/internal/runtime" + agentsession "neo-code/internal/session" + tuibootstrap "neo-code/internal/tui/bootstrap" + tuistate "neo-code/internal/tui/state" +) + +type workspaceTestRuntime struct { + sessions []agentsession.Summary + session agentsession.Session +} + +func (r *workspaceTestRuntime) Run(ctx context.Context, input agentruntime.UserInput) error { + return nil +} + +func (r *workspaceTestRuntime) Compact(ctx context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) { + return agentruntime.CompactResult{}, nil +} + +func (r *workspaceTestRuntime) ResolvePermission(ctx context.Context, input agentruntime.PermissionResolutionInput) error { + return nil +} + +func (r *workspaceTestRuntime) CancelActiveRun() bool { + return false +} + +func (r *workspaceTestRuntime) Events() <-chan agentruntime.RuntimeEvent { + ch := make(chan agentruntime.RuntimeEvent) + close(ch) + return ch +} + +func (r *workspaceTestRuntime) ListSessions(ctx context.Context) ([]agentsession.Summary, error) { + return append([]agentsession.Summary(nil), r.sessions...), nil +} + +func (r *workspaceTestRuntime) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { + return r.session, nil +} + +func (r *workspaceTestRuntime) UpdateSessionWorkdir(ctx context.Context, sessionID string, requestedPath string) (agentsession.Session, error) { + return r.session, nil +} + +type workspaceTestProvider struct{} + +func (p *workspaceTestProvider) ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) { + return nil, nil +} + +func (p *workspaceTestProvider) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) { + return config.ProviderSelection{}, nil +} + +func (p *workspaceTestProvider) ListModels(ctx context.Context) ([]config.ModelDescriptor, error) { + return nil, nil +} + +func (p *workspaceTestProvider) ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) { + return nil, nil +} + +func (p *workspaceTestProvider) SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) { + return config.ProviderSelection{}, nil +} + +func newWorkspaceConfigManager(t *testing.T, workdir string) *config.Manager { + t.Helper() + + defaults := config.DefaultConfig() + manager := config.NewManager(config.NewLoader(t.TempDir(), defaults)) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("load config: %v", err) + } + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Workdir = workdir + return nil + }); err != nil { + t.Fatalf("update config: %v", err) + } + return manager +} + +func newWorkspaceTestApp(manager *config.Manager, runtime agentruntime.Runtime, provider ProviderController) *App { + input := textarea.New() + spin := spinner.New() + sessionList := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) + uiStyles := newStyles() + app := &App{ + state: tuistate.UIState{ + Focus: panelInput, + CurrentWorkdir: manager.Get().Workdir, + }, + appServices: appServices{ + configManager: manager, + providerSvc: provider, + runtime: runtime, + workspaceRoot: manager.Get().Workdir, + }, + appComponents: appComponents{ + keys: newKeyMap(), + spinner: spin, + sessions: sessionList, + commandMenu: newCommandMenuModel(uiStyles), + providerPicker: newSelectionPickerItems(nil), + modelPicker: newSelectionPickerItems(nil), + input: input, + transcript: viewport.New(0, 0), + activity: viewport.New(0, 0), + }, + appRuntimeState: appRuntimeState{ + nowFn: time.Now, + codeCopyBlocks: map[int]string{}, + focus: panelInput, + runtimeListenerID: 1, + }, + styles: uiStyles, + } + return app +} + +func TestSessionWorkdirResultBlocksCrossWorkspaceWhileBusy(t *testing.T) { + app := newPermissionTestApp(&permissionTestRuntime{}) + app.rebuildWorkspace = func(ctx context.Context, requestedPath string) (tuibootstrap.WorkspaceBinding, error) { + t.Fatalf("rebuild should not be called while busy") + return tuibootstrap.WorkspaceBinding{}, nil + } + app.state.IsAgentRunning = true + + model, _ := app.Update(sessionWorkdirResultMsg{ + Notice: "[System] Workspace switched to /tmp/other.", + Workdir: t.TempDir(), + RequiresRebuild: true, + }) + next := model.(App) + if next.state.ExecutionError == "" { + t.Fatalf("expected busy error when rebuild is blocked") + } +} + +func TestSessionWorkdirResultRequiresConfiguredRebuild(t *testing.T) { + app := newPermissionTestApp(&permissionTestRuntime{}) + + model, _ := app.Update(sessionWorkdirResultMsg{ + Notice: "[System] Workspace switched to /tmp/other.", + Workdir: t.TempDir(), + RequiresRebuild: true, + }) + next := model.(App) + if next.state.ExecutionError == "" { + t.Fatalf("expected rebuild configuration error") + } +} + +func TestRunWorkspaceRebuildReturnsFinishedMsg(t *testing.T) { + wantPath := t.TempDir() + cmd := runWorkspaceRebuild(7, func(ctx context.Context, requestedPath string) (tuibootstrap.WorkspaceBinding, error) { + if requestedPath != wantPath { + t.Fatalf("expected requested path %q, got %q", wantPath, requestedPath) + } + return tuibootstrap.WorkspaceBinding{Workdir: requestedPath}, nil + }, "notice", wantPath) + + msg := cmd() + finished, ok := msg.(workspaceRebuildFinishedMsg) + if !ok { + t.Fatalf("expected workspaceRebuildFinishedMsg, got %T", msg) + } + if finished.RebuildID != 7 { + t.Fatalf("expected rebuild id 7, got %d", finished.RebuildID) + } + if finished.Notice != "notice" { + t.Fatalf("unexpected notice: %+v", finished) + } + if finished.Binding.Workdir != wantPath { + t.Fatalf("expected binding workdir %q, got %q", wantPath, finished.Binding.Workdir) + } +} + +func TestWorkspaceRebuildFinishedResetsToFreshDraft(t *testing.T) { + oldRoot := t.TempDir() + newRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(newRoot, "new.txt"), []byte("new"), 0o644); err != nil { + t.Fatalf("write new workspace file: %v", err) + } + oldManager := newWorkspaceConfigManager(t, oldRoot) + newManager := newWorkspaceConfigManager(t, newRoot) + provider := &workspaceTestProvider{} + oldRuntime := &workspaceTestRuntime{ + sessions: []agentsession.Summary{{ID: "old-session", Title: "Old"}}, + session: agentsession.Session{ID: "old-session", Title: "Old", Workdir: oldRoot}, + } + newRuntime := &workspaceTestRuntime{} + + app := newWorkspaceTestApp(oldManager, oldRuntime, provider) + app.rebuildRequestID = 1 + app.state.ActiveSessionID = "old-session" + app.state.ActiveSessionTitle = "Old" + app.input.SetValue("stale input") + app.state.InputText = "stale input" + app.fileCandidates = []string{"old.txt"} + app.fileBrowser.CurrentDirectory = oldRoot + + model, _ := app.Update(workspaceRebuildFinishedMsg{ + RebuildID: 1, + Notice: "[System] Workspace switched to " + newRoot + ".", + Binding: tuibootstrap.WorkspaceBinding{ + Config: newManager.Get(), + ConfigManager: newManager, + Runtime: newRuntime, + ProviderService: provider, + WorkspaceRoot: newRoot, + Workdir: newRoot, + }, + }) + next := model.(App) + if next.state.IsRebuilding { + t.Fatalf("expected rebuild flag to be cleared") + } + if next.state.ActiveSessionID != "" { + t.Fatalf("expected draft session after rebuild, got %q", next.state.ActiveSessionID) + } + if next.state.CurrentWorkdir != newRoot { + t.Fatalf("expected rebuilt workdir %q, got %q", newRoot, next.state.CurrentWorkdir) + } + if next.fileBrowser.CurrentDirectory != newRoot { + t.Fatalf("expected file browser directory %q, got %q", newRoot, next.fileBrowser.CurrentDirectory) + } + if !containsWorkspaceCandidate(next.fileCandidates, "new.txt") { + t.Fatalf("expected rebuilt workspace files to include new.txt, got %+v", next.fileCandidates) + } + if len(next.state.Sessions) != 0 { + t.Fatalf("expected session list to refresh from rebuilt runtime, got %+v", next.state.Sessions) + } + if next.state.InputText != "" { + t.Fatalf("expected draft input to be cleared, got %q", next.state.InputText) + } +} + +func TestWorkspaceRebuildFinishedReportsRebuildError(t *testing.T) { + app := newPermissionTestApp(&permissionTestRuntime{}) + app.rebuildRequestID = 1 + app.state.IsRebuilding = true + + model, _ := app.Update(workspaceRebuildFinishedMsg{RebuildID: 1, Err: errors.New("rebuild failed")}) + next := model.(App) + if next.state.ExecutionError != "rebuild failed" { + t.Fatalf("expected rebuild error to surface, got %q", next.state.ExecutionError) + } + if next.state.IsRebuilding { + t.Fatalf("expected rebuild flag to clear after failure") + } +} + +func TestSessionWorkdirResultStartsBusyRebuild(t *testing.T) { + app := newPermissionTestApp(&permissionTestRuntime{}) + app.rebuildWorkspace = func(ctx context.Context, requestedPath string) (tuibootstrap.WorkspaceBinding, error) { + return tuibootstrap.WorkspaceBinding{}, nil + } + + model, cmd := app.Update(sessionWorkdirResultMsg{ + Notice: "[System] Workspace switched to /tmp/other.", + Workdir: t.TempDir(), + RequiresRebuild: true, + }) + next := model.(App) + if !next.state.IsRebuilding { + t.Fatalf("expected rebuild flag to be set") + } + if next.state.StatusText != statusRebuildingWorkspace { + t.Fatalf("expected rebuild status, got %q", next.state.StatusText) + } + if cmd == nil { + t.Fatalf("expected rebuild command to be returned") + } +} + +func TestWorkspaceRebuildFinishedIgnoresStaleResult(t *testing.T) { + app := newPermissionTestApp(&permissionTestRuntime{}) + app.rebuildRequestID = 2 + app.state.IsRebuilding = true + app.state.StatusText = statusRebuildingWorkspace + + model, _ := app.Update(workspaceRebuildFinishedMsg{ + RebuildID: 1, + Err: errors.New("stale rebuild should be ignored"), + }) + next := model.(App) + if !next.state.IsRebuilding { + t.Fatalf("expected stale rebuild to keep current rebuild state") + } + if next.state.ExecutionError != "" { + t.Fatalf("expected stale rebuild error to be ignored, got %q", next.state.ExecutionError) + } +} + +func TestRuntimeMessagesIgnoreStaleSubscription(t *testing.T) { + app := newPermissionTestApp(&permissionTestRuntime{}) + app.runtimeListenerID = 2 + app.state.StatusText = statusThinking + + model, _ := app.Update(RuntimeClosedMsg{SubscriptionID: 1}) + next := model.(App) + if next.state.StatusText != statusThinking { + t.Fatalf("expected stale runtime close to be ignored, got %q", next.state.StatusText) + } + + model, _ = next.Update(RuntimeMsg{ + SubscriptionID: 1, + Event: agentruntime.RuntimeEvent{ + Type: agentruntime.EventError, + Payload: "stale event", + }, + }) + next = model.(App) + if next.state.ExecutionError != "" { + t.Fatalf("expected stale runtime event to be ignored, got %q", next.state.ExecutionError) + } +} + +func TestApplyWorkspaceBindingValidatesDependencies(t *testing.T) { + app := newPermissionTestApp(&permissionTestRuntime{}) + manager := newWorkspaceConfigManager(t, t.TempDir()) + provider := &workspaceTestProvider{} + runtime := &workspaceTestRuntime{} + + if err := app.applyWorkspaceBinding(tuibootstrap.WorkspaceBinding{}); err == nil { + t.Fatalf("expected config manager validation error") + } + if err := app.applyWorkspaceBinding(tuibootstrap.WorkspaceBinding{ + ConfigManager: manager, + ProviderService: provider, + }); err == nil { + t.Fatalf("expected runtime validation error") + } + if err := app.applyWorkspaceBinding(tuibootstrap.WorkspaceBinding{ + ConfigManager: manager, + Runtime: runtime, + }); err == nil { + t.Fatalf("expected provider validation error") + } +} + +func TestApplyWorkspaceBindingFallsBackToConfigPaths(t *testing.T) { + root := t.TempDir() + manager := newWorkspaceConfigManager(t, root) + provider := &workspaceTestProvider{} + runtime := &workspaceTestRuntime{} + app := newPermissionTestApp(&permissionTestRuntime{}) + + if err := app.applyWorkspaceBinding(tuibootstrap.WorkspaceBinding{ + Config: manager.Get(), + ConfigManager: manager, + Runtime: runtime, + ProviderService: provider, + }); err != nil { + t.Fatalf("apply workspace binding: %v", err) + } + if app.workspaceRoot != root { + t.Fatalf("expected workspace root fallback %q, got %q", root, app.workspaceRoot) + } + if app.state.CurrentWorkdir != root { + t.Fatalf("expected workdir fallback %q, got %q", root, app.state.CurrentWorkdir) + } +} + +func containsWorkspaceCandidate(candidates []string, name string) bool { + for _, candidate := range candidates { + if strings.Contains(candidate, name) { + return true + } + } + return false +} diff --git a/internal/tui/core/commands/workspace.go b/internal/tui/core/commands/workspace.go index 08be31d1..edf2cb13 100644 --- a/internal/tui/core/commands/workspace.go +++ b/internal/tui/core/commands/workspace.go @@ -6,28 +6,32 @@ import ( "strings" agentsession "neo-code/internal/session" + agentworkspace "neo-code/internal/workspace" ) -// SessionWorkdirSetter 定义设置会话工作目录所需的最小 runtime 能力。 -type SessionWorkdirSetter interface { - SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) +// SessionWorkdirUpdater 定义更新会话工作目录所需的最小 runtime 能力。 +type SessionWorkdirUpdater interface { + UpdateSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) } // SessionWorkdirCommandResult 表示工作目录命令执行结果。 type SessionWorkdirCommandResult struct { - Notice string - Workdir string - Err error + Notice string + Workdir string + WorkspaceRoot string + RequiresRebuild bool + Err error } // ExecuteSessionWorkdirCommand 执行 /cwd 命令的核心流程,返回统一结果结构。 func ExecuteSessionWorkdirCommand( - runtime SessionWorkdirSetter, + runtime SessionWorkdirUpdater, sessionID string, + currentWorkspaceRoot string, currentWorkdir string, raw string, parseCommand func(string) (string, error), - resolveWorkspacePath func(string, string) (string, error), + resolveWorkspace func(string, string) (agentworkspace.Resolution, error), selectSessionWorkdir func(string, string) string, ) SessionWorkdirCommandResult { requested, err := parseCommand(raw) @@ -47,24 +51,44 @@ func ExecuteSessionWorkdirCommand( } if strings.TrimSpace(sessionID) == "" { - workdir, err := resolveWorkspacePath(currentWorkdir, requested) + resolved, err := resolveWorkspace(currentWorkdir, requested) if err != nil { return SessionWorkdirCommandResult{Err: err} } + notice := fmt.Sprintf("[System] Draft workspace switched to %s.", resolved.Workdir) + if !agentworkspace.SameRoot(currentWorkspaceRoot, resolved.WorkspaceRoot) { + notice = fmt.Sprintf("[System] Workspace switched to %s.", resolved.Workdir) + } return SessionWorkdirCommandResult{ - Notice: fmt.Sprintf("[System] Draft workspace switched to %s.", workdir), - Workdir: workdir, + Notice: notice, + Workdir: resolved.Workdir, + WorkspaceRoot: resolved.WorkspaceRoot, + RequiresRebuild: !agentworkspace.SameRoot(currentWorkspaceRoot, resolved.WorkspaceRoot), + } + } + + resolved, err := resolveWorkspace(currentWorkdir, requested) + if err != nil { + return SessionWorkdirCommandResult{Err: err} + } + if !agentworkspace.SameRoot(currentWorkspaceRoot, resolved.WorkspaceRoot) { + return SessionWorkdirCommandResult{ + Notice: fmt.Sprintf("[System] Workspace switched to %s.", resolved.Workdir), + Workdir: resolved.Workdir, + WorkspaceRoot: resolved.WorkspaceRoot, + RequiresRebuild: true, } } - session, err := runtime.SetSessionWorkdir(context.Background(), sessionID, requested) + session, err := runtime.UpdateSessionWorkdir(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, + Notice: fmt.Sprintf("[System] Session workspace switched to %s.", workdir), + Workdir: workdir, + WorkspaceRoot: currentWorkspaceRoot, } } diff --git a/internal/tui/core/commands/workspace_test.go b/internal/tui/core/commands/workspace_test.go index 4ed48934..91068dfd 100644 --- a/internal/tui/core/commands/workspace_test.go +++ b/internal/tui/core/commands/workspace_test.go @@ -10,6 +10,7 @@ import ( agentsession "neo-code/internal/session" tuiworkspace "neo-code/internal/tui/core/workspace" + agentworkspace "neo-code/internal/workspace" ) type stubSessionWorkdirSetter struct { @@ -18,7 +19,7 @@ type stubSessionWorkdirSetter struct { calls int } -func (s *stubSessionWorkdirSetter) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { +func (s *stubSessionWorkdirSetter) UpdateSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { s.calls++ if s.err != nil { return agentsession.Session{}, s.err @@ -42,14 +43,14 @@ func TestExecuteSessionWorkdirCommand(t *testing.T) { } t.Run("parse error", func(t *testing.T) { - result := ExecuteSessionWorkdirCommand(&stubSessionWorkdirSetter{}, "", "", "/bad", parse, tuiworkspace.ResolveWorkspacePath, tuiworkspace.SelectSessionWorkdir) + result := ExecuteSessionWorkdirCommand(&stubSessionWorkdirSetter{}, "", "", "", "/bad", parse, agentworkspace.ResolveFrom, 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) + result := ExecuteSessionWorkdirCommand(&stubSessionWorkdirSetter{}, "", "", "", "/cwd", parse, agentworkspace.ResolveFrom, tuiworkspace.SelectSessionWorkdir) if result.Err == nil || !strings.Contains(result.Err.Error(), "usage: /cwd ") { t.Fatalf("expected usage error, got %+v", result) } @@ -57,7 +58,7 @@ func TestExecuteSessionWorkdirCommand(t *testing.T) { t.Run("empty requested with current workdir", func(t *testing.T) { current := t.TempDir() - result := ExecuteSessionWorkdirCommand(&stubSessionWorkdirSetter{}, "", current, "/cwd", parse, tuiworkspace.ResolveWorkspacePath, tuiworkspace.SelectSessionWorkdir) + result := ExecuteSessionWorkdirCommand(&stubSessionWorkdirSetter{}, "", current, current, "/cwd", parse, agentworkspace.ResolveFrom, tuiworkspace.SelectSessionWorkdir) if result.Err != nil { t.Fatalf("unexpected error: %v", result.Err) } @@ -69,10 +70,13 @@ func TestExecuteSessionWorkdirCommand(t *testing.T) { t.Run("draft session resolves requested path", func(t *testing.T) { base := t.TempDir() target := filepath.Join(base, "sub") + if err := ensureDir(filepath.Join(base, ".git")); err != nil { + t.Fatalf("mkdir git marker: %v", err) + } if err := ensureDir(target); err != nil { t.Fatalf("mkdir target: %v", err) } - result := ExecuteSessionWorkdirCommand(&stubSessionWorkdirSetter{}, "", base, "/cwd sub", parse, tuiworkspace.ResolveWorkspacePath, tuiworkspace.SelectSessionWorkdir) + result := ExecuteSessionWorkdirCommand(&stubSessionWorkdirSetter{}, "", base, base, "/cwd sub", parse, agentworkspace.ResolveFrom, tuiworkspace.SelectSessionWorkdir) if result.Err != nil { t.Fatalf("unexpected error: %v", result.Err) } @@ -83,7 +87,14 @@ func TestExecuteSessionWorkdirCommand(t *testing.T) { 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) + workdir := t.TempDir() + if err := ensureDir(filepath.Join(workdir, ".git")); err != nil { + t.Fatalf("mkdir git marker: %v", err) + } + if err := ensureDir(filepath.Join(workdir, "sub")); err != nil { + t.Fatalf("mkdir subdir: %v", err) + } + result := ExecuteSessionWorkdirCommand(stub, "session-1", workdir, workdir, "/cwd sub", parse, agentworkspace.ResolveFrom, tuiworkspace.SelectSessionWorkdir) if result.Err == nil || !strings.Contains(result.Err.Error(), "set workdir failed") { t.Fatalf("expected runtime error, got %+v", result) } @@ -91,8 +102,14 @@ func TestExecuteSessionWorkdirCommand(t *testing.T) { t.Run("runtime empty workdir fallback", func(t *testing.T) { current := t.TempDir() + if err := ensureDir(filepath.Join(current, ".git")); err != nil { + t.Fatalf("mkdir git marker: %v", err) + } + if err := ensureDir(filepath.Join(current, "sub")); err != nil { + t.Fatalf("mkdir subdir: %v", err) + } stub := &stubSessionWorkdirSetter{session: agentsession.Session{ID: "session-1", Workdir: ""}} - result := ExecuteSessionWorkdirCommand(stub, "session-1", current, "/cwd sub", parse, tuiworkspace.ResolveWorkspacePath, tuiworkspace.SelectSessionWorkdir) + result := ExecuteSessionWorkdirCommand(stub, "session-1", current, current, "/cwd sub", parse, agentworkspace.ResolveFrom, tuiworkspace.SelectSessionWorkdir) if result.Err != nil { t.Fatalf("unexpected error: %v", result.Err) } @@ -100,6 +117,34 @@ func TestExecuteSessionWorkdirCommand(t *testing.T) { t.Fatalf("expected fallback workdir %q, got %q", current, result.Workdir) } }) + + t.Run("cross workspace requests rebuild", func(t *testing.T) { + current := t.TempDir() + other := t.TempDir() + result := ExecuteSessionWorkdirCommand(&stubSessionWorkdirSetter{}, "", current, current, "/cwd "+other, parse, agentworkspace.ResolveFrom, tuiworkspace.SelectSessionWorkdir) + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + if !result.RequiresRebuild { + t.Fatalf("expected rebuild request") + } + }) + + t.Run("active session cross workspace requests rebuild without updating runtime", func(t *testing.T) { + current := t.TempDir() + other := t.TempDir() + stub := &stubSessionWorkdirSetter{} + result := ExecuteSessionWorkdirCommand(stub, "session-1", current, current, "/cwd "+other, parse, agentworkspace.ResolveFrom, tuiworkspace.SelectSessionWorkdir) + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + if !result.RequiresRebuild { + t.Fatalf("expected rebuild request") + } + if stub.calls != 0 { + t.Fatalf("expected runtime not to be updated on cross-workspace switch, got %d calls", stub.calls) + } + }) } func ensureDir(path string) error { diff --git a/internal/tui/core/utils/view_helpers.go b/internal/tui/core/utils/view_helpers.go index 4c0d029a..8e242cb8 100644 --- a/internal/tui/core/utils/view_helpers.go +++ b/internal/tui/core/utils/view_helpers.go @@ -28,9 +28,9 @@ func RequestedWorkdirForRun(activeSessionID string, currentWorkdir string) strin return "" } -// IsBusy 统一判断当前是否存在进行中的 agent 或 compact 操作。 -func IsBusy(isAgentRunning bool, isCompacting bool) bool { - return isAgentRunning || isCompacting +// IsBusy 统一判断当前是否存在进行中的 agent、compact 或 workspace rebuild 操作。 +func IsBusy(isAgentRunning bool, isCompacting bool, isRebuilding bool) bool { + return isAgentRunning || isCompacting || isRebuilding } // FocusLabelFromPanel 将焦点面板枚举映射为界面展示标签。 diff --git a/internal/tui/core/utils/view_helpers_test.go b/internal/tui/core/utils/view_helpers_test.go index 9a37e084..a72222d0 100644 --- a/internal/tui/core/utils/view_helpers_test.go +++ b/internal/tui/core/utils/view_helpers_test.go @@ -51,17 +51,19 @@ func TestIsBusy(t *testing.T) { name string isAgentRunning bool isCompacting bool + isRebuilding bool want bool }{ - {"both false", false, false, false}, - {"agent running", true, false, true}, - {"compacting", false, true, true}, - {"both true", true, true, true}, + {"both false", false, false, false, false}, + {"agent running", true, false, false, true}, + {"compacting", false, true, false, true}, + {"rebuilding", false, false, true, true}, + {"both true", true, true, false, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := IsBusy(tt.isAgentRunning, tt.isCompacting); got != tt.want { + if got := IsBusy(tt.isAgentRunning, tt.isCompacting, tt.isRebuilding); got != tt.want { t.Errorf("IsBusy() = %v, want %v", got, tt.want) } }) diff --git a/internal/tui/core/workspace/resolver.go b/internal/tui/core/workspace/resolver.go index ccf6c103..dc9eda5c 100644 --- a/internal/tui/core/workspace/resolver.go +++ b/internal/tui/core/workspace/resolver.go @@ -1,43 +1,17 @@ package workspace import ( - "fmt" - "os" - "path/filepath" + agentworkspace "neo-code/internal/workspace" "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) + resolved, err := agentworkspace.ResolveFrom(base, requested) 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 "", err } - return filepath.Clean(absolute), nil + return resolved.Workdir, nil } // SelectSessionWorkdir 优先返回会话工作目录,缺失时回退到默认工作目录。 diff --git a/internal/tui/services/runtime_service.go b/internal/tui/services/runtime_service.go index d4982d4d..ee0c4478 100644 --- a/internal/tui/services/runtime_service.go +++ b/internal/tui/services/runtime_service.go @@ -28,16 +28,21 @@ type PermissionResolver interface { // ListenForRuntimeEventCmd 监听 runtime 事件通道,并将结果映射为 UI 消息。 func ListenForRuntimeEventCmd( + ctx context.Context, sub <-chan agentruntime.RuntimeEvent, eventMsg func(agentruntime.RuntimeEvent) tea.Msg, closedMsg func() tea.Msg, ) tea.Cmd { return func() tea.Msg { - event, ok := <-sub - if !ok { - return closedMsg() + select { + case <-ctx.Done(): + return nil + case event, ok := <-sub: + if !ok { + return closedMsg() + } + return eventMsg(event) } - return eventMsg(event) } } diff --git a/internal/tui/services/services_test.go b/internal/tui/services/services_test.go index b5893084..be5bed8a 100644 --- a/internal/tui/services/services_test.go +++ b/internal/tui/services/services_test.go @@ -71,6 +71,7 @@ func TestListenForRuntimeEventCmd(t *testing.T) { ch <- event msg := ListenForRuntimeEventCmd( + context.Background(), ch, func(e agentruntime.RuntimeEvent) tea.Msg { return e }, func() tea.Msg { return "closed" }, @@ -82,6 +83,7 @@ func TestListenForRuntimeEventCmd(t *testing.T) { close(ch) msg = ListenForRuntimeEventCmd( + context.Background(), ch, func(e agentruntime.RuntimeEvent) tea.Msg { return e }, func() tea.Msg { return "closed" }, @@ -91,6 +93,21 @@ func TestListenForRuntimeEventCmd(t *testing.T) { } } +func TestListenForRuntimeEventCmdCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + msg := ListenForRuntimeEventCmd( + ctx, + make(chan agentruntime.RuntimeEvent), + func(e agentruntime.RuntimeEvent) tea.Msg { return e }, + func() tea.Msg { return "closed" }, + )() + if msg != nil { + t.Fatalf("expected nil msg when listener is canceled, got %T %#v", msg, msg) + } +} + func TestRunAgentCmd(t *testing.T) { runner := &stubRunner{err: errors.New("boom")} input := agentruntime.UserInput{SessionID: "s1", Content: "hello", Workdir: "D:/"} diff --git a/internal/tui/state/messages.go b/internal/tui/state/messages.go index 6bd8d47f..ef8c4e7e 100644 --- a/internal/tui/state/messages.go +++ b/internal/tui/state/messages.go @@ -3,15 +3,19 @@ package state import ( "neo-code/internal/config" agentruntime "neo-code/internal/runtime" + tuibootstrap "neo-code/internal/tui/bootstrap" ) // RuntimeMsg 封装 runtime 事件流消息。 type RuntimeMsg struct { - Event agentruntime.RuntimeEvent + SubscriptionID uint64 + Event agentruntime.RuntimeEvent } // RuntimeClosedMsg 表示 runtime 事件通道已关闭。 -type RuntimeClosedMsg struct{} +type RuntimeClosedMsg struct { + SubscriptionID uint64 +} // RunFinishedMsg 表示一次 Run 调用结束。 type RunFinishedMsg struct { @@ -40,9 +44,19 @@ type LocalCommandResultMsg struct { // SessionWorkdirResultMsg 表示会话工作目录命令结果。 type SessionWorkdirResultMsg struct { - Notice string - Workdir string - Err error + Notice string + Workdir string + WorkspaceRoot string + RequiresRebuild bool + Err error +} + +// WorkspaceRebuildFinishedMsg 表示一次跨工作区 bundle 重建结束。 +type WorkspaceRebuildFinishedMsg struct { + RebuildID uint64 + Notice string + Binding tuibootstrap.WorkspaceBinding + Err error } // WorkspaceCommandResultMsg 表示工作区命令执行结果。 diff --git a/internal/tui/state/ui_state.go b/internal/tui/state/ui_state.go index 9fa071a3..185cd7ba 100644 --- a/internal/tui/state/ui_state.go +++ b/internal/tui/state/ui_state.go @@ -31,6 +31,7 @@ type UIState struct { InputText string IsAgentRunning bool IsCompacting bool + IsRebuilding bool StreamingReply bool CurrentTool string ToolStates []ToolState diff --git a/internal/workspace/resolver.go b/internal/workspace/resolver.go new file mode 100644 index 00000000..ed206236 --- /dev/null +++ b/internal/workspace/resolver.go @@ -0,0 +1,119 @@ +package workspace + +import ( + "fmt" + "os" + "path/filepath" + goruntime "runtime" + "strings" +) + +// Resolution 描述一次工作区解析后的根目录与当前工作目录。 +type Resolution struct { + WorkspaceRoot string + Workdir string +} + +// Resolve 将目标路径解析为存在的绝对目录,并推导所属工作区根目录。 +func Resolve(requestedPath string) (Resolution, error) { + return ResolveFrom("", requestedPath) +} + +// ResolveFrom 基于 base 解析 requestedPath,并统一返回 workdir 与 workspaceRoot。 +func ResolveFrom(base string, requestedPath string) (Resolution, error) { + workdir, err := normalizeExistingDirectory(base, requestedPath) + if err != nil { + return Resolution{}, err + } + + workspaceRoot, err := detectWorkspaceRoot(workdir) + if err != nil { + return Resolution{}, err + } + + return Resolution{ + WorkspaceRoot: workspaceRoot, + Workdir: workdir, + }, nil +} + +// SameRoot 判断两个工作区根目录在当前平台语义下是否指向同一位置。 +func SameRoot(left string, right string) bool { + leftKey := pathKey(left) + rightKey := pathKey(right) + return leftKey != "" && leftKey == rightKey +} + +// normalizeExistingDirectory 负责把路径解析成存在的绝对目录。 +func normalizeExistingDirectory(base string, requestedPath 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(requestedPath) + 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 %q: %w", requestedPath, err) + } + absolute = filepath.Clean(absolute) + + info, err := os.Stat(absolute) + if err != nil { + return "", fmt.Errorf("workspace: resolve path %q: %w", requestedPath, err) + } + if !info.IsDir() { + return "", fmt.Errorf("workspace: %q is not a directory", absolute) + } + return absolute, nil +} + +// detectWorkspaceRoot 优先回溯 Git 根目录,非 Git 场景回退为目标目录自身。 +func detectWorkspaceRoot(workdir string) (string, error) { + current := filepath.Clean(strings.TrimSpace(workdir)) + if current == "" { + return "", fmt.Errorf("workspace: workdir is empty") + } + + for { + gitMarker := filepath.Join(current, ".git") + if _, err := os.Stat(gitMarker); err == nil { + return current, nil + } else if err != nil && !os.IsNotExist(err) { + return "", fmt.Errorf("workspace: inspect git marker %q: %w", gitMarker, err) + } + + parent := filepath.Dir(current) + if parent == current { + return workdir, nil + } + current = parent + } +} + +func pathKey(path string) string { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return "" + } + absolute, err := filepath.Abs(trimmed) + if err == nil { + trimmed = absolute + } + trimmed = filepath.Clean(trimmed) + if goruntime.GOOS == "windows" { + return strings.ToLower(trimmed) + } + return trimmed +} diff --git a/internal/workspace/resolver_test.go b/internal/workspace/resolver_test.go new file mode 100644 index 00000000..8464353c --- /dev/null +++ b/internal/workspace/resolver_test.go @@ -0,0 +1,147 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveFrom(t *testing.T) { + base := t.TempDir() + repoRoot := filepath.Join(base, "repo") + nested := filepath.Join(repoRoot, "pkg", "nested") + if err := os.MkdirAll(filepath.Join(repoRoot, ".git"), 0o755); err != nil { + t.Fatalf("mkdir git marker: %v", err) + } + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatalf("mkdir nested: %v", err) + } + + resolved, err := ResolveFrom(repoRoot, "pkg/nested") + if err != nil { + t.Fatalf("ResolveFrom() error = %v", err) + } + if resolved.Workdir != nested { + t.Fatalf("expected workdir %q, got %q", nested, resolved.Workdir) + } + if resolved.WorkspaceRoot != repoRoot { + t.Fatalf("expected workspace root %q, got %q", repoRoot, resolved.WorkspaceRoot) + } +} + +func TestResolveFromFallsBackToTargetDirectoryOutsideGit(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "standalone") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("mkdir target: %v", err) + } + + resolved, err := ResolveFrom(base, "standalone") + if err != nil { + t.Fatalf("ResolveFrom() error = %v", err) + } + if resolved.WorkspaceRoot != target { + t.Fatalf("expected standalone workspace root %q, got %q", target, resolved.WorkspaceRoot) + } + if resolved.Workdir != target { + t.Fatalf("expected workdir %q, got %q", target, resolved.Workdir) + } +} + +func TestResolveFromDetectsWorkspaceRootWhenGitMarkerIsFile(t *testing.T) { + base := t.TempDir() + repoRoot := filepath.Join(base, "repo") + nested := filepath.Join(repoRoot, "pkg", "nested") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatalf("mkdir nested: %v", err) + } + if err := os.WriteFile(filepath.Join(repoRoot, ".git"), []byte("gitdir: /tmp/linked"), 0o644); err != nil { + t.Fatalf("write git marker file: %v", err) + } + + resolved, err := ResolveFrom(repoRoot, "pkg/nested") + if err != nil { + t.Fatalf("ResolveFrom() error = %v", err) + } + if resolved.WorkspaceRoot != repoRoot { + t.Fatalf("expected workspace root %q, got %q", repoRoot, resolved.WorkspaceRoot) + } +} + +func TestResolveUsesCurrentDirectoryWhenBaseAndPathAreEmpty(t *testing.T) { + previous, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + + repoRoot := t.TempDir() + nested := filepath.Join(repoRoot, "nested") + if err := os.MkdirAll(filepath.Join(repoRoot, ".git"), 0o755); err != nil { + t.Fatalf("mkdir git marker: %v", err) + } + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatalf("mkdir nested: %v", err) + } + if err := os.Chdir(nested); err != nil { + t.Fatalf("chdir nested: %v", err) + } + t.Cleanup(func() { + if chdirErr := os.Chdir(previous); chdirErr != nil { + t.Fatalf("restore cwd: %v", chdirErr) + } + }) + + resolved, err := Resolve("") + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if resolved.Workdir != nested { + t.Fatalf("expected workdir %q, got %q", nested, resolved.Workdir) + } + if resolved.WorkspaceRoot != repoRoot { + t.Fatalf("expected workspace root %q, got %q", repoRoot, resolved.WorkspaceRoot) + } +} + +func TestResolveRejectsInvalidTargets(t *testing.T) { + base := t.TempDir() + filePath := filepath.Join(base, "note.txt") + if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + if _, err := ResolveFrom(base, "missing"); err == nil { + t.Fatalf("expected missing path error") + } + if _, err := ResolveFrom(base, filePath); err == nil { + t.Fatalf("expected file path error") + } +} + +func TestSameRoot(t *testing.T) { + root := t.TempDir() + if !SameRoot(root, filepath.Join(root, ".")) { + t.Fatalf("expected same root match") + } + previous, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(root); err != nil { + t.Fatalf("chdir root: %v", err) + } + t.Cleanup(func() { + if chdirErr := os.Chdir(previous); chdirErr != nil { + t.Fatalf("restore cwd: %v", chdirErr) + } + }) + if !SameRoot(root, ".") { + t.Fatalf("expected relative path to normalize to current root") + } + if SameRoot(root, filepath.Join(root, "child")) { + t.Fatalf("expected different roots") + } + if SameRoot("", root) { + t.Fatalf("expected empty root to never match") + } +}