From b9753001a1f2381854903a1d3310ba28a51b2a3a Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Thu, 9 Apr 2026 11:19:11 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=E5=88=87=E6=8D=A2sla?= =?UTF-8?q?sh=E5=88=87=E6=8D=A2=E5=B7=A5=E4=BD=9C=E5=8C=BA=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- docs/guides/configuration.md | 3 +- docs/session-persistence-design.md | 5 +- internal/app/bootstrap.go | 2 +- internal/config/config_test.go | 16 +- internal/config/model.go | 2 +- internal/runtime/compact.go | 13 +- internal/runtime/events.go | 6 +- internal/runtime/runtime.go | 115 +++---- internal/runtime/runtime_test.go | 11 +- internal/runtime/workspace_switch.go | 310 +++++++++++++++++++ internal/runtime/workspace_switch_test.go | 166 ++++++++++ internal/session/router.go | 78 +++++ internal/tui/bootstrap/builder_test.go | 4 +- internal/tui/core/app/runtime_event_test.go | 26 ++ internal/tui/core/app/styles.go | 1 - internal/tui/core/app/update.go | 64 +++- internal/tui/core/commands/workspace.go | 54 ++-- internal/tui/core/commands/workspace_test.go | 58 ++-- internal/tui/state/messages.go | 9 +- 20 files changed, 802 insertions(+), 144 deletions(-) create mode 100644 internal/runtime/workspace_switch.go create mode 100644 internal/runtime/workspace_switch_test.go create mode 100644 internal/session/router.go create mode 100644 internal/tui/core/app/runtime_event_test.go diff --git a/README.md b/README.md index 95135439..b1f75039 100644 --- a/README.md +++ b/README.md @@ -204,5 +204,6 @@ go run ./cmd/neocode --workdir /path/to/workspace 说明: - `--workdir` 只影响当前进程,不会写回 `config.yaml` -- 当前工作区会同时用于工具执行根目录与 session 存储分桶 +- 当前工作区会同时影响工具默认执行根目录与 session 作用域;若目录位于 Git 仓库内,session 作用域优先使用仓库根目录 +- 运行中的 `/cwd` 只影响当前进程:同工作区内保留当前会话,跨工作区时切换到目标工作区的 session 分桶并重置为 Draft - session 历史现在按工作区隔离存储,不同工作区默认互不可见 diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 07d3e01b..e5e00c51 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -342,5 +342,6 @@ go run ./cmd/neocode --workdir /path/to/workspace 补充说明: - `--workdir` 只影响本次启动,不会持久化到 `config.yaml` -- 运行时工具根目录与 session 存储分桶都会使用该工作区 +- 运行时工具默认根目录与初始 session 作用域都会从该目录出发;若目录位于 Git 仓库内,则 session 作用域优先使用仓库根目录 +- `/cwd` 只影响当前进程中的工作区上下文,不会回写配置;同工作区内保留当前会话,跨工作区时会切换 session 分桶并重置为 Draft - session 现按工作区隔离存储,不同工作区的历史会话默认互不可见 diff --git a/docs/session-persistence-design.md b/docs/session-persistence-design.md index fb22d26d..81ee4860 100644 --- a/docs/session-persistence-design.md +++ b/docs/session-persistence-design.md @@ -39,6 +39,7 @@ NeoCode 在 MVP 阶段使用 JSON 文件持久化 Session,以保持本地优 ## 工作区隔离 - session 现按工作区隔离存储,目录规则为 `~/.neocode/projects//sessions/` -- 工作区哈希基于启动时确定的工作区根目录生成,而不是基于 `session.Workdir` -- `session.Workdir` 仍表示该会话当前实际执行命令时使用的目录,可被 `/cwd` 修改 +- 工作区哈希基于当前 runtime 持有的 `workspaceRoot` 生成;`workspaceRoot` 优先取目标目录所在 Git 仓库根目录,非 Git 目录则退回目标目录本身 +- `session.Workdir` 仍表示该会话当前实际执行命令时使用的目录;同工作区内 `/cwd` 只更新该字段 +- 当 `/cwd` 跨到新的 `workspaceRoot` 时,runtime 会切换到新的 session 分桶并重置为 Draft,不迁移旧会话历史 - 旧的全局 `~/.neocode/sessions/` 开发期数据不迁移、不回读 diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 29fa456a..50197e1a 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -86,7 +86,7 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er return RuntimeBundle{}, err } - sessionStore := agentsession.NewStore(loader.BaseDir(), cfg.Workdir) + sessionStore := agentsession.NewScopedStoreRouter(loader.BaseDir(), cfg.Workdir) runtimeSvc := agentruntime.NewWithFactory( manager, toolManager, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d2d37542..20dc6c24 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1231,10 +1231,10 @@ func TestDescriptorFromRawModel(t *testing.T) { t.Parallel() tests := []struct { - name string - raw map[string]any - want ModelDescriptor - wantOK bool + name string + raw map[string]any + want ModelDescriptor + wantOK bool }{ { name: "empty map returns false", @@ -1252,10 +1252,10 @@ func TestDescriptorFromRawModel(t *testing.T) { { name: "full descriptor", raw: map[string]any{ - "id": "gpt-4.1", - "display_name": "GPT-4.1", - "description": "desc", - "context_window": 128000, + "id": "gpt-4.1", + "display_name": "GPT-4.1", + "description": "desc", + "context_window": 128000, "max_output_tokens": 16384, }, want: ModelDescriptor{ 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/runtime/compact.go b/internal/runtime/compact.go index 26487f33..be0ab9a3 100644 --- a/internal/runtime/compact.go +++ b/internal/runtime/compact.go @@ -57,9 +57,15 @@ func (s *Service) Compact(ctx context.Context, input CompactInput) (CompactResul s.operationMu.Lock() defer s.operationMu.Unlock() + s.setOperationState("compact") + defer s.clearOperationState() cfg := s.configManager.Get() - session, err := s.sessionStore.Load(ctx, input.SessionID) + store := s.currentSessionStore() + if store == nil { + return CompactResult{}, errors.New("runtime: session store is nil") + } + session, err := store.Load(ctx, input.SessionID) if err != nil { return CompactResult{}, err } @@ -107,10 +113,11 @@ func (s *Service) runCompactForSession( originalMessages := append([]providertypes.Message(nil), session.Messages...) s.emit(ctx, EventCompactStart, runID, session.ID, string(contextcompact.ModeManual)) + workspaceRoot, workspaceWorkdir := s.currentWorkspaceState() result, err := runner.Run(ctx, contextcompact.Input{ Mode: contextcompact.ModeManual, SessionID: session.ID, - Workdir: cfg.Workdir, + Workdir: effectiveSessionWorkdir(session.Workdir, effectiveWorkspaceBase(workspaceWorkdir, workspaceRoot)), Messages: session.Messages, Config: cfg.Context.Compact, }) @@ -128,7 +135,7 @@ func (s *Service) runCompactForSession( if result.Applied { session.Messages = append([]providertypes.Message(nil), result.Messages...) session.UpdatedAt = time.Now() - if err := s.sessionStore.Save(ctx, &session); err != nil { + if err := s.currentSessionStore().Save(ctx, &session); err != nil { s.emit(ctx, EventCompactError, runID, session.ID, CompactErrorPayload{ TriggerMode: string(contextcompact.ModeManual), Message: err.Error(), diff --git a/internal/runtime/events.go b/internal/runtime/events.go index 80117aec..97eadc6a 100644 --- a/internal/runtime/events.go +++ b/internal/runtime/events.go @@ -85,8 +85,8 @@ const ( // TokenUsagePayload carries token usage statistics for a single provider turn. type TokenUsagePayload struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` SessionInputTokens int `json:"session_input_tokens"` SessionOutputTokens int `json:"session_output_tokens"` -} \ No newline at end of file +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index fa0087ec..1d948a7f 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -112,12 +112,12 @@ func (a *streamAccumulator) buildMessage() (providertypes.Message, error) { type Runtime interface { Run(ctx context.Context, input UserInput) error Compact(ctx context.Context, input CompactInput) (CompactResult, error) + SwitchWorkspace(ctx context.Context, input WorkspaceSwitchInput) (WorkspaceSwitchResult, error) ResolvePermission(ctx context.Context, input PermissionResolutionInput) error CancelActiveRun() bool Events() <-chan RuntimeEvent ListSessions(ctx context.Context) ([]agentsession.Summary, error) LoadSession(ctx context.Context, id string) (agentsession.Session, error) - SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) } type UserInput struct { @@ -132,20 +132,26 @@ type ProviderFactory interface { } type Service struct { - configManager *config.Manager // 配置管理器,提供当前选中的 provider、model、workdir 等配置读取能力。 - sessionStore agentsession.Store // 会话持久化接口,负责保存和加载聊天会话。 - toolManager tools.Manager // 工具管理器,统一工具 schema 暴露与执行入口。 - providerFactory ProviderFactory // Provider 工厂接口,根据配置动态创建具体的 provider 实例。 - contextBuilder agentcontext.Builder // 上下文构建器,负责组装 system prompt 与本轮发给模型的消息上下文。 - compactRunner contextcompact.Runner - events chan RuntimeEvent - operationMu sync.Mutex // 运行级互斥:串行化 Run 与 Compact,避免并发写同一会话。 - runMu sync.Mutex // 仅保护 activeRun* 字段的并发读写。 - activeRunToken uint64 // 当前活跃运行的令牌标识,用于标记正在执行的 Run 实例。 - nextRunToken uint64 // 下一个运行令牌的递增计数器,用于区分不同 Run 的生命周期。 + configManager *config.Manager // 配置管理器,提供当前选中的 provider、model、workdir 等配置读取能力。 + sessionStore agentsession.Store // 默认会话持久化接口,兼容未实现工作区路由能力的场景。 + storeProvider workspaceStoreProvider + toolManager tools.Manager // 工具管理器,统一工具 schema 暴露与执行入口。 + providerFactory ProviderFactory // Provider 工厂接口,根据配置动态创建具体的 provider 实例。 + contextBuilder agentcontext.Builder // 上下文构建器,负责组装 system prompt 与本轮发给模型的消息上下文。 + compactRunner contextcompact.Runner + events chan RuntimeEvent + operationMu sync.Mutex // 运行级互斥:串行化 Run 与 Compact,避免并发写同一会话。 + runMu sync.Mutex // 仅保护 activeRun* 字段的并发读写。 + workspaceMu sync.RWMutex + operationStateMu sync.RWMutex + activeRunToken uint64 // 当前活跃运行的令牌标识,用于标记正在执行的 Run 实例。 + nextRunToken uint64 // 下一个运行令牌的递增计数器,用于区分不同 Run 的生命周期。 activeRunCancel context.CancelFunc // 当前活跃 Run 的取消函数。 sessionInputTokens int // 当前会话累计输入 token。 sessionOutputTokens int // 当前会话累计输出 token。 + activeWorkspaceRoot string + activeWorkdir string + activeOperation string } func NewWithFactory( @@ -165,7 +171,7 @@ func NewWithFactory( contextBuilder = agentcontext.NewBuilderWithToolPolicies(toolManager) } - return &Service{ + service := &Service{ configManager: configManager, sessionStore: sessionStore, toolManager: toolManager, @@ -173,11 +179,22 @@ func NewWithFactory( contextBuilder: contextBuilder, events: make(chan RuntimeEvent, 128), } + if provider, ok := sessionStore.(workspaceStoreProvider); ok { + service.storeProvider = provider + } else { + service.storeProvider = fixedWorkspaceStoreProvider{store: sessionStore} + } + if configManager != nil { + service.initializeWorkspaceState(configManager.Get().Workdir) + } + return service } func (s *Service) Run(ctx context.Context, input UserInput) error { s.operationMu.Lock() defer s.operationMu.Unlock() + s.setOperationState("run") + defer s.clearOperationState() runCtx, cancel := context.WithCancel(ctx) runToken := s.startRun(cancel) @@ -191,11 +208,13 @@ 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) + workspaceRoot, workspaceWorkdir := s.currentWorkspaceState() + defaultWorkdir := effectiveWorkspaceBase(workspaceWorkdir, workspaceRoot) + session, err := s.loadOrCreateSession(ctx, input.SessionID, input.Content, defaultWorkdir, input.Workdir) if err != nil { return s.handleRunError(ctx, input.RunID, input.SessionID, err) } + s.setWorkspaceState(workspaceRoot, effectiveSessionWorkdir(session.Workdir, defaultWorkdir)) s.restoreSessionTokens(session) userMessage := providertypes.Message{ @@ -204,7 +223,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { } session.Messages = append(session.Messages, userMessage) session.UpdatedAt = time.Now() - if err := s.sessionStore.Save(ctx, &session); err != nil { + if err := s.currentSessionStore().Save(ctx, &session); err != nil { return s.handleRunError(ctx, input.RunID, session.ID, err) } s.emit(ctx, EventUserMessage, input.RunID, session.ID, userMessage) @@ -217,7 +236,9 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { } cfg := s.configManager.Get() - activeWorkdir := effectiveSessionWorkdir(session.Workdir, cfg.Workdir) + workspaceRoot, workspaceWorkdir := s.currentWorkspaceState() + activeWorkdir := effectiveSessionWorkdir(session.Workdir, effectiveWorkspaceBase(workspaceWorkdir, workspaceRoot)) + s.setWorkspaceState(workspaceRoot, activeWorkdir) maxLoops := cfg.MaxLoops if maxLoops <= 0 { maxLoops = 8 @@ -296,12 +317,12 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { if strings.TrimSpace(assistant.Content) != "" || len(assistant.ToolCalls) > 0 { session.Messages = append(session.Messages, assistant) session.UpdatedAt = time.Now() - if err := s.sessionStore.Save(ctx, &session); err != nil { + if err := s.currentSessionStore().Save(ctx, &session); err != nil { return s.handleRunError(ctx, input.RunID, session.ID, err) } } else if metadataChanged { session.UpdatedAt = time.Now() - if err := s.sessionStore.Save(ctx, &session); err != nil { + if err := s.currentSessionStore().Save(ctx, &session); err != nil { return s.handleRunError(ctx, input.RunID, session.ID, err) } } @@ -351,7 +372,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { } session.Messages = append(session.Messages, toolMessage) session.UpdatedAt = time.Now() - if err := s.sessionStore.Save(ctx, &session); err != nil { + if err := s.currentSessionStore().Save(ctx, &session); err != nil { if execErr != nil && errors.Is(err, context.Canceled) { s.emit(ctx, EventToolResult, input.RunID, session.ID, result) } @@ -390,42 +411,24 @@ func (s *Service) Events() <-chan RuntimeEvent { } func (s *Service) ListSessions(ctx context.Context) ([]agentsession.Summary, error) { - return s.sessionStore.ListSummaries(ctx) -} - -func (s *Service) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { - session, err := s.sessionStore.Load(ctx, id) - if err != nil { - return agentsession.Session{}, err + store := s.currentSessionStore() + if store == nil { + return nil, errors.New("runtime: session store is nil") } - return session, nil + return store.ListSummaries(ctx) } -func (s *Service) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { - sessionID = strings.TrimSpace(sessionID) - if sessionID == "" { - return agentsession.Session{}, errors.New("runtime: session id is empty") - } - - session, err := s.sessionStore.Load(ctx, sessionID) - if err != nil { - return agentsession.Session{}, err +func (s *Service) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { + store := s.currentSessionStore() + if store == nil { + return agentsession.Session{}, errors.New("runtime: session store is nil") } - - cfg := s.configManager.Get() - resolved, err := resolveWorkdirForSession(cfg.Workdir, session.Workdir, workdir) + session, err := store.Load(ctx, id) if err != nil { return agentsession.Session{}, err } - if session.Workdir == resolved { - return session, nil - } - - session.Workdir = resolved - session.UpdatedAt = time.Now() - if err := s.sessionStore.Save(ctx, &session); err != nil { - return agentsession.Session{}, err - } + workspaceRoot, workspaceWorkdir := s.currentWorkspaceState() + s.setWorkspaceState(workspaceRoot, effectiveSessionWorkdir(session.Workdir, effectiveWorkspaceBase(workspaceWorkdir, workspaceRoot))) return session, nil } @@ -436,18 +439,22 @@ func (s *Service) loadOrCreateSession( defaultWorkdir string, requestedWorkdir string, ) (agentsession.Session, error) { + store := s.currentSessionStore() + if store == nil { + return agentsession.Session{}, errors.New("runtime: session store is nil") + } if strings.TrimSpace(sessionID) == "" { sessionWorkdir, err := resolveWorkdirForSession(defaultWorkdir, "", requestedWorkdir) if err != nil { return agentsession.Session{}, err } session := agentsession.NewWithWorkdir(title, sessionWorkdir) - if err := s.sessionStore.Save(ctx, &session); err != nil { + if err := store.Save(ctx, &session); err != nil { return agentsession.Session{}, err } return session, nil } - session, err := s.sessionStore.Load(ctx, sessionID) + session, err := store.Load(ctx, sessionID) if err != nil { return agentsession.Session{}, err } @@ -464,7 +471,7 @@ func (s *Service) loadOrCreateSession( } session.Workdir = resolved session.UpdatedAt = time.Now() - if err := s.sessionStore.Save(ctx, &session); err != nil { + if err := store.Save(ctx, &session); err != nil { return agentsession.Session{}, err } return session, nil @@ -579,8 +586,8 @@ func (s *Service) forwardProviderEvents( s.sessionInputTokens += done.Usage.InputTokens s.sessionOutputTokens += done.Usage.OutputTokens s.emit(ctx, EventTokenUsage, runID, sessionID, TokenUsagePayload{ - InputTokens: done.Usage.InputTokens, - OutputTokens: done.Usage.OutputTokens, + InputTokens: done.Usage.InputTokens, + OutputTokens: done.Usage.OutputTokens, SessionInputTokens: s.sessionInputTokens, SessionOutputTokens: s.sessionOutputTokens, }) diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index fbcc27f0..adb7422c 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -2406,6 +2406,9 @@ func TestServiceRunUsesInputWorkdirForNewSession(t *testing.T) { func TestServiceSetSessionWorkdir(t *testing.T) { manager := newRuntimeConfigManager(t) defaultWorkdir := t.TempDir() + if err := os.MkdirAll(filepath.Join(defaultWorkdir, ".git"), 0o755); err != nil { + t.Fatalf("mkdir git marker: %v", err) + } target := filepath.Join(defaultWorkdir, "sub") if err := os.MkdirAll(target, 0o755); err != nil { t.Fatalf("mkdir target: %v", err) @@ -2690,6 +2693,9 @@ func TestWorkdirHelperFunctions(t *testing.T) { func TestServiceSetSessionWorkdirNoopDoesNotSave(t *testing.T) { manager := newRuntimeConfigManager(t) defaultWorkdir := t.TempDir() + if err := os.MkdirAll(filepath.Join(defaultWorkdir, ".git"), 0o755); err != nil { + t.Fatalf("mkdir git marker: %v", err) + } if err := manager.Update(context.Background(), func(cfg *config.Config) error { cfg.Workdir = defaultWorkdir return nil @@ -2698,7 +2704,10 @@ func TestServiceSetSessionWorkdirNoopDoesNotSave(t *testing.T) { } store := newMemoryStore() - target := t.TempDir() + target := filepath.Join(defaultWorkdir, "sub") + 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() diff --git a/internal/runtime/workspace_switch.go b/internal/runtime/workspace_switch.go new file mode 100644 index 00000000..5e78f393 --- /dev/null +++ b/internal/runtime/workspace_switch.go @@ -0,0 +1,310 @@ +package runtime + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + goruntime "runtime" + "strings" + "time" + + agentsession "neo-code/internal/session" +) + +// WorkspaceSwitchInput 描述一次工作区切换请求。 +type WorkspaceSwitchInput struct { + SessionID string + RequestedPath string +} + +// WorkspaceSwitchResult 汇总一次工作区切换后的可见状态。 +type WorkspaceSwitchResult struct { + WorkspaceRoot string + Workdir string + WorkspaceChanged bool + KeepSession bool + ResetToDraft bool + Session agentsession.Session +} + +var workspaceGitRootResolver = detectWorkspaceGitRoot + +// workspaceStoreProvider 描述 runtime 所需的按工作区获取会话存储能力。 +type workspaceStoreProvider interface { + StoreForWorkspace(workspaceRoot string) agentsession.Store +} + +// fixedWorkspaceStoreProvider 让单一 store 也能适配按工作区路由的读取逻辑。 +type fixedWorkspaceStoreProvider struct { + store agentsession.Store +} + +// StoreForWorkspace 返回固定的单一会话存储实例。 +func (p fixedWorkspaceStoreProvider) StoreForWorkspace(string) agentsession.Store { + return p.store +} + +// effectiveWorkspaceBase 返回解析目标路径时应使用的当前目录。 +func effectiveWorkspaceBase(currentWorkdir string, workspaceRoot string) string { + base := strings.TrimSpace(currentWorkdir) + if base != "" { + return base + } + return strings.TrimSpace(workspaceRoot) +} + +// resolveWorkspaceSelection 解析目标目录并推导所属工作区根目录。 +func resolveWorkspaceSelection(ctx context.Context, baseWorkdir string, requestedPath string) (string, string, error) { + workdir, err := resolveRequestedWorkspacePath(baseWorkdir, requestedPath) + if err != nil { + return "", "", err + } + + root := workdir + if resolver := workspaceGitRootResolver; resolver != nil { + if gitRoot, resolveErr := resolver(ctx, workdir); resolveErr == nil && strings.TrimSpace(gitRoot) != "" { + root = gitRoot + } + } + + root, err = normalizeExistingWorkdir(root) + if err != nil { + return "", "", err + } + return workdir, root, nil +} + +// resolveRequestedWorkspacePath 将请求路径解析为存在的绝对目录。 +func resolveRequestedWorkspacePath(baseWorkdir string, requestedPath string) (string, error) { + base, err := normalizeExistingWorkdir(baseWorkdir) + if err != nil { + return "", err + } + if strings.TrimSpace(requestedPath) == "" { + return base, nil + } + + target := strings.TrimSpace(requestedPath) + if !filepath.IsAbs(target) { + target = filepath.Join(base, target) + } + return normalizeExistingWorkdir(target) +} + +// detectWorkspaceGitRoot 尝试解析目录所属 Git 仓库根目录。 +func detectWorkspaceGitRoot(ctx context.Context, workdir string) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + current, err := normalizeExistingWorkdir(workdir) + if err != nil { + return "", err + } + for { + gitMarker := filepath.Join(current, ".git") + if _, statErr := os.Stat(gitMarker); statErr == nil { + if isFilesystemRoot(current) { + break + } + return current, nil + } + + parent := filepath.Dir(current) + if parent == current || sameWorkspacePath(parent, current) { + break + } + current = parent + } + + return "", errors.New("runtime: directory is not inside git work tree") +} + +// isFilesystemRoot 判断路径是否已经位于文件系统根目录。 +func isFilesystemRoot(path string) bool { + cleaned := filepath.Clean(strings.TrimSpace(path)) + if cleaned == "" { + return false + } + return filepath.Dir(cleaned) == cleaned +} + +// sameWorkspacePath 判断两个工作区路径在当前平台上是否表示同一目录。 +func sameWorkspacePath(left string, right string) bool { + normalizedLeft := normalizeWorkspacePathKey(left) + normalizedRight := normalizeWorkspacePathKey(right) + return normalizedLeft != "" && normalizedLeft == normalizedRight +} + +// normalizeWorkspacePathKey 生成用于比较工作区路径的稳定键。 +func normalizeWorkspacePathKey(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 +} + +// currentWorkspaceState 读取 runtime 当前持有的工作区根目录和工作目录。 +func (s *Service) currentWorkspaceState() (string, string) { + s.workspaceMu.RLock() + defer s.workspaceMu.RUnlock() + return strings.TrimSpace(s.activeWorkspaceRoot), strings.TrimSpace(s.activeWorkdir) +} + +// setWorkspaceState 更新 runtime 当前持有的工作区根目录和工作目录。 +func (s *Service) setWorkspaceState(workspaceRoot string, workdir string) { + s.workspaceMu.Lock() + defer s.workspaceMu.Unlock() + s.activeWorkspaceRoot = strings.TrimSpace(workspaceRoot) + s.activeWorkdir = strings.TrimSpace(workdir) +} + +// currentSessionStore 返回当前工作区作用域下的会话存储实例。 +func (s *Service) currentSessionStore() agentsession.Store { + workspaceRoot, _ := s.currentWorkspaceState() + return s.sessionStoreForWorkspace(workspaceRoot) +} + +// sessionStoreForWorkspace 返回指定工作区根目录下的会话存储实例。 +func (s *Service) sessionStoreForWorkspace(workspaceRoot string) agentsession.Store { + if s.storeProvider == nil { + return nil + } + return s.storeProvider.StoreForWorkspace(workspaceRoot) +} + +// setOperationState 记录当前正在执行的运行态操作类型。 +func (s *Service) setOperationState(name string) { + s.operationStateMu.Lock() + defer s.operationStateMu.Unlock() + s.activeOperation = strings.TrimSpace(name) +} + +// clearOperationState 清空当前运行态操作标记。 +func (s *Service) clearOperationState() { + s.operationStateMu.Lock() + defer s.operationStateMu.Unlock() + s.activeOperation = "" +} + +// currentOperationState 返回当前运行态操作标记。 +func (s *Service) currentOperationState() string { + s.operationStateMu.RLock() + defer s.operationStateMu.RUnlock() + return strings.TrimSpace(s.activeOperation) +} + +// initializeWorkspaceState 以配置中的启动目录初始化当前工作区状态。 +func (s *Service) initializeWorkspaceState(workdir string) { + trimmed := strings.TrimSpace(workdir) + if trimmed == "" { + return + } + + root := trimmed + if resolvedWorkdir, resolvedRoot, err := resolveWorkspaceSelection(context.Background(), trimmed, ""); err == nil { + trimmed = resolvedWorkdir + root = resolvedRoot + } + s.setWorkspaceState(root, trimmed) +} + +// SwitchWorkspace 根据请求路径更新当前工作区上下文,并决定是否重置为草稿态。 +func (s *Service) SwitchWorkspace(ctx context.Context, input WorkspaceSwitchInput) (WorkspaceSwitchResult, error) { + if err := ctx.Err(); err != nil { + return WorkspaceSwitchResult{}, err + } + + currentRoot, currentWorkdir := s.currentWorkspaceState() + baseWorkdir := effectiveWorkspaceBase(currentWorkdir, currentRoot) + result := WorkspaceSwitchResult{} + + var session agentsession.Session + if sessionID := strings.TrimSpace(input.SessionID); sessionID != "" { + store := s.currentSessionStore() + if store == nil { + return WorkspaceSwitchResult{}, errors.New("runtime: session store is nil") + } + loaded, err := store.Load(ctx, sessionID) + if err != nil { + return WorkspaceSwitchResult{}, err + } + session = loaded + baseWorkdir = effectiveSessionWorkdir(session.Workdir, baseWorkdir) + } + + workdir, workspaceRoot, err := resolveWorkspaceSelection(ctx, baseWorkdir, input.RequestedPath) + if err != nil { + return WorkspaceSwitchResult{}, err + } + + workspaceChanged := !sameWorkspacePath(currentRoot, workspaceRoot) + if workspaceChanged && s.currentOperationState() != "" { + return WorkspaceSwitchResult{}, fmt.Errorf("runtime: cannot switch workspace while %s is running", s.currentOperationState()) + } + + result.Workdir = workdir + result.WorkspaceRoot = workspaceRoot + result.WorkspaceChanged = workspaceChanged + + if workspaceChanged { + s.setWorkspaceState(workspaceRoot, workdir) + result.ResetToDraft = true + return result, nil + } + + s.setWorkspaceState(workspaceRoot, workdir) + if strings.TrimSpace(session.ID) == "" { + return result, nil + } + + result.KeepSession = true + if session.Workdir == workdir { + result.Session = session + return result, nil + } + + store := s.currentSessionStore() + if store == nil { + return WorkspaceSwitchResult{}, errors.New("runtime: session store is nil") + } + session.Workdir = workdir + session.UpdatedAt = time.Now() + if err := store.Save(ctx, &session); err != nil { + return WorkspaceSwitchResult{}, err + } + result.Session = session + return result, nil +} + +// SetSessionWorkdir 兼容旧测试入口,并复用新的工作区切换语义。 +func (s *Service) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return agentsession.Session{}, errors.New("runtime: session id is empty") + } + + result, err := s.SwitchWorkspace(ctx, WorkspaceSwitchInput{ + SessionID: sessionID, + RequestedPath: workdir, + }) + if err != nil { + return agentsession.Session{}, err + } + if result.ResetToDraft { + return agentsession.Session{}, errors.New("runtime: workspace switch reset current session") + } + return result.Session, nil +} diff --git a/internal/runtime/workspace_switch_test.go b/internal/runtime/workspace_switch_test.go new file mode 100644 index 00000000..3a7cc1b9 --- /dev/null +++ b/internal/runtime/workspace_switch_test.go @@ -0,0 +1,166 @@ +package runtime + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "neo-code/internal/config" + agentsession "neo-code/internal/session" + "neo-code/internal/tools" +) + +func TestServiceSwitchWorkspaceScopesSessionsByWorkspace(t *testing.T) { + manager := newRuntimeConfigManager(t) + workspaceA := t.TempDir() + workspaceB := t.TempDir() + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Workdir = workspaceA + return nil + }); err != nil { + t.Fatalf("update workdir: %v", err) + } + + router := agentsession.NewScopedStoreRouter(t.TempDir(), workspaceA) + storeA := router.StoreForWorkspace(workspaceA) + storeB := router.StoreForWorkspace(workspaceB) + + sessionA := agentsession.NewWithWorkdir("workspace-a", workspaceA) + sessionB := agentsession.NewWithWorkdir("workspace-b", workspaceB) + if err := storeA.Save(context.Background(), &sessionA); err != nil { + t.Fatalf("save workspace A session: %v", err) + } + if err := storeB.Save(context.Background(), &sessionB); err != nil { + t.Fatalf("save workspace B session: %v", err) + } + + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + service := NewWithFactory(manager, registry, router, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + + summaries, err := service.ListSessions(context.Background()) + if err != nil { + t.Fatalf("ListSessions() error = %v", err) + } + if len(summaries) != 1 || summaries[0].ID != sessionA.ID { + t.Fatalf("expected workspace A summaries, got %+v", summaries) + } + + result, err := service.SwitchWorkspace(context.Background(), WorkspaceSwitchInput{RequestedPath: workspaceB}) + if err != nil { + t.Fatalf("SwitchWorkspace() error = %v", err) + } + if !result.WorkspaceChanged || !result.ResetToDraft { + t.Fatalf("expected cross-workspace switch to reset draft, got %+v", result) + } + if result.WorkspaceRoot != filepath.Clean(workspaceB) { + t.Fatalf("expected workspace root %q, got %q", filepath.Clean(workspaceB), result.WorkspaceRoot) + } + + summaries, err = service.ListSessions(context.Background()) + if err != nil { + t.Fatalf("ListSessions() after switch error = %v", err) + } + if len(summaries) != 1 || summaries[0].ID != sessionB.ID { + t.Fatalf("expected workspace B summaries, got %+v", summaries) + } + + if _, err := service.LoadSession(context.Background(), sessionA.ID); err == nil { + t.Fatalf("expected previous workspace session to be hidden after switch") + } +} + +func TestServiceSwitchWorkspaceDraftDoesNotCreateSession(t *testing.T) { + manager := newRuntimeConfigManager(t) + workspaceA := t.TempDir() + workspaceB := t.TempDir() + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Workdir = workspaceA + return nil + }); err != nil { + t.Fatalf("update workdir: %v", err) + } + + router := agentsession.NewScopedStoreRouter(t.TempDir(), workspaceA) + service := NewWithFactory(manager, tools.NewRegistry(), router, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + + result, err := service.SwitchWorkspace(context.Background(), WorkspaceSwitchInput{RequestedPath: workspaceB}) + if err != nil { + t.Fatalf("SwitchWorkspace() error = %v", err) + } + if !result.ResetToDraft { + t.Fatalf("expected draft workspace switch to stay in draft, got %+v", result) + } + + summaries, err := service.ListSessions(context.Background()) + if err != nil { + t.Fatalf("ListSessions() error = %v", err) + } + if len(summaries) != 0 { + t.Fatalf("expected no sessions created during draft workspace switch, got %+v", summaries) + } +} + +func TestServiceSwitchWorkspacePrefersGitRoot(t *testing.T) { + manager := newRuntimeConfigManager(t) + workspaceA := t.TempDir() + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Workdir = workspaceA + return nil + }); err != nil { + t.Fatalf("update workdir: %v", err) + } + + repoRoot := t.TempDir() + subdir := filepath.Join(repoRoot, "pkg", "child") + if err := os.MkdirAll(subdir, 0o755); err != nil { + t.Fatalf("mkdir subdir: %v", err) + } + if output, err := runGitCommand(t, repoRoot, "init"); err != nil { + t.Fatalf("git init: %v (%s)", err, output) + } + + service := NewWithFactory(manager, tools.NewRegistry(), agentsession.NewScopedStoreRouter(t.TempDir(), workspaceA), &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + result, err := service.SwitchWorkspace(context.Background(), WorkspaceSwitchInput{RequestedPath: subdir}) + if err != nil { + t.Fatalf("SwitchWorkspace() error = %v", err) + } + if result.WorkspaceRoot != filepath.Clean(repoRoot) { + t.Fatalf("expected git root %q, got %q", filepath.Clean(repoRoot), result.WorkspaceRoot) + } + if result.Workdir != filepath.Clean(subdir) { + t.Fatalf("expected workdir %q, got %q", filepath.Clean(subdir), result.Workdir) + } +} + +func TestServiceSwitchWorkspaceRejectsCrossWorkspaceWhileBusy(t *testing.T) { + manager := newRuntimeConfigManager(t) + workspaceA := t.TempDir() + workspaceB := t.TempDir() + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Workdir = workspaceA + return nil + }); err != nil { + t.Fatalf("update workdir: %v", err) + } + + service := NewWithFactory(manager, tools.NewRegistry(), agentsession.NewScopedStoreRouter(t.TempDir(), workspaceA), &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + service.setOperationState("run") + defer service.clearOperationState() + + _, err := service.SwitchWorkspace(context.Background(), WorkspaceSwitchInput{RequestedPath: workspaceB}) + if err == nil || !strings.Contains(err.Error(), "cannot switch workspace while run is running") { + t.Fatalf("expected busy switch rejection, got %v", err) + } +} + +func runGitCommand(t *testing.T, workdir string, args ...string) (string, error) { + t.Helper() + + command := exec.Command("git", append([]string{"-C", workdir}, args...)...) + output, err := command.CombinedOutput() + return string(output), err +} diff --git a/internal/session/router.go b/internal/session/router.go new file mode 100644 index 00000000..60866eca --- /dev/null +++ b/internal/session/router.go @@ -0,0 +1,78 @@ +package session + +import ( + "context" + "sync" +) + +// WorkspaceStoreProvider 定义按工作区根目录路由会话存储的能力。 +type WorkspaceStoreProvider interface { + StoreForWorkspace(workspaceRoot string) Store +} + +// ScopedStoreRouter 基于工作区根目录缓存并复用具体的会话存储实例。 +type ScopedStoreRouter struct { + mu sync.Mutex + baseDir string + defaultRoot string + stores map[string]Store +} + +// NewScopedStoreRouter 创建一个按工作区根目录分桶的会话存储路由器。 +func NewScopedStoreRouter(baseDir string, defaultRoot string) *ScopedStoreRouter { + return &ScopedStoreRouter{ + baseDir: baseDir, + defaultRoot: normalizeWorkspaceRoot(defaultRoot), + stores: make(map[string]Store), + } +} + +// StoreForWorkspace 返回指定工作区根目录对应的会话存储实例。 +func (r *ScopedStoreRouter) StoreForWorkspace(workspaceRoot string) Store { + if r == nil { + return nil + } + + normalized := normalizeWorkspaceRoot(workspaceRoot) + if normalized == "" { + normalized = r.defaultRoot + } + + r.mu.Lock() + defer r.mu.Unlock() + + if store, ok := r.stores[normalized]; ok { + return store + } + + store := NewJSONStore(r.baseDir, normalized) + r.stores[normalized] = store + return store +} + +// Save 使用默认工作区根目录对应的存储执行保存。 +func (r *ScopedStoreRouter) Save(ctx context.Context, session *Session) error { + store := r.StoreForWorkspace(r.defaultRoot) + if store == nil { + return nil + } + return store.Save(ctx, session) +} + +// Load 使用默认工作区根目录对应的存储执行读取。 +func (r *ScopedStoreRouter) Load(ctx context.Context, id string) (Session, error) { + store := r.StoreForWorkspace(r.defaultRoot) + if store == nil { + return Session{}, nil + } + return store.Load(ctx, id) +} + +// ListSummaries 使用默认工作区根目录对应的存储列出会话摘要。 +func (r *ScopedStoreRouter) ListSummaries(ctx context.Context) ([]Summary, error) { + store := r.StoreForWorkspace(r.defaultRoot) + if store == nil { + return nil, nil + } + return store.ListSummaries(ctx) +} diff --git a/internal/tui/bootstrap/builder_test.go b/internal/tui/bootstrap/builder_test.go index 71a9cf06..64d334d0 100644 --- a/internal/tui/bootstrap/builder_test.go +++ b/internal/tui/bootstrap/builder_test.go @@ -41,8 +41,8 @@ func (r *testRuntime) LoadSession(ctx context.Context, id string) (agentsession. return agentsession.Session{}, nil } -func (r *testRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { - return agentsession.Session{}, nil +func (r *testRuntime) SwitchWorkspace(ctx context.Context, input agentruntime.WorkspaceSwitchInput) (agentruntime.WorkspaceSwitchResult, error) { + return agentruntime.WorkspaceSwitchResult{}, nil } type testProviderService struct{} diff --git a/internal/tui/core/app/runtime_event_test.go b/internal/tui/core/app/runtime_event_test.go new file mode 100644 index 00000000..c4fe0254 --- /dev/null +++ b/internal/tui/core/app/runtime_event_test.go @@ -0,0 +1,26 @@ +package tui + +import ( + "testing" + + agentruntime "neo-code/internal/runtime" +) + +func TestHandleRuntimeEventIgnoresStaleRunAndSession(t *testing.T) { + app := App{} + app.state.ActiveSessionID = "session-current" + app.state.ActiveRunID = "run-current" + + dirty := app.handleRuntimeEvent(agentruntime.RuntimeEvent{ + Type: agentruntime.EventAgentChunk, + RunID: "run-stale", + SessionID: "session-stale", + Payload: "stale chunk", + }) + if dirty { + t.Fatalf("expected stale runtime event to be ignored") + } + if len(app.activeMessages) != 0 { + t.Fatalf("expected stale runtime event to leave transcript untouched, got %+v", app.activeMessages) + } +} diff --git a/internal/tui/core/app/styles.go b/internal/tui/core/app/styles.go index 15262a71..83c44a47 100644 --- a/internal/tui/core/app/styles.go +++ b/internal/tui/core/app/styles.go @@ -325,4 +325,3 @@ func preview(text string, width int, lines int) string { } return joined } - diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index e9face16..9f63fd5e 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -173,15 +173,27 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Batch(cmds...) } + if typed.ResetToDraft { + a.startDraftSessionWithWorkdir(typed.Workdir) + if err := a.refreshSessions(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("workspace", "Failed to refresh workspace sessions", err.Error(), true) + return a, tea.Batch(cmds...) + } + } else { + a.state.ExecutionError = "" + a.state.StatusText = typed.Notice + a.state.CurrentWorkdir = strings.TrimSpace(typed.Workdir) + if err := a.refreshFileCandidates(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("workspace", "Failed to refresh workspace files", err.Error(), true) + return a, tea.Batch(cmds...) + } + } a.state.ExecutionError = "" a.state.StatusText = typed.Notice - a.state.CurrentWorkdir = strings.TrimSpace(typed.Workdir) - if err := a.refreshFileCandidates(); err != nil { - a.state.ExecutionError = err.Error() - a.state.StatusText = err.Error() - a.appendActivity("workspace", "Failed to refresh workspace files", err.Error(), true) - return a, tea.Batch(cmds...) - } a.appendActivity("workspace", typed.Notice, "", false) return a, tea.Batch(cmds...) case workspaceCommandResultMsg: @@ -732,6 +744,9 @@ var runtimeEventHandlerRegistry = map[agentruntime.EventType]func(*App, agentrun // handleRuntimeEvent 通过注册表分发 runtime 事件,避免巨型 switch 膨胀。 func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) bool { + if !a.shouldHandleRuntimeEvent(event) { + return false + } if a.state.ActiveSessionID == "" { a.state.ActiveSessionID = event.SessionID } @@ -742,6 +757,23 @@ func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) bool { return handler(a, event) } +// shouldHandleRuntimeEvent 判断事件是否仍属于当前激活的会话或运行。 +func (a *App) shouldHandleRuntimeEvent(event agentruntime.RuntimeEvent) bool { + activeRunID := strings.TrimSpace(a.state.ActiveRunID) + eventRunID := strings.TrimSpace(event.RunID) + if activeRunID != "" && eventRunID != "" && !strings.EqualFold(activeRunID, eventRunID) { + return false + } + + activeSessionID := strings.TrimSpace(a.state.ActiveSessionID) + eventSessionID := strings.TrimSpace(event.SessionID) + if activeSessionID != "" && eventSessionID != "" && !strings.EqualFold(activeSessionID, eventSessionID) { + return false + } + + return true +} + // runtimeEventUserMessageHandler 处理用户消息进入运行队列后的状态同步。 func runtimeEventUserMessageHandler(a *App, event agentruntime.RuntimeEvent) bool { if strings.TrimSpace(event.RunID) != "" { @@ -1510,6 +1542,11 @@ func (a App) currentStatusSnapshot() tuistatus.Snapshot { } func (a *App) startDraftSession() { + a.startDraftSessionWithWorkdir("") +} + +// startDraftSessionWithWorkdir 重置当前界面为草稿态,并可指定新的工作目录。 +func (a *App) startDraftSessionWithWorkdir(workdir string) { a.state.ActiveSessionID = "" a.state.ActiveSessionTitle = draftSessionTitle a.activeMessages = nil @@ -1525,7 +1562,7 @@ func (a *App) startDraftSession() { a.clearRunProgress() a.input.Reset() a.state.InputText = "" - a.state.CurrentWorkdir = a.configManager.Get().Workdir + a.state.CurrentWorkdir = tuiutils.Fallback(strings.TrimSpace(workdir), a.configManager.Get().Workdir) if err := a.refreshFileCandidates(); err != nil { a.state.ExecutionError = err.Error() a.appendActivity("workspace", "Failed to refresh workspace files", err.Error(), true) @@ -1580,13 +1617,14 @@ func runSessionWorkdirCommand( currentWorkdir, raw, parseWorkspaceSlashCommand, - tuiworkspace.ResolveWorkspacePath, - tuiworkspace.SelectSessionWorkdir, ) return sessionWorkdirResultMsg{ - Notice: result.Notice, - Workdir: result.Workdir, - Err: result.Err, + Notice: result.Notice, + Workdir: result.Workdir, + WorkspaceRoot: result.WorkspaceRoot, + WorkspaceChanged: result.WorkspaceChanged, + ResetToDraft: result.ResetToDraft, + Err: result.Err, } } } diff --git a/internal/tui/core/commands/workspace.go b/internal/tui/core/commands/workspace.go index 08be31d1..1043515c 100644 --- a/internal/tui/core/commands/workspace.go +++ b/internal/tui/core/commands/workspace.go @@ -5,30 +5,31 @@ import ( "fmt" "strings" - agentsession "neo-code/internal/session" + agentruntime "neo-code/internal/runtime" ) -// SessionWorkdirSetter 定义设置会话工作目录所需的最小 runtime 能力。 -type SessionWorkdirSetter interface { - SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) +// WorkspaceSwitcher 定义切换当前工作区上下文所需的最小 runtime 能力。 +type WorkspaceSwitcher interface { + SwitchWorkspace(ctx context.Context, input agentruntime.WorkspaceSwitchInput) (agentruntime.WorkspaceSwitchResult, error) } // SessionWorkdirCommandResult 表示工作目录命令执行结果。 type SessionWorkdirCommandResult struct { - Notice string - Workdir string - Err error + Notice string + Workdir string + WorkspaceRoot string + WorkspaceChanged bool + ResetToDraft bool + Err error } // ExecuteSessionWorkdirCommand 执行 /cwd 命令的核心流程,返回统一结果结构。 func ExecuteSessionWorkdirCommand( - runtime SessionWorkdirSetter, + runtime WorkspaceSwitcher, sessionID string, currentWorkdir string, raw string, parseCommand func(string) (string, error), - resolveWorkspacePath func(string, string) (string, error), - selectSessionWorkdir func(string, string) string, ) SessionWorkdirCommandResult { requested, err := parseCommand(raw) if err != nil { @@ -46,25 +47,26 @@ func ExecuteSessionWorkdirCommand( } } - if strings.TrimSpace(sessionID) == "" { - workdir, err := resolveWorkspacePath(currentWorkdir, requested) - if err != nil { - return SessionWorkdirCommandResult{Err: err} - } - return SessionWorkdirCommandResult{ - Notice: fmt.Sprintf("[System] Draft workspace switched to %s.", workdir), - Workdir: workdir, - } - } - - session, err := runtime.SetSessionWorkdir(context.Background(), sessionID, requested) + result, err := runtime.SwitchWorkspace(context.Background(), agentruntime.WorkspaceSwitchInput{ + SessionID: strings.TrimSpace(sessionID), + RequestedPath: requested, + }) if err != nil { return SessionWorkdirCommandResult{Err: err} } - - workdir := selectSessionWorkdir(session.Workdir, currentWorkdir) + notice := fmt.Sprintf("[System] Draft workspace switched to %s.", result.Workdir) + if strings.TrimSpace(sessionID) != "" { + if result.ResetToDraft { + notice = fmt.Sprintf("[System] Workspace switched to %s. Started a new draft in the target workspace.", result.Workdir) + } else { + notice = fmt.Sprintf("[System] Session workspace switched to %s.", result.Workdir) + } + } return SessionWorkdirCommandResult{ - Notice: fmt.Sprintf("[System] Session workspace switched to %s.", workdir), - Workdir: workdir, + Notice: notice, + Workdir: result.Workdir, + WorkspaceRoot: result.WorkspaceRoot, + WorkspaceChanged: result.WorkspaceChanged, + ResetToDraft: result.ResetToDraft, } } diff --git a/internal/tui/core/commands/workspace_test.go b/internal/tui/core/commands/workspace_test.go index 4ed48934..490db345 100644 --- a/internal/tui/core/commands/workspace_test.go +++ b/internal/tui/core/commands/workspace_test.go @@ -3,27 +3,24 @@ package commands import ( "context" "errors" - "os" - "path/filepath" "strings" "testing" - agentsession "neo-code/internal/session" - tuiworkspace "neo-code/internal/tui/core/workspace" + agentruntime "neo-code/internal/runtime" ) type stubSessionWorkdirSetter struct { - session agentsession.Session - err error - calls int + result agentruntime.WorkspaceSwitchResult + err error + calls int } -func (s *stubSessionWorkdirSetter) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { +func (s *stubSessionWorkdirSetter) SwitchWorkspace(ctx context.Context, input agentruntime.WorkspaceSwitchInput) (agentruntime.WorkspaceSwitchResult, error) { s.calls++ if s.err != nil { - return agentsession.Session{}, s.err + return agentruntime.WorkspaceSwitchResult{}, s.err } - return s.session, nil + return s.result, nil } func TestExecuteSessionWorkdirCommand(t *testing.T) { @@ -42,14 +39,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) 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) if result.Err == nil || !strings.Contains(result.Err.Error(), "usage: /cwd ") { t.Fatalf("expected usage error, got %+v", result) } @@ -57,7 +54,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, "/cwd", parse) if result.Err != nil { t.Fatalf("unexpected error: %v", result.Err) } @@ -68,11 +65,8 @@ 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(target); err != nil { - t.Fatalf("mkdir target: %v", err) - } - result := ExecuteSessionWorkdirCommand(&stubSessionWorkdirSetter{}, "", base, "/cwd sub", parse, tuiworkspace.ResolveWorkspacePath, tuiworkspace.SelectSessionWorkdir) + stub := &stubSessionWorkdirSetter{result: agentruntime.WorkspaceSwitchResult{Workdir: base}} + result := ExecuteSessionWorkdirCommand(stub, "", base, "/cwd sub", parse) if result.Err != nil { t.Fatalf("unexpected error: %v", result.Err) } @@ -83,7 +77,7 @@ 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) + result := ExecuteSessionWorkdirCommand(stub, "session-1", t.TempDir(), "/cwd sub", parse) if result.Err == nil || !strings.Contains(result.Err.Error(), "set workdir failed") { t.Fatalf("expected runtime error, got %+v", result) } @@ -91,8 +85,8 @@ func TestExecuteSessionWorkdirCommand(t *testing.T) { t.Run("runtime empty workdir fallback", func(t *testing.T) { current := t.TempDir() - stub := &stubSessionWorkdirSetter{session: agentsession.Session{ID: "session-1", Workdir: ""}} - result := ExecuteSessionWorkdirCommand(stub, "session-1", current, "/cwd sub", parse, tuiworkspace.ResolveWorkspacePath, tuiworkspace.SelectSessionWorkdir) + stub := &stubSessionWorkdirSetter{result: agentruntime.WorkspaceSwitchResult{Workdir: current}} + result := ExecuteSessionWorkdirCommand(stub, "session-1", current, "/cwd sub", parse) if result.Err != nil { t.Fatalf("unexpected error: %v", result.Err) } @@ -100,8 +94,24 @@ func TestExecuteSessionWorkdirCommand(t *testing.T) { t.Fatalf("expected fallback workdir %q, got %q", current, result.Workdir) } }) -} -func ensureDir(path string) error { - return os.MkdirAll(path, 0o755) + t.Run("cross workspace resets to draft", func(t *testing.T) { + workdir := t.TempDir() + stub := &stubSessionWorkdirSetter{result: agentruntime.WorkspaceSwitchResult{ + Workdir: workdir, + WorkspaceRoot: workdir, + WorkspaceChanged: true, + ResetToDraft: true, + }} + result := ExecuteSessionWorkdirCommand(stub, "session-1", t.TempDir(), "/cwd "+workdir, parse) + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + if !result.ResetToDraft || !result.WorkspaceChanged { + t.Fatalf("expected reset-to-draft result, got %+v", result) + } + if !strings.Contains(result.Notice, "Started a new draft") { + t.Fatalf("unexpected notice: %q", result.Notice) + } + }) } diff --git a/internal/tui/state/messages.go b/internal/tui/state/messages.go index 9016281d..aa67e7ed 100644 --- a/internal/tui/state/messages.go +++ b/internal/tui/state/messages.go @@ -40,9 +40,12 @@ type LocalCommandResultMsg struct { // SessionWorkdirResultMsg 表示会话工作目录命令结果。 type SessionWorkdirResultMsg struct { - Notice string - Workdir string - Err error + Notice string + Workdir string + WorkspaceRoot string + WorkspaceChanged bool + ResetToDraft bool + Err error } // WorkspaceCommandResultMsg 表示工作区命令执行结果。 From 1f152a5397727821999a105628c185a80a0fe4b9 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Thu, 9 Apr 2026 11:43:07 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E5=88=87=E6=8D=A2=E5=90=8E=E7=9A=84=E4=B8=B2=E5=8F=B0?= =?UTF-8?q?=E4=B8=8E=20Git=20=E6=A0=B9=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/runtime/runtime_test.go | 21 +++++++++++----- internal/runtime/workspace_switch.go | 28 ++++++++++----------- internal/tui/core/app/runtime_event_test.go | 20 +++++++++++++++ internal/tui/core/app/update.go | 7 ++++-- 4 files changed, 53 insertions(+), 23 deletions(-) diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index adb7422c..e2896f1a 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -2405,14 +2406,18 @@ func TestServiceRunUsesInputWorkdirForNewSession(t *testing.T) { func TestServiceSetSessionWorkdir(t *testing.T) { manager := newRuntimeConfigManager(t) - defaultWorkdir := t.TempDir() - if err := os.MkdirAll(filepath.Join(defaultWorkdir, ".git"), 0o755); err != nil { - t.Fatalf("mkdir git marker: %v", err) + repoRoot := t.TempDir() + defaultWorkdir := filepath.Join(repoRoot, "app") + if err := os.MkdirAll(defaultWorkdir, 0o755); err != nil { + t.Fatalf("mkdir default workdir: %v", err) } target := filepath.Join(defaultWorkdir, "sub") if err := os.MkdirAll(target, 0o755); err != nil { t.Fatalf("mkdir target: %v", err) } + if output, err := exec.Command("git", "-C", repoRoot, "init").CombinedOutput(); err != nil { + t.Fatalf("git init: %v (%s)", err, string(output)) + } if err := manager.Update(context.Background(), func(cfg *config.Config) error { cfg.Workdir = defaultWorkdir return nil @@ -2692,9 +2697,10 @@ func TestWorkdirHelperFunctions(t *testing.T) { func TestServiceSetSessionWorkdirNoopDoesNotSave(t *testing.T) { manager := newRuntimeConfigManager(t) - defaultWorkdir := t.TempDir() - if err := os.MkdirAll(filepath.Join(defaultWorkdir, ".git"), 0o755); err != nil { - t.Fatalf("mkdir git marker: %v", err) + repoRoot := t.TempDir() + defaultWorkdir := filepath.Join(repoRoot, "app") + if err := os.MkdirAll(defaultWorkdir, 0o755); err != nil { + t.Fatalf("mkdir default workdir: %v", err) } if err := manager.Update(context.Background(), func(cfg *config.Config) error { cfg.Workdir = defaultWorkdir @@ -2708,6 +2714,9 @@ func TestServiceSetSessionWorkdirNoopDoesNotSave(t *testing.T) { if err := os.MkdirAll(target, 0o755); err != nil { t.Fatalf("mkdir target: %v", err) } + if output, err := exec.Command("git", "-C", repoRoot, "init").CombinedOutput(); err != nil { + t.Fatalf("git init: %v (%s)", err, string(output)) + } session := agentsession.NewWithWorkdir("noop", target) store.sessions[session.ID] = cloneSession(session) registry := tools.NewRegistry() diff --git a/internal/runtime/workspace_switch.go b/internal/runtime/workspace_switch.go index 5e78f393..92a76967 100644 --- a/internal/runtime/workspace_switch.go +++ b/internal/runtime/workspace_switch.go @@ -4,7 +4,7 @@ import ( "context" "errors" "fmt" - "os" + "os/exec" "path/filepath" goruntime "runtime" "strings" @@ -103,23 +103,21 @@ func detectWorkspaceGitRoot(ctx context.Context, workdir string) (string, error) if err != nil { return "", err } - for { - gitMarker := filepath.Join(current, ".git") - if _, statErr := os.Stat(gitMarker); statErr == nil { - if isFilesystemRoot(current) { - break - } - return current, nil - } - parent := filepath.Dir(current) - if parent == current || sameWorkspacePath(parent, current) { - break - } - current = parent + command := exec.CommandContext(ctx, "git", "-C", current, "rev-parse", "--show-toplevel") + output, err := command.Output() + if err != nil { + return "", errors.New("runtime: directory is not inside git work tree") } - return "", errors.New("runtime: directory is not inside git work tree") + root, err := normalizeExistingWorkdir(strings.TrimSpace(string(output))) + if err != nil { + return "", err + } + if isFilesystemRoot(root) && !sameWorkspacePath(root, current) { + return "", errors.New("runtime: directory is not inside git work tree") + } + return root, nil } // isFilesystemRoot 判断路径是否已经位于文件系统根目录。 diff --git a/internal/tui/core/app/runtime_event_test.go b/internal/tui/core/app/runtime_event_test.go index c4fe0254..6f8a1776 100644 --- a/internal/tui/core/app/runtime_event_test.go +++ b/internal/tui/core/app/runtime_event_test.go @@ -24,3 +24,23 @@ func TestHandleRuntimeEventIgnoresStaleRunAndSession(t *testing.T) { t.Fatalf("expected stale runtime event to leave transcript untouched, got %+v", app.activeMessages) } } + +func TestHandleRuntimeEventIgnoresEventsWithoutActiveForeground(t *testing.T) { + app := App{} + + dirty := app.handleRuntimeEvent(agentruntime.RuntimeEvent{ + Type: agentruntime.EventAgentChunk, + RunID: "run-stale", + SessionID: "session-stale", + Payload: "stale chunk", + }) + if dirty { + t.Fatalf("expected orphan runtime event to be ignored") + } + if app.state.ActiveSessionID != "" { + t.Fatalf("expected orphan runtime event not to restore active session, got %q", app.state.ActiveSessionID) + } + if len(app.activeMessages) != 0 { + t.Fatalf("expected orphan runtime event to leave transcript untouched, got %+v", app.activeMessages) + } +} diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index 9f63fd5e..3e1d0db5 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -761,12 +761,15 @@ func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) bool { func (a *App) shouldHandleRuntimeEvent(event agentruntime.RuntimeEvent) bool { activeRunID := strings.TrimSpace(a.state.ActiveRunID) eventRunID := strings.TrimSpace(event.RunID) + activeSessionID := strings.TrimSpace(a.state.ActiveSessionID) + eventSessionID := strings.TrimSpace(event.SessionID) + if activeRunID == "" && activeSessionID == "" { + return eventRunID == "" && eventSessionID == "" + } if activeRunID != "" && eventRunID != "" && !strings.EqualFold(activeRunID, eventRunID) { return false } - activeSessionID := strings.TrimSpace(a.state.ActiveSessionID) - eventSessionID := strings.TrimSpace(event.SessionID) if activeSessionID != "" && eventSessionID != "" && !strings.EqualFold(activeSessionID, eventSessionID) { return false } From e81b54be3ee84a4041bb81168db37e7dac334eed Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Thu, 9 Apr 2026 14:11:47 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E5=88=87=E6=8D=A2=E7=9B=B8=E5=85=B3=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/runtime/workspace_switch_test.go | 85 ++++++++ internal/tui/core/app/runtime_event_test.go | 210 ++++++++++++++++++++ 2 files changed, 295 insertions(+) diff --git a/internal/runtime/workspace_switch_test.go b/internal/runtime/workspace_switch_test.go index 3a7cc1b9..d549e2dd 100644 --- a/internal/runtime/workspace_switch_test.go +++ b/internal/runtime/workspace_switch_test.go @@ -2,6 +2,7 @@ package runtime import ( "context" + "errors" "os" "os/exec" "path/filepath" @@ -157,6 +158,90 @@ func TestServiceSwitchWorkspaceRejectsCrossWorkspaceWhileBusy(t *testing.T) { } } +func TestServiceSwitchWorkspaceKeepsSessionWithinWorkspace(t *testing.T) { + manager := newRuntimeConfigManager(t) + repoRoot := t.TempDir() + workspaceA := filepath.Join(repoRoot, "pkg") + target := filepath.Join(repoRoot, "cmd") + if err := os.MkdirAll(workspaceA, 0o755); err != nil { + t.Fatalf("mkdir workspaceA: %v", err) + } + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("mkdir target: %v", err) + } + if output, err := runGitCommand(t, repoRoot, "init"); err != nil { + t.Fatalf("git init: %v (%s)", err, output) + } + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Workdir = workspaceA + return nil + }); err != nil { + t.Fatalf("update workdir: %v", err) + } + + router := agentsession.NewScopedStoreRouter(t.TempDir(), workspaceA) + service := NewWithFactory(manager, tools.NewRegistry(), router, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + session := agentsession.NewWithWorkdir("workspace-a", workspaceA) + if err := router.StoreForWorkspace(repoRoot).Save(context.Background(), &session); err != nil { + t.Fatalf("save session: %v", err) + } + + result, err := service.SwitchWorkspace(context.Background(), WorkspaceSwitchInput{ + SessionID: session.ID, + RequestedPath: target, + }) + if err != nil { + t.Fatalf("SwitchWorkspace() error = %v", err) + } + if result.WorkspaceChanged { + t.Fatalf("expected same workspace switch, got %+v", result) + } + if result.ResetToDraft { + t.Fatalf("expected same workspace switch to keep session, got %+v", result) + } + if !result.KeepSession { + t.Fatalf("expected same workspace switch to keep session, got %+v", result) + } + if result.Session.Workdir != filepath.Clean(target) { + t.Fatalf("expected updated session workdir %q, got %q", filepath.Clean(target), result.Session.Workdir) + } + + loaded, err := service.LoadSession(context.Background(), session.ID) + if err != nil { + t.Fatalf("LoadSession() error = %v", err) + } + if loaded.Workdir != filepath.Clean(target) { + t.Fatalf("expected persisted session workdir %q, got %q", filepath.Clean(target), loaded.Workdir) + } +} + +func TestResolveWorkspaceSelectionFallsBackToWorkdirWhenGitResolverFails(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "child") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("mkdir target: %v", err) + } + + previous := workspaceGitRootResolver + workspaceGitRootResolver = func(context.Context, string) (string, error) { + return "", errors.New("no git root") + } + defer func() { + workspaceGitRootResolver = previous + }() + + workdir, root, err := resolveWorkspaceSelection(context.Background(), base, "child") + if err != nil { + t.Fatalf("resolveWorkspaceSelection() error = %v", err) + } + if workdir != filepath.Clean(target) { + t.Fatalf("expected workdir %q, got %q", filepath.Clean(target), workdir) + } + if root != filepath.Clean(target) { + t.Fatalf("expected fallback workspace root %q, got %q", filepath.Clean(target), root) + } +} + func runGitCommand(t *testing.T, workdir string, args ...string) (string, error) { t.Helper() diff --git a/internal/tui/core/app/runtime_event_test.go b/internal/tui/core/app/runtime_event_test.go index 6f8a1776..72a06025 100644 --- a/internal/tui/core/app/runtime_event_test.go +++ b/internal/tui/core/app/runtime_event_test.go @@ -1,9 +1,25 @@ package tui import ( + "context" + "os" + "path/filepath" "testing" + "time" + "github.com/charmbracelet/bubbles/filepicker" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/progress" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/viewport" + + "neo-code/internal/config" + providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" + agentsession "neo-code/internal/session" + tuistate "neo-code/internal/tui/state" ) func TestHandleRuntimeEventIgnoresStaleRunAndSession(t *testing.T) { @@ -44,3 +60,197 @@ func TestHandleRuntimeEventIgnoresEventsWithoutActiveForeground(t *testing.T) { t.Fatalf("expected orphan runtime event to leave transcript untouched, got %+v", app.activeMessages) } } + +func TestHandleRuntimeEventAcceptsAnonymousEventWithoutForeground(t *testing.T) { + app := App{} + + dirty := app.handleRuntimeEvent(agentruntime.RuntimeEvent{ + Type: agentruntime.EventAgentChunk, + Payload: "draft chunk", + }) + if !dirty { + t.Fatalf("expected anonymous runtime event to be handled") + } + if len(app.activeMessages) != 1 { + t.Fatalf("expected anonymous runtime event to append one message, got %+v", app.activeMessages) + } + if app.activeMessages[0].Content != "draft chunk" { + t.Fatalf("expected anonymous runtime payload to be appended, got %+v", app.activeMessages) + } +} + +func TestUpdateSessionWorkdirResultResetsDraftState(t *testing.T) { + workdir := t.TempDir() + other := t.TempDir() + runtime := &workspaceResultRuntimeStub{ + summaries: []agentsession.Summary{{ID: "session-new", Title: "new session"}}, + } + app := newWorkspaceResultTestApp(t, runtime, workdir) + app.state.ActiveSessionID = "session-old" + app.state.ActiveRunID = "run-old" + app.state.ActiveSessionTitle = "old session" + app.state.CurrentTool = "filesystem_read_file" + app.state.ToolStates = []tuistate.ToolState{{ToolName: "filesystem_read_file"}} + app.state.RunContext = tuistate.ContextWindowState{RunID: "run-old"} + app.state.TokenUsage = tuistate.TokenUsageState{RunInputTokens: 3, RunOutputTokens: 5} + app.activeMessages = []providertypes.Message{{Role: providertypes.RoleAssistant, Content: "old"}} + + model, _ := app.Update(sessionWorkdirResultMsg{ + Notice: "已切换到新工作区", + Workdir: other, + ResetToDraft: true, + }) + updated := model.(App) + + if updated.state.ActiveSessionID != "" { + t.Fatalf("expected draft session after workspace reset, got %q", updated.state.ActiveSessionID) + } + if updated.state.ActiveRunID != "" { + t.Fatalf("expected active run to be cleared after workspace reset, got %q", updated.state.ActiveRunID) + } + if updated.state.CurrentWorkdir != filepath.Clean(other) { + t.Fatalf("expected current workdir %q, got %q", filepath.Clean(other), updated.state.CurrentWorkdir) + } + if updated.state.StatusText != "已切换到新工作区" { + t.Fatalf("expected status text to reflect workspace notice, got %q", updated.state.StatusText) + } + if len(updated.activeMessages) != 0 { + t.Fatalf("expected workspace reset to clear transcript, got %+v", updated.activeMessages) + } + if len(updated.state.Sessions) != 1 || updated.state.Sessions[0].ID != "session-new" { + t.Fatalf("expected refreshed workspace sessions, got %+v", updated.state.Sessions) + } +} + +func TestUpdateSessionWorkdirResultRefreshesFilesWithinWorkspace(t *testing.T) { + workdir := t.TempDir() + target := filepath.Join(workdir, "child") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("mkdir target: %v", err) + } + filePath := filepath.Join(target, "demo.txt") + if err := os.WriteFile(filePath, []byte("demo"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + app := newWorkspaceResultTestApp(t, &workspaceResultRuntimeStub{}, workdir) + app.state.ActiveSessionID = "session-current" + + model, _ := app.Update(sessionWorkdirResultMsg{ + Notice: "已更新目录", + Workdir: target, + }) + updated := model.(App) + + if updated.state.ActiveSessionID != "session-current" { + t.Fatalf("expected same-workspace update to keep active session, got %q", updated.state.ActiveSessionID) + } + if updated.state.CurrentWorkdir != filepath.Clean(target) { + t.Fatalf("expected current workdir %q, got %q", filepath.Clean(target), updated.state.CurrentWorkdir) + } + if updated.state.StatusText != "已更新目录" { + t.Fatalf("expected status text to reflect workdir notice, got %q", updated.state.StatusText) + } + if len(updated.fileCandidates) == 0 { + t.Fatalf("expected file candidates to be refreshed for target workdir") + } +} + +type workspaceResultRuntimeStub struct { + summaries []agentsession.Summary +} + +func (s *workspaceResultRuntimeStub) Run(context.Context, agentruntime.UserInput) error { + return nil +} + +func (s *workspaceResultRuntimeStub) Compact(context.Context, agentruntime.CompactInput) (agentruntime.CompactResult, error) { + return agentruntime.CompactResult{}, nil +} + +func (s *workspaceResultRuntimeStub) SwitchWorkspace(context.Context, agentruntime.WorkspaceSwitchInput) (agentruntime.WorkspaceSwitchResult, error) { + return agentruntime.WorkspaceSwitchResult{}, nil +} + +func (s *workspaceResultRuntimeStub) ResolvePermission(context.Context, agentruntime.PermissionResolutionInput) error { + return nil +} + +func (s *workspaceResultRuntimeStub) CancelActiveRun() bool { + return false +} + +func (s *workspaceResultRuntimeStub) Events() <-chan agentruntime.RuntimeEvent { + return make(chan agentruntime.RuntimeEvent) +} + +func (s *workspaceResultRuntimeStub) ListSessions(context.Context) ([]agentsession.Summary, error) { + return append([]agentsession.Summary(nil), s.summaries...), nil +} + +func (s *workspaceResultRuntimeStub) LoadSession(context.Context, string) (agentsession.Session, error) { + return agentsession.Session{}, nil +} + +func newWorkspaceResultTestApp(t *testing.T, runtime agentruntime.Runtime, workdir string) App { + t.Helper() + + defaults := config.DefaultConfig() + defaults.Workdir = workdir + loader := config.NewLoader(t.TempDir(), defaults) + manager := config.NewManager(loader) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Workdir = workdir + return nil + }); err != nil { + t.Fatalf("update config manager: %v", err) + } + + uiStyles := newStyles() + sessionList := list.New([]list.Item{}, sessionDelegate{styles: uiStyles}, 0, 0) + sessionList.SetShowTitle(false) + sessionList.SetShowHelp(false) + sessionList.SetShowStatusBar(false) + sessionList.SetShowPagination(false) + sessionList.SetFilteringEnabled(true) + sessionList.DisableQuitKeybindings() + + input := textarea.New() + input.Focus() + fileBrowser := filepicker.New() + fileBrowser.CurrentDirectory = workdir + + return App{ + state: tuistate.UIState{ + StatusText: statusReady, + CurrentWorkdir: workdir, + ActiveSessionTitle: draftSessionTitle, + Focus: panelInput, + }, + appServices: appServices{ + configManager: manager, + runtime: runtime, + }, + appComponents: appComponents{ + help: help.New(), + spinner: spinner.New(), + sessions: sessionList, + commandMenu: newCommandMenuModel(uiStyles), + providerPicker: newSelectionPickerItems(nil), + modelPicker: newSelectionPickerItems(nil), + fileBrowser: fileBrowser, + progress: progress.New(progress.WithoutPercentage()), + transcript: viewport.New(0, 0), + activity: viewport.New(0, 0), + input: input, + }, + appRuntimeState: appRuntimeState{ + codeCopyBlocks: make(map[int]string), + nowFn: time.Now, + focus: panelInput, + }, + width: 120, + height: 40, + styles: uiStyles, + } +} From fce21b5bebefc75c5bc3327da292fc7655398490 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Thu, 9 Apr 2026 15:26:54 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E5=88=87=E6=8D=A2=E7=9B=B8=E5=85=B3=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=B9=B6=E7=A8=B3=E5=AE=9A=E8=A6=86=E7=9B=96=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/neocode/main_test.go | 55 +++ internal/cli/root_test.go | 2 + internal/runtime/compact_additional_test.go | 297 +++++++++++++ internal/session/router.go | 9 + internal/session/router_test.go | 98 +++++ internal/tools/bash/tool_test.go | 4 +- internal/tools/mcp/stdio_client_test.go | 8 +- internal/tui/core/app/runtime_event_test.go | 332 +++++++++++++- .../tui/core/app/update_additional_test.go | 412 ++++++++++++++++++ internal/tui/infra/infra_test.go | 6 +- 10 files changed, 1208 insertions(+), 15 deletions(-) create mode 100644 cmd/neocode/main_test.go create mode 100644 internal/runtime/compact_additional_test.go create mode 100644 internal/session/router_test.go create mode 100644 internal/tui/core/app/update_additional_test.go diff --git a/cmd/neocode/main_test.go b/cmd/neocode/main_test.go new file mode 100644 index 00000000..00a25352 --- /dev/null +++ b/cmd/neocode/main_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +func TestMainHelpAndErrorExitPaths(t *testing.T) { + t.Run("help exits successfully", func(t *testing.T) { + cmd := exec.Command(os.Args[0], "-test.run=TestHelperProcessMain", "--", "--help") + cmd.Env = append(os.Environ(), "GO_WANT_MAIN_HELPER=1") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("expected help path to exit successfully, got %v (%s)", err, string(output)) + } + if !strings.Contains(string(output), "Usage:") { + t.Fatalf("expected help output, got %q", string(output)) + } + }) + + t.Run("invalid flag exits with error", func(t *testing.T) { + cmd := exec.Command(os.Args[0], "-test.run=TestHelperProcessMain", "--", "--bad-flag") + cmd.Env = append(os.Environ(), "GO_WANT_MAIN_HELPER=1") + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("expected invalid flag path to exit with error") + } + if !strings.Contains(string(output), "unknown flag") { + t.Fatalf("expected invalid flag output, got %q", string(output)) + } + }) +} + +func TestHelperProcessMain(t *testing.T) { + if os.Getenv("GO_WANT_MAIN_HELPER") != "1" { + return + } + + args := []string{"neocode"} + for _, arg := range os.Args { + if arg == "--" { + break + } + } + for i, arg := range os.Args { + if arg == "--" { + args = append(args, os.Args[i+1:]...) + break + } + } + os.Args = args + main() +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 06538080..0ad9103a 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -43,6 +43,7 @@ func TestNewRootCommandAllowsEmptyWorkdir(t *testing.T) { } cmd := NewRootCommand() + cmd.SetArgs([]string{}) if err := cmd.ExecuteContext(context.Background()); err != nil { t.Fatalf("ExecuteContext() error = %v", err) } @@ -61,6 +62,7 @@ func TestNewRootCommandReturnsLauncherError(t *testing.T) { } cmd := NewRootCommand() + cmd.SetArgs([]string{}) err := cmd.ExecuteContext(context.Background()) if !errors.Is(err, expected) { t.Fatalf("expected launcher error %v, got %v", expected, err) diff --git a/internal/runtime/compact_additional_test.go b/internal/runtime/compact_additional_test.go new file mode 100644 index 00000000..9866e005 --- /dev/null +++ b/internal/runtime/compact_additional_test.go @@ -0,0 +1,297 @@ +package runtime + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + contextcompact "neo-code/internal/context/compact" + providertypes "neo-code/internal/provider/types" + agentsession "neo-code/internal/session" +) + +func TestServiceCompactValidationAndStoreErrors(t *testing.T) { + t.Run("rejects canceled context", func(t *testing.T) { + service := &Service{} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := service.Compact(ctx, CompactInput{SessionID: "session-1"}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context canceled error, got %v", err) + } + }) + + t.Run("rejects empty session id", func(t *testing.T) { + service := &Service{} + _, err := service.Compact(context.Background(), CompactInput{}) + if err == nil || err.Error() != "runtime: compact session_id is empty" { + t.Fatalf("expected empty session id error, got %v", err) + } + }) + + t.Run("fails when session store is nil", func(t *testing.T) { + manager := newRuntimeConfigManager(t) + service := NewWithFactory(manager, nil, nil, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + + _, err := service.Compact(context.Background(), CompactInput{SessionID: "session-1"}) + if err == nil || err.Error() != "runtime: session store is nil" { + t.Fatalf("expected nil store error, got %v", err) + } + }) +} + +func TestRunCompactForSessionNonFatalErrorBranches(t *testing.T) { + t.Run("default runner resolution error becomes compact event when failOnError false", func(t *testing.T) { + manager := newRuntimeConfigManager(t) + service := NewWithFactory(manager, nil, newMemoryStore(), &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + cfg := manager.Get() + session := agentsession.Session{ + ID: "session-default-runner-error", + Provider: "missing-provider", + Model: "missing-model", + Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, + } + + returned, result, err := service.runCompactForSession(context.Background(), "run-1", session, cfg, false) + if err != nil { + t.Fatalf("expected failOnError=false to swallow error, got %v", err) + } + if returned.ID != session.ID || result.Applied || len(result.Messages) != 0 { + t.Fatalf("expected original session and zero result, got session=%+v result=%+v", returned, result) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{EventCompactError}) + }) + + t.Run("runner failure emits compact error without returning error when non fatal", func(t *testing.T) { + manager := newRuntimeConfigManager(t) + service := NewWithFactory(manager, nil, newMemoryStore(), &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + service.compactRunner = &stubCompactRunner{err: errors.New("compact runner failed")} + session := agentsession.Session{ + ID: "session-runner-error", + Workdir: t.TempDir(), + Messages: []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}}, + } + + returned, result, err := service.runCompactForSession(context.Background(), "run-2", session, manager.Get(), false) + if err != nil { + t.Fatalf("expected non-fatal runner error to be swallowed, got %v", err) + } + if returned.ID != session.ID || result.Applied || len(result.Messages) != 0 { + t.Fatalf("expected original session and zero result, got session=%+v result=%+v", returned, result) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{EventCompactStart, EventCompactError}) + }) + + t.Run("save failure restores original messages when non fatal", func(t *testing.T) { + manager := newRuntimeConfigManager(t) + baseStore := newMemoryStore() + store := &failingStore{ + Store: baseStore, + saveErr: errors.New("save compacted session failed"), + failOnSave: 1, + } + service := NewWithFactory(manager, nil, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + service.compactRunner = &stubCompactRunner{ + result: contextcompact.Result{ + Applied: true, + Messages: []providertypes.Message{ + {Role: providertypes.RoleAssistant, Content: "compacted"}, + }, + Metrics: contextcompact.Metrics{ + BeforeChars: 20, + AfterChars: 10, + SavedRatio: 0.5, + }, + }, + } + originalMessages := []providertypes.Message{{Role: providertypes.RoleUser, Content: "hello"}} + session := agentsession.Session{ + ID: "session-save-error", + Workdir: t.TempDir(), + Messages: append([]providertypes.Message(nil), originalMessages...), + } + + returned, result, err := service.runCompactForSession(context.Background(), "run-3", session, manager.Get(), false) + if err != nil { + t.Fatalf("expected non-fatal save error to be swallowed, got %v", err) + } + if result.Applied || len(result.Messages) != 0 { + t.Fatalf("expected zero result after save failure, got %+v", result) + } + if len(returned.Messages) != len(originalMessages) || returned.Messages[0].Content != originalMessages[0].Content { + t.Fatalf("expected original messages restored after save failure, got %+v", returned.Messages) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{EventCompactStart, EventCompactError}) + }) +} + +func TestResolveCompactProviderSelectionBranches(t *testing.T) { + manager := newRuntimeConfigManagerWithProviderEnvs(t, map[string]string{ + "openai": runtimeTestAPIKeyEnv(t), + }) + cfg := manager.Get() + + sessionResolved, sessionModel, err := resolveCompactProviderSelection(agentsession.Session{ + Provider: cfg.SelectedProvider, + Model: "session-model", + }, cfg) + if err != nil { + t.Fatalf("expected session provider selection to resolve, got %v", err) + } + if sessionResolved.Name == "" || sessionModel != "session-model" { + t.Fatalf("expected session provider/model to be preferred, got provider=%+v model=%q", sessionResolved, sessionModel) + } + + fallbackResolved, fallbackModel, err := resolveCompactProviderSelection(agentsession.Session{}, cfg) + if err != nil { + t.Fatalf("expected fallback provider selection to resolve, got %v", err) + } + if fallbackResolved.Name == "" || fallbackModel != cfg.CurrentModel { + t.Fatalf("expected fallback provider/model from config, got provider=%+v model=%q", fallbackResolved, fallbackModel) + } + + if _, _, err := resolveCompactProviderSelection(agentsession.Session{ + Provider: "missing-provider", + Model: "session-model", + }, cfg); err == nil { + t.Fatalf("expected invalid session provider to fail") + } + + badCfg := cfg + badCfg.SelectedProvider = "missing-provider" + if _, err := resolveSelectedProviderFromConfig(badCfg); err == nil { + t.Fatalf("expected invalid selected provider to fail") + } +} + +func TestWorkspaceSwitchHelperBranches(t *testing.T) { + t.Run("initialize workspace state prefers git root", func(t *testing.T) { + manager := newRuntimeConfigManager(t) + repoRoot := t.TempDir() + subdir := filepath.Join(repoRoot, "pkg", "child") + if err := os.MkdirAll(subdir, 0o755); err != nil { + t.Fatalf("mkdir subdir: %v", err) + } + if output, err := runGitCommand(t, repoRoot, "init"); err != nil { + t.Fatalf("git init: %v (%s)", err, output) + } + + service := NewWithFactory(manager, nil, newMemoryStore(), &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + service.initializeWorkspaceState(subdir) + + root, workdir := service.currentWorkspaceState() + if root != filepath.Clean(repoRoot) || workdir != filepath.Clean(subdir) { + t.Fatalf("expected initialized workspace root=%q workdir=%q, got root=%q workdir=%q", filepath.Clean(repoRoot), filepath.Clean(subdir), root, workdir) + } + }) + + t.Run("helper functions normalize workspace paths", func(t *testing.T) { + root := filepath.VolumeName(t.TempDir()) + string(filepath.Separator) + if effectiveWorkspaceBase(" ", "/repo") != "/repo" { + t.Fatalf("expected workspace base fallback to root") + } + if !sameWorkspacePath(filepath.Join("a", "..", "b"), filepath.Join(".", "b")) { + t.Fatalf("expected normalized workspace paths to match") + } + if !isFilesystemRoot(root) { + t.Fatalf("expected %q to be recognized as filesystem root", root) + } + if normalizeWorkspacePathKey(" ") != "" { + t.Fatalf("expected blank workspace key to stay blank") + } + }) + + t.Run("session store provider falls back and handles nil", func(t *testing.T) { + service := &Service{} + if store := service.sessionStoreForWorkspace("workspace"); store != nil { + t.Fatalf("expected nil store provider to return nil store") + } + + memory := newMemoryStore() + service.storeProvider = fixedWorkspaceStoreProvider{store: memory} + if store := service.sessionStoreForWorkspace("workspace"); store != memory { + t.Fatalf("expected fixed store provider to return backing store") + } + }) +} + +func TestResolveRequestedWorkspacePathUsesBaseAndRejectsInvalidBase(t *testing.T) { + base := t.TempDir() + child := filepath.Join(base, "child") + if err := os.MkdirAll(child, 0o755); err != nil { + t.Fatalf("mkdir child: %v", err) + } + + resolved, err := resolveRequestedWorkspacePath(base, "child") + if err != nil { + t.Fatalf("expected relative workspace path to resolve, got %v", err) + } + if resolved != filepath.Clean(child) { + t.Fatalf("expected resolved workdir %q, got %q", filepath.Clean(child), resolved) + } + + if _, err := resolveRequestedWorkspacePath(filepath.Join(base, "missing"), "child"); err == nil { + t.Fatalf("expected invalid base workdir to fail") + } +} + +func TestDetectWorkspaceGitRootRejectsCanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := detectWorkspaceGitRoot(ctx, t.TempDir()); !errors.Is(err, context.Canceled) { + t.Fatalf("expected canceled context error, got %v", err) + } +} + +func TestRunCompactForSessionPersistsUpdatedTimestamp(t *testing.T) { + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + service := NewWithFactory(manager, nil, store, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + service.compactRunner = &stubCompactRunner{ + result: contextcompact.Result{ + Applied: true, + Messages: []providertypes.Message{ + {Role: providertypes.RoleAssistant, Content: "compacted"}, + }, + Metrics: contextcompact.Metrics{ + BeforeChars: 12, + AfterChars: 6, + SavedRatio: 0.5, + }, + }, + } + + session := agentsession.Session{ + ID: "session-compact-success", + Workdir: t.TempDir(), + UpdatedAt: time.Now().Add(-time.Hour), + Messages: []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "hello"}, + }, + } + + returned, result, err := service.runCompactForSession(context.Background(), "run-4", session, manager.Get(), true) + if err != nil { + t.Fatalf("expected compact success, got %v", err) + } + if !result.Applied || len(returned.Messages) != 1 || returned.Messages[0].Content != "compacted" { + t.Fatalf("expected compacted messages to be persisted, got session=%+v result=%+v", returned, result) + } + if !returned.UpdatedAt.After(session.UpdatedAt) { + t.Fatalf("expected compact to refresh updated timestamp, got before=%v after=%v", session.UpdatedAt, returned.UpdatedAt) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{EventCompactStart, EventCompactDone}) +} diff --git a/internal/session/router.go b/internal/session/router.go index 60866eca..a856baef 100644 --- a/internal/session/router.go +++ b/internal/session/router.go @@ -52,6 +52,9 @@ func (r *ScopedStoreRouter) StoreForWorkspace(workspaceRoot string) Store { // Save 使用默认工作区根目录对应的存储执行保存。 func (r *ScopedStoreRouter) Save(ctx context.Context, session *Session) error { + if r == nil { + return nil + } store := r.StoreForWorkspace(r.defaultRoot) if store == nil { return nil @@ -61,6 +64,9 @@ func (r *ScopedStoreRouter) Save(ctx context.Context, session *Session) error { // Load 使用默认工作区根目录对应的存储执行读取。 func (r *ScopedStoreRouter) Load(ctx context.Context, id string) (Session, error) { + if r == nil { + return Session{}, nil + } store := r.StoreForWorkspace(r.defaultRoot) if store == nil { return Session{}, nil @@ -70,6 +76,9 @@ func (r *ScopedStoreRouter) Load(ctx context.Context, id string) (Session, error // ListSummaries 使用默认工作区根目录对应的存储列出会话摘要。 func (r *ScopedStoreRouter) ListSummaries(ctx context.Context) ([]Summary, error) { + if r == nil { + return nil, nil + } store := r.StoreForWorkspace(r.defaultRoot) if store == nil { return nil, nil diff --git a/internal/session/router_test.go b/internal/session/router_test.go new file mode 100644 index 00000000..6cac5535 --- /dev/null +++ b/internal/session/router_test.go @@ -0,0 +1,98 @@ +package session + +import ( + "context" + "path/filepath" + "testing" + "time" +) + +func TestScopedStoreRouterStoreForWorkspaceCachesPerRoot(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + defaultRoot := filepath.Join(t.TempDir(), "workspace-default") + otherRoot := filepath.Join(t.TempDir(), "workspace-other") + + router := NewScopedStoreRouter(baseDir, defaultRoot) + defaultStore := router.StoreForWorkspace("") + if defaultStore == nil { + t.Fatalf("expected default workspace store") + } + if got := router.StoreForWorkspace(defaultRoot); got != defaultStore { + t.Fatalf("expected default workspace store to be reused") + } + + otherStore := router.StoreForWorkspace(otherRoot) + if otherStore == nil { + t.Fatalf("expected other workspace store") + } + if otherStore == defaultStore { + t.Fatalf("expected distinct store instances for different workspace roots") + } + if got := router.StoreForWorkspace(otherRoot); got != otherStore { + t.Fatalf("expected other workspace store to be reused") + } +} + +func TestScopedStoreRouterSaveLoadAndListUseDefaultWorkspace(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + defaultRoot := filepath.Join(t.TempDir(), "workspace-default") + router := NewScopedStoreRouter(baseDir, defaultRoot) + + session := &Session{ + ID: "session-default", + Title: "Default Workspace", + CreatedAt: time.Now().Add(-time.Minute), + UpdatedAt: time.Now(), + Workdir: defaultRoot, + } + if err := router.Save(context.Background(), session); err != nil { + t.Fatalf("Save() error = %v", err) + } + + loaded, err := router.Load(context.Background(), session.ID) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if loaded.ID != session.ID || loaded.Title != session.Title { + t.Fatalf("expected saved session to be loaded back, got %+v", loaded) + } + + summaries, err := router.ListSummaries(context.Background()) + if err != nil { + t.Fatalf("ListSummaries() error = %v", err) + } + if len(summaries) != 1 || summaries[0].ID != session.ID { + t.Fatalf("expected default workspace summary, got %+v", summaries) + } +} + +func TestScopedStoreRouterNilReceiverBehaviors(t *testing.T) { + t.Parallel() + + var router *ScopedStoreRouter + + if store := router.StoreForWorkspace("workspace"); store != nil { + t.Fatalf("expected nil receiver to return nil store, got %#v", store) + } + if err := router.Save(context.Background(), &Session{ID: "session"}); err != nil { + t.Fatalf("expected nil receiver Save() to be a no-op, got %v", err) + } + loaded, err := router.Load(context.Background(), "session") + if err != nil { + t.Fatalf("expected nil receiver Load() to return nil error, got %v", err) + } + if loaded.ID != "" || loaded.Title != "" || len(loaded.Messages) != 0 { + t.Fatalf("expected zero session from nil receiver Load(), got %+v", loaded) + } + summaries, err := router.ListSummaries(context.Background()) + if err != nil { + t.Fatalf("expected nil receiver ListSummaries() to return nil error, got %v", err) + } + if summaries != nil { + t.Fatalf("expected nil summaries from nil receiver, got %+v", summaries) + } +} diff --git a/internal/tools/bash/tool_test.go b/internal/tools/bash/tool_test.go index e2202ca4..d47e99be 100644 --- a/internal/tools/bash/tool_test.go +++ b/internal/tools/bash/tool_test.go @@ -62,7 +62,7 @@ func TestToolExecute(t *testing.T) { }, } - tool := New(workspace, defaultShell(), 3*time.Second) + tool := New(workspace, defaultShell(), 10*time.Second) for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { @@ -101,7 +101,7 @@ func TestToolExecute(t *testing.T) { func TestToolExecuteErrorFormattingAndTruncation(t *testing.T) { workspace := t.TempDir() - tool := New(workspace, defaultShell(), 3*time.Second) + tool := New(workspace, defaultShell(), 10*time.Second) tests := []struct { name string diff --git a/internal/tools/mcp/stdio_client_test.go b/internal/tools/mcp/stdio_client_test.go index 01d8d20b..25afb702 100644 --- a/internal/tools/mcp/stdio_client_test.go +++ b/internal/tools/mcp/stdio_client_test.go @@ -438,8 +438,8 @@ func newTestStdIOClientWithMode(t *testing.T, wireMode string) *StdIOClient { Command: os.Args[0], Args: []string{"-test.run=TestHelperProcessMCPStdioServer", "--"}, Env: []string{"GO_WANT_MCP_STDIO_HELPER=1", "GO_MCP_STDIO_REQUIRE_INITIALIZE=1", "GO_MCP_STDIO_WIRE=" + wireMode}, - StartTimeout: 3 * time.Second, - CallTimeout: 3 * time.Second, + StartTimeout: 10 * time.Second, + CallTimeout: 10 * time.Second, }) if err != nil { t.Fatalf("NewStdIOClient() error = %v", err) @@ -454,8 +454,8 @@ func TestStdIOClientInitializeFailure(t *testing.T) { Command: os.Args[0], Args: []string{"-test.run=TestHelperProcessMCPStdioServer", "--"}, Env: []string{"GO_WANT_MCP_STDIO_HELPER=1", "GO_MCP_STDIO_INIT_FAIL=1", "GO_MCP_STDIO_WIRE=framed"}, - StartTimeout: 3 * time.Second, - CallTimeout: 3 * time.Second, + StartTimeout: 10 * time.Second, + CallTimeout: 10 * time.Second, }) if err != nil { t.Fatalf("NewStdIOClient() error = %v", err) diff --git a/internal/tui/core/app/runtime_event_test.go b/internal/tui/core/app/runtime_event_test.go index 72a06025..dd271b6d 100644 --- a/internal/tui/core/app/runtime_event_test.go +++ b/internal/tui/core/app/runtime_event_test.go @@ -19,6 +19,7 @@ import ( providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" agentsession "neo-code/internal/session" + "neo-code/internal/tools" tuistate "neo-code/internal/tui/state" ) @@ -96,7 +97,7 @@ func TestUpdateSessionWorkdirResultResetsDraftState(t *testing.T) { app.activeMessages = []providertypes.Message{{Role: providertypes.RoleAssistant, Content: "old"}} model, _ := app.Update(sessionWorkdirResultMsg{ - Notice: "已切换到新工作区", + Notice: "workspace switched", Workdir: other, ResetToDraft: true, }) @@ -111,7 +112,7 @@ func TestUpdateSessionWorkdirResultResetsDraftState(t *testing.T) { if updated.state.CurrentWorkdir != filepath.Clean(other) { t.Fatalf("expected current workdir %q, got %q", filepath.Clean(other), updated.state.CurrentWorkdir) } - if updated.state.StatusText != "已切换到新工作区" { + if updated.state.StatusText != "workspace switched" { t.Fatalf("expected status text to reflect workspace notice, got %q", updated.state.StatusText) } if len(updated.activeMessages) != 0 { @@ -137,7 +138,7 @@ func TestUpdateSessionWorkdirResultRefreshesFilesWithinWorkspace(t *testing.T) { app.state.ActiveSessionID = "session-current" model, _ := app.Update(sessionWorkdirResultMsg{ - Notice: "已更新目录", + Notice: "workdir updated", Workdir: target, }) updated := model.(App) @@ -148,7 +149,7 @@ func TestUpdateSessionWorkdirResultRefreshesFilesWithinWorkspace(t *testing.T) { if updated.state.CurrentWorkdir != filepath.Clean(target) { t.Fatalf("expected current workdir %q, got %q", filepath.Clean(target), updated.state.CurrentWorkdir) } - if updated.state.StatusText != "已更新目录" { + if updated.state.StatusText != "workdir updated" { t.Fatalf("expected status text to reflect workdir notice, got %q", updated.state.StatusText) } if len(updated.fileCandidates) == 0 { @@ -156,8 +157,312 @@ func TestUpdateSessionWorkdirResultRefreshesFilesWithinWorkspace(t *testing.T) { } } +func TestRefreshMessagesLoadsSessionState(t *testing.T) { + workdir := t.TempDir() + sessionWorkdir := filepath.Join(workdir, "child") + if err := os.MkdirAll(sessionWorkdir, 0o755); err != nil { + t.Fatalf("mkdir session workdir: %v", err) + } + + runtime := &workspaceResultRuntimeStub{ + sessions: map[string]agentsession.Session{ + "session-1": { + ID: "session-1", + Title: "Session One", + Workdir: sessionWorkdir, + Messages: []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "hello"}, + {Role: providertypes.RoleAssistant, Content: "world"}, + }, + }, + }, + } + app := newWorkspaceResultTestApp(t, runtime, workdir) + app.state.ActiveSessionID = "session-1" + app.activities = []tuistate.ActivityEntry{{Title: "stale"}} + + if err := app.refreshMessages(); err != nil { + t.Fatalf("refreshMessages() error = %v", err) + } + if len(app.activeMessages) != 2 { + t.Fatalf("expected loaded session messages, got %+v", app.activeMessages) + } + if app.state.ActiveSessionTitle != "Session One" { + t.Fatalf("expected session title to sync, got %q", app.state.ActiveSessionTitle) + } + if app.state.CurrentWorkdir != filepath.Clean(sessionWorkdir) { + t.Fatalf("expected session workdir %q, got %q", filepath.Clean(sessionWorkdir), app.state.CurrentWorkdir) + } + if len(app.activities) != 0 { + t.Fatalf("expected refreshMessages to clear activities, got %+v", app.activities) + } +} + +func TestActivateSelectedSessionLoadsChosenSession(t *testing.T) { + workdir := t.TempDir() + sessionA := agentsession.Session{ID: "session-a", Title: "Session A", Messages: []providertypes.Message{{Role: providertypes.RoleAssistant, Content: "A"}}} + sessionB := agentsession.Session{ID: "session-b", Title: "Session B", Messages: []providertypes.Message{{Role: providertypes.RoleAssistant, Content: "B"}}} + runtime := &workspaceResultRuntimeStub{ + summaries: []agentsession.Summary{ + {ID: sessionA.ID, Title: sessionA.Title}, + {ID: sessionB.ID, Title: sessionB.Title}, + }, + sessions: map[string]agentsession.Session{ + sessionA.ID: sessionA, + sessionB.ID: sessionB, + }, + } + app := newWorkspaceResultTestApp(t, runtime, workdir) + if err := app.refreshSessions(); err != nil { + t.Fatalf("refreshSessions() error = %v", err) + } + app.sessions.Select(1) + + if err := app.activateSelectedSession(); err != nil { + t.Fatalf("activateSelectedSession() error = %v", err) + } + if app.state.ActiveSessionID != sessionB.ID { + t.Fatalf("expected selected session id %q, got %q", sessionB.ID, app.state.ActiveSessionID) + } + if app.state.ActiveSessionTitle != sessionB.Title { + t.Fatalf("expected selected session title %q, got %q", sessionB.Title, app.state.ActiveSessionTitle) + } + if len(app.activeMessages) != 1 || app.activeMessages[0].Content != "B" { + t.Fatalf("expected selected session messages to load, got %+v", app.activeMessages) + } +} + +func TestRuntimeEventHandlersUpdateState(t *testing.T) { + app := newWorkspaceResultTestApp(t, &workspaceResultRuntimeStub{}, t.TempDir()) + + if dirty := runtimeEventRunContextHandler(&app, agentruntime.RuntimeEvent{ + RunID: "run-1", + SessionID: "session-1", + Payload: map[string]any{ + "Provider": "openai", + "Model": "gpt-5.4", + "Workdir": "/repo", + "Mode": "act", + }, + }); dirty { + t.Fatalf("expected run context handler not to dirty transcript") + } + if app.state.ActiveRunID != "run-1" || app.state.CurrentProvider != "openai" || app.state.CurrentModel != "gpt-5.4" || app.state.CurrentWorkdir != "/repo" { + t.Fatalf("unexpected run context state: %+v", app.state) + } + + runtimeEventToolStatusHandler(&app, agentruntime.RuntimeEvent{ + Payload: map[string]any{ + "ToolCallID": "call-1", + "ToolName": "filesystem_read_file", + "Status": "running", + }, + }) + if app.state.CurrentTool != "filesystem_read_file" || len(app.state.ToolStates) != 1 { + t.Fatalf("expected running tool state, got current=%q states=%+v", app.state.CurrentTool, app.state.ToolStates) + } + + runtimeEventToolStatusHandler(&app, agentruntime.RuntimeEvent{ + Payload: map[string]any{ + "ToolCallID": "call-1", + "ToolName": "filesystem_read_file", + "Status": "succeeded", + }, + }) + if app.state.CurrentTool != "" { + t.Fatalf("expected succeeded tool to clear current tool, got %q", app.state.CurrentTool) + } + + runtimeEventUsageHandler(&app, agentruntime.RuntimeEvent{ + Payload: map[string]any{ + "Run": map[string]any{ + "InputTokens": 10, + "OutputTokens": 20, + "TotalTokens": 30, + }, + "Session": map[string]any{ + "InputTokens": 40, + "OutputTokens": 50, + "TotalTokens": 90, + }, + }, + }) + if app.state.TokenUsage.RunTotalTokens != 30 || app.state.TokenUsage.SessionTotalTokens != 90 { + t.Fatalf("unexpected token usage state: %+v", app.state.TokenUsage) + } +} + +func TestRuntimeEventTerminalHandlers(t *testing.T) { + app := newWorkspaceResultTestApp(t, &workspaceResultRuntimeStub{}, t.TempDir()) + app.state.IsAgentRunning = true + app.state.ActiveRunID = "run-1" + app.state.CurrentTool = "tool" + + runtimeEventToolCallThinkingHandler(&app, agentruntime.RuntimeEvent{Payload: "bash"}) + if app.state.CurrentTool != "bash" || len(app.activities) == 0 { + t.Fatalf("expected tool call thinking to update activity, got current=%q activities=%+v", app.state.CurrentTool, app.activities) + } + + runtimeEventToolStartHandler(&app, agentruntime.RuntimeEvent{ + Payload: providertypes.ToolCall{Name: "filesystem_read_file"}, + }) + if app.state.StatusText != statusRunningTool || app.state.CurrentTool != "filesystem_read_file" { + t.Fatalf("expected tool start state, got status=%q current=%q", app.state.StatusText, app.state.CurrentTool) + } + + dirty := runtimeEventToolResultHandler(&app, agentruntime.RuntimeEvent{ + Payload: tools.ToolResult{Name: "filesystem_read_file", Content: "done"}, + }) + if !dirty || app.state.StatusText != statusToolFinished { + t.Fatalf("expected successful tool result to dirty transcript and finish tool, got dirty=%v status=%q", dirty, app.state.StatusText) + } + + app.state.IsAgentRunning = true + app.state.ActiveRunID = "run-2" + dirty = runtimeEventAgentDoneHandler(&app, agentruntime.RuntimeEvent{ + Payload: providertypes.Message{Role: providertypes.RoleAssistant, Content: "final answer"}, + }) + if !dirty || app.state.IsAgentRunning || app.state.ActiveRunID != "" { + t.Fatalf("expected agent done to clear run state, got dirty=%v state=%+v", dirty, app.state) + } + + app.state.IsAgentRunning = true + app.state.ActiveRunID = "run-3" + runtimeEventRunCanceledHandler(&app, agentruntime.RuntimeEvent{}) + if app.state.StatusText != statusCanceled || app.state.IsAgentRunning { + t.Fatalf("expected canceled state, got %+v", app.state) + } + + app.state.IsAgentRunning = true + runtimeEventErrorHandler(&app, agentruntime.RuntimeEvent{Payload: "boom"}) + if app.state.ExecutionError != "boom" || app.state.StatusText != "boom" || app.state.IsAgentRunning { + t.Fatalf("expected runtime error state, got %+v", app.state) + } + + runtimeEventProviderRetryHandler(&app, agentruntime.RuntimeEvent{Payload: "retrying"}) + if app.state.StatusText != statusThinking { + t.Fatalf("expected provider retry to restore thinking status, got %q", app.state.StatusText) + } + + dirty = runtimeEventCompactDoneHandler(&app, agentruntime.RuntimeEvent{ + Payload: agentruntime.CompactDonePayload{ + Applied: true, + BeforeChars: 100, + AfterChars: 40, + SavedRatio: 0.6, + TriggerMode: "manual", + TranscriptPath: "/tmp/compact.txt", + }, + }) + if !dirty || len(app.activeMessages) == 0 { + t.Fatalf("expected compact done to append inline message, got dirty=%v messages=%+v", dirty, app.activeMessages) + } + + dirty = runtimeEventCompactErrorHandler(&app, agentruntime.RuntimeEvent{ + Payload: agentruntime.CompactErrorPayload{ + TriggerMode: "manual", + Message: "failed", + }, + }) + if !dirty || app.state.ExecutionError == "" { + t.Fatalf("expected compact error to append error state, got dirty=%v state=%+v", dirty, app.state) + } +} + +func TestAppStateSyncHelpers(t *testing.T) { + app := newWorkspaceResultTestApp(t, &workspaceResultRuntimeStub{}, t.TempDir()) + + app.syncActiveSessionTitle() + if app.state.ActiveSessionTitle != draftSessionTitle { + t.Fatalf("expected draft title fallback, got %q", app.state.ActiveSessionTitle) + } + + app.state.ActiveSessionID = "session-1" + app.state.Sessions = []agentsession.Summary{{ID: "session-1", Title: "Session One"}} + app.syncActiveSessionTitle() + if app.state.ActiveSessionTitle != "Session One" { + t.Fatalf("expected active session title to sync from summaries, got %q", app.state.ActiveSessionTitle) + } + + app.state.CurrentWorkdir = "" + app.syncConfigState(config.Config{ + SelectedProvider: "openai", + CurrentModel: "gpt-5.4", + Workdir: "/repo", + }) + if app.state.CurrentProvider != "openai" || app.state.CurrentModel != "gpt-5.4" || app.state.CurrentWorkdir != "/repo" { + t.Fatalf("unexpected config sync state: %+v", app.state) + } +} + +func TestRefreshRuntimeSourceSnapshot(t *testing.T) { + workdir := t.TempDir() + runtime := &workspaceResultRuntimeStub{ + sessionContext: map[string]any{ + "SessionID": "session-1", + "Provider": "openai", + "Model": "gpt-5.4", + "Workdir": "/session", + "Mode": "chat", + }, + sessionUsage: map[string]any{ + "InputTokens": 11, + "OutputTokens": 22, + "TotalTokens": 33, + }, + runSnapshot: map[string]any{ + "RunID": "run-1", + "SessionID": "session-1", + "Context": map[string]any{ + "Provider": "openai", + "Model": "gpt-5.4", + "Workdir": "/run", + "Mode": "act", + }, + "ToolStates": []any{ + map[string]any{ + "ToolCallID": "call-1", + "ToolName": "filesystem_read_file", + "Status": "running", + "Message": "working", + "DurationMS": int64(12), + }, + }, + "Usage": map[string]any{ + "InputTokens": 1, + "OutputTokens": 2, + "TotalTokens": 3, + }, + "SessionUsage": map[string]any{ + "InputTokens": 10, + "OutputTokens": 20, + "TotalTokens": 30, + }, + }, + } + app := newWorkspaceResultTestApp(t, runtime, workdir) + app.state.ActiveSessionID = "session-1" + app.state.ActiveRunID = "run-1" + + app.refreshRuntimeSourceSnapshot() + + if app.state.RunContext.RunID != "run-1" || app.state.RunContext.Workdir != "/run" { + t.Fatalf("expected run snapshot context to override session snapshot, got %+v", app.state.RunContext) + } + if len(app.state.ToolStates) != 1 || app.state.ToolStates[0].ToolName != "filesystem_read_file" { + t.Fatalf("expected run snapshot tool states, got %+v", app.state.ToolStates) + } + if app.state.TokenUsage.RunTotalTokens != 3 || app.state.TokenUsage.SessionTotalTokens != 30 { + t.Fatalf("expected run snapshot usage, got %+v", app.state.TokenUsage) + } +} + type workspaceResultRuntimeStub struct { - summaries []agentsession.Summary + summaries []agentsession.Summary + sessions map[string]agentsession.Session + sessionContext any + sessionUsage any + runSnapshot any } func (s *workspaceResultRuntimeStub) Run(context.Context, agentruntime.UserInput) error { @@ -188,10 +493,25 @@ func (s *workspaceResultRuntimeStub) ListSessions(context.Context) ([]agentsessi return append([]agentsession.Summary(nil), s.summaries...), nil } -func (s *workspaceResultRuntimeStub) LoadSession(context.Context, string) (agentsession.Session, error) { +func (s *workspaceResultRuntimeStub) LoadSession(_ context.Context, id string) (agentsession.Session, error) { + if session, ok := s.sessions[id]; ok { + return session, nil + } return agentsession.Session{}, nil } +func (s *workspaceResultRuntimeStub) GetSessionContext(context.Context, string) (any, error) { + return s.sessionContext, nil +} + +func (s *workspaceResultRuntimeStub) GetSessionUsage(context.Context, string) (any, error) { + return s.sessionUsage, nil +} + +func (s *workspaceResultRuntimeStub) GetRunSnapshot(context.Context, string) (any, error) { + return s.runSnapshot, nil +} + func newWorkspaceResultTestApp(t *testing.T, runtime agentruntime.Runtime, workdir string) App { t.Helper() diff --git a/internal/tui/core/app/update_additional_test.go b/internal/tui/core/app/update_additional_test.go new file mode 100644 index 00000000..23056e76 --- /dev/null +++ b/internal/tui/core/app/update_additional_test.go @@ -0,0 +1,412 @@ +package tui + +import ( + "context" + "errors" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + providertypes "neo-code/internal/provider/types" + agentruntime "neo-code/internal/runtime" + agentsession "neo-code/internal/session" +) + +func TestShouldHandleRuntimeEventForegroundRules(t *testing.T) { + app := newWorkspaceResultTestApp(t, &slashCommandRuntimeStub{}, t.TempDir()) + app.state.ActiveRunID = "run-current" + app.state.ActiveSessionID = "session-current" + + if !app.shouldHandleRuntimeEvent(agentruntime.RuntimeEvent{ + RunID: "run-current", + SessionID: "session-current", + }) { + t.Fatalf("expected matching foreground event to be accepted") + } + if !app.shouldHandleRuntimeEvent(agentruntime.RuntimeEvent{ + RunID: "", + SessionID: "session-current", + }) { + t.Fatalf("expected event without run id to reuse active session") + } + if app.shouldHandleRuntimeEvent(agentruntime.RuntimeEvent{ + RunID: "run-other", + SessionID: "session-current", + }) { + t.Fatalf("expected different run id to be rejected") + } + if app.shouldHandleRuntimeEvent(agentruntime.RuntimeEvent{ + RunID: "run-current", + SessionID: "session-other", + }) { + t.Fatalf("expected different session id to be rejected") + } +} + +func TestRuntimeEventSupplementalHandlers(t *testing.T) { + app := newWorkspaceResultTestApp(t, &slashCommandRuntimeStub{}, t.TempDir()) + app.state.IsAgentRunning = true + + if dirty := runtimeEventUserMessageHandler(&app, agentruntime.RuntimeEvent{RunID: "run-queued"}); dirty { + t.Fatalf("expected user message handler not to dirty transcript") + } + if app.state.ActiveRunID != "run-queued" { + t.Fatalf("expected user message handler to sync run id, got %q", app.state.ActiveRunID) + } + if app.state.StatusText != statusThinking { + t.Fatalf("expected user message handler to set thinking status, got %q", app.state.StatusText) + } + if !app.runProgressKnown || app.runProgressLabel != "Queued" { + t.Fatalf("expected user message handler to set queued progress, got known=%v label=%q", app.runProgressKnown, app.runProgressLabel) + } + + if dirty := runtimeEventToolChunkHandler(&app, agentruntime.RuntimeEvent{Payload: " chunk output "}); dirty { + t.Fatalf("expected tool chunk handler not to dirty transcript") + } + if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title != "Tool output" { + t.Fatalf("expected tool chunk handler to append activity, got %+v", app.activities) + } + + activityCount := len(app.activities) + if dirty := runtimeEventToolChunkHandler(&app, agentruntime.RuntimeEvent{Payload: " "}); dirty { + t.Fatalf("expected blank tool chunk handler not to dirty transcript") + } + if len(app.activities) != activityCount { + t.Fatalf("expected blank tool chunk to be ignored, got %+v", app.activities) + } +} + +func TestAppAppendHelpers(t *testing.T) { + app := newWorkspaceResultTestApp(t, &slashCommandRuntimeStub{}, t.TempDir()) + + app.appendInlineMessage(roleAssistant, " hello world ") + app.appendInlineMessage(roleAssistant, " ") + if len(app.activeMessages) != 1 || app.activeMessages[0].Content != "hello world" { + t.Fatalf("expected inline message helper to trim and skip blanks, got %+v", app.activeMessages) + } + + app.appendActivity("tool", "", "detail only", false) + if len(app.activities) != 1 || app.activities[0].Title != "detail only" || app.activities[0].Detail != "" { + t.Fatalf("expected blank-title activity to promote detail into title, got %+v", app.activities) + } + + for i := 0; i < maxActivityEntries+2; i++ { + app.appendActivity("tool", "entry", "detail", false) + } + if len(app.activities) != maxActivityEntries { + t.Fatalf("expected activity helper to cap entries at %d, got %d", maxActivityEntries, len(app.activities)) + } + + if !app.lastAssistantMatches("hello world") { + t.Fatalf("expected assistant content match") + } + if app.lastAssistantMatches("other") { + t.Fatalf("expected mismatched assistant content to return false") + } +} + +func TestHandleImmediateSlashCommandPaths(t *testing.T) { + t.Run("clear resets draft state", func(t *testing.T) { + app := newWorkspaceResultTestApp(t, &slashCommandRuntimeStub{}, t.TempDir()) + app.state.ActiveSessionID = "session-1" + app.state.ActiveSessionTitle = "Session One" + app.state.ActiveRunID = "run-1" + app.activeMessages = []providertypes.Message{{Role: roleAssistant, Content: "history"}} + + handled, cmd := app.handleImmediateSlashCommand("/clear") + if !handled || cmd != nil { + t.Fatalf("expected /clear to be handled locally") + } + if app.state.ActiveSessionID != "" || len(app.activeMessages) != 0 { + t.Fatalf("expected /clear to reset draft state, got state=%+v messages=%+v", app.state, app.activeMessages) + } + if app.state.StatusText != "[System] Cleared current draft/history." { + t.Fatalf("unexpected /clear status: %q", app.state.StatusText) + } + }) + + t.Run("compact rejects extra arguments", func(t *testing.T) { + app := newWorkspaceResultTestApp(t, &slashCommandRuntimeStub{}, t.TempDir()) + + handled, cmd := app.handleImmediateSlashCommand("/compact now") + if !handled || cmd != nil { + t.Fatalf("expected /compact with args to be handled locally") + } + if app.state.ExecutionError == "" || len(app.activeMessages) != 1 || app.activeMessages[0].Role != roleError { + t.Fatalf("expected usage error inline message, got state=%+v messages=%+v", app.state, app.activeMessages) + } + }) + + t.Run("compact requires existing session", func(t *testing.T) { + app := newWorkspaceResultTestApp(t, &slashCommandRuntimeStub{}, t.TempDir()) + + handled, cmd := app.handleImmediateSlashCommand("/compact") + if !handled || cmd != nil { + t.Fatalf("expected /compact without session to be handled locally") + } + if app.state.ExecutionError != "compact requires an existing session" { + t.Fatalf("unexpected /compact empty session error: %q", app.state.ExecutionError) + } + }) + + t.Run("compact rejects while busy", func(t *testing.T) { + app := newWorkspaceResultTestApp(t, &slashCommandRuntimeStub{}, t.TempDir()) + app.state.ActiveSessionID = "session-1" + app.state.IsAgentRunning = true + + handled, cmd := app.handleImmediateSlashCommand("/compact") + if !handled || cmd != nil { + t.Fatalf("expected busy /compact to be handled locally") + } + if app.state.ExecutionError != "compact is already running, please wait" { + t.Fatalf("unexpected busy compact error: %q", app.state.ExecutionError) + } + }) + + t.Run("compact dispatches runtime command", func(t *testing.T) { + runtime := &slashCommandRuntimeStub{} + app := newWorkspaceResultTestApp(t, runtime, t.TempDir()) + app.state.ActiveSessionID = "session-compact" + app.state.CurrentTool = "filesystem_read_file" + app.state.StreamingReply = true + + handled, cmd := app.handleImmediateSlashCommand("/compact") + if !handled || cmd == nil { + t.Fatalf("expected /compact to return runtime command") + } + if !app.state.IsCompacting || app.state.CurrentTool != "" || app.state.StatusText != statusCompacting { + t.Fatalf("expected compact state to switch into compacting mode, got %+v", app.state) + } + + msg := cmd() + finished, ok := msg.(compactFinishedMsg) + if !ok { + t.Fatalf("expected compactFinishedMsg, got %T", msg) + } + if finished.Err != nil { + t.Fatalf("expected compact runtime command to succeed, got %v", finished.Err) + } + if runtime.compactCalls != 1 || runtime.lastCompactInput.SessionID != "session-compact" { + t.Fatalf("expected runtime compact command to execute once, got calls=%d input=%+v", runtime.compactCalls, runtime.lastCompactInput) + } + }) + + t.Run("exit returns quit command", func(t *testing.T) { + app := newWorkspaceResultTestApp(t, &slashCommandRuntimeStub{}, t.TempDir()) + + handled, cmd := app.handleImmediateSlashCommand("/exit") + if !handled || cmd == nil { + t.Fatalf("expected /exit to return quit command") + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Fatalf("expected quit command message, got %T", cmd()) + } + }) + + t.Run("unknown slash command falls through", func(t *testing.T) { + app := newWorkspaceResultTestApp(t, &slashCommandRuntimeStub{}, t.TempDir()) + + handled, cmd := app.handleImmediateSlashCommand("/unknown") + if handled || cmd != nil { + t.Fatalf("expected unknown slash command to fall through") + } + }) +} + +func TestUpdateHelperFunctions(t *testing.T) { + t.Run("tab focus and composer helpers", func(t *testing.T) { + app := newWorkspaceResultTestApp(t, &slashCommandRuntimeStub{}, t.TempDir()) + app.keys = newKeyMap() + app.focus = panelInput + app.input.SetValue("hello") + app.state.InputText = "hello" + + if !app.shouldHandleTabAsInput(tea.KeyMsg{Type: tea.KeyTab}) { + t.Fatalf("expected tab to be treated as input when composer has text") + } + app.state.ActivePicker = pickerFile + if app.shouldHandleTabAsInput(tea.KeyMsg{Type: tea.KeyTab}) { + t.Fatalf("expected active picker to disable tab-as-input") + } + app.state.ActivePicker = pickerNone + + app.focus = panelSessions + app.focusNext() + if app.focus != panelTranscript { + t.Fatalf("expected focusNext to advance to transcript, got %v", app.focus) + } + app.focusPrev() + if app.focus != panelSessions { + t.Fatalf("expected focusPrev to move back to sessions, got %v", app.focus) + } + + app.focus = panelInput + app.applyFocus() + if !app.input.Focused() { + t.Fatalf("expected input panel focus to focus textarea") + } + app.focus = panelActivity + app.applyFocus() + if app.input.Focused() { + t.Fatalf("expected non-input focus to blur textarea") + } + + app.input.SetValue("line1") + app.normalizeComposerHeight() + beforeHeight := app.input.Height() + app.growComposerForNewline() + if app.input.Height() < beforeHeight { + t.Fatalf("expected growComposerForNewline not to shrink composer") + } + app.input.SetValue("line1\nline2\nline3") + app.normalizeComposerHeight() + if app.input.Height() != 3 { + t.Fatalf("expected normalizeComposerHeight to follow line count, got %d", app.input.Height()) + } + }) + + t.Run("paste heuristics and viewport helpers", func(t *testing.T) { + app := newWorkspaceResultTestApp(t, &slashCommandRuntimeStub{}, t.TempDir()) + app.keys = newKeyMap() + now := time.Now() + if !app.shouldTreatEnterAsNewline(tea.KeyMsg{Type: tea.KeyEnter, Paste: true}, now) { + t.Fatalf("expected pasted enter to become newline") + } + + app.noteInputEdit("", "hello", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("hello")}, now) + if !app.pasteMode { + t.Fatalf("expected multi-rune edit to enable paste mode") + } + app.resetPasteHeuristics() + if app.pasteMode || !app.lastInputEditAt.IsZero() || !app.lastPasteLikeAt.IsZero() { + t.Fatalf("expected resetPasteHeuristics to clear paste tracking") + } + + app.transcript.SetContent("1\n2\n3\n4\n5\n6") + app.transcript.Height = 2 + app.transcript.GotoBottom() + before := app.transcript.YOffset + app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")}) + if app.transcript.YOffset >= before { + t.Fatalf("expected scroll up key to move viewport upward") + } + }) + + t.Run("runtime bridge helpers", func(t *testing.T) { + runtime := &slashCommandRuntimeStub{ + switchResult: agentruntime.WorkspaceSwitchResult{ + Workdir: "/repo/pkg", + WorkspaceRoot: "/repo", + WorkspaceChanged: true, + ResetToDraft: true, + }, + } + app := newWorkspaceResultTestApp(t, runtime, t.TempDir()) + + runCmd := runAgent(runtime, "run-1", "session-1", "/repo", "hello") + msg := runCmd() + finished, ok := msg.(runFinishedMsg) + if !ok || finished.Err != nil { + t.Fatalf("expected run command to return successful runFinishedMsg, got %T %+v", msg, msg) + } + if runtime.runCalls != 1 || runtime.lastRunInput.RunID != "run-1" || runtime.lastRunInput.Workdir != "/repo" { + t.Fatalf("expected run command to forward runtime input, got calls=%d input=%+v", runtime.runCalls, runtime.lastRunInput) + } + + workdirCmd := runSessionWorkdirCommand(runtime, "session-1", "/repo", "/cwd ./pkg") + workdirMsg := workdirCmd() + workdirResult, ok := workdirMsg.(sessionWorkdirResultMsg) + if !ok { + t.Fatalf("expected sessionWorkdirResultMsg, got %T", workdirMsg) + } + if !workdirResult.ResetToDraft || workdirResult.Workdir != "/repo/pkg" || runtime.switchCalls != 1 { + t.Fatalf("expected workdir command to forward runtime switch result, got result=%+v calls=%d input=%+v", workdirResult, runtime.switchCalls, runtime.lastSwitchInput) + } + + events := make(chan agentruntime.RuntimeEvent, 1) + events <- agentruntime.RuntimeEvent{Type: agentruntime.EventAgentChunk, Payload: "hello"} + cmd := ListenForRuntimeEvent(events) + if _, ok := cmd().(RuntimeMsg); !ok { + t.Fatalf("expected runtime event listener to map event into RuntimeMsg") + } + close(events) + if _, ok := ListenForRuntimeEvent(events)().(RuntimeClosedMsg); !ok { + t.Fatalf("expected closed listener to map into RuntimeClosedMsg") + } + + if cmd := app.requestModelCatalogRefresh(""); cmd != nil { + t.Fatalf("expected empty provider refresh request to be skipped") + } + cmd = app.requestModelCatalogRefresh("openai") + if app.modelRefreshID != "openai" { + t.Fatalf("expected provider refresh id to be recorded once, got %q", app.modelRefreshID) + } + if cmd := app.requestModelCatalogRefresh("openai"); cmd != nil { + t.Fatalf("expected duplicate provider refresh request to be suppressed") + } + }) +} + +type slashCommandRuntimeStub struct { + compactCalls int + lastCompactInput agentruntime.CompactInput + compactErr error + runCalls int + lastRunInput agentruntime.UserInput + runErr error + switchCalls int + lastSwitchInput agentruntime.WorkspaceSwitchInput + switchResult agentruntime.WorkspaceSwitchResult + switchErr error +} + +func (s *slashCommandRuntimeStub) Run(_ context.Context, input agentruntime.UserInput) error { + s.runCalls++ + s.lastRunInput = input + return s.runErr +} + +func (s *slashCommandRuntimeStub) Compact(_ context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) { + s.compactCalls++ + s.lastCompactInput = input + return agentruntime.CompactResult{}, s.compactErr +} + +func (s *slashCommandRuntimeStub) SwitchWorkspace(_ context.Context, input agentruntime.WorkspaceSwitchInput) (agentruntime.WorkspaceSwitchResult, error) { + s.switchCalls++ + s.lastSwitchInput = input + return s.switchResult, s.switchErr +} + +func (s *slashCommandRuntimeStub) ResolvePermission(context.Context, agentruntime.PermissionResolutionInput) error { + return nil +} + +func (s *slashCommandRuntimeStub) CancelActiveRun() bool { + return false +} + +func (s *slashCommandRuntimeStub) Events() <-chan agentruntime.RuntimeEvent { + return make(chan agentruntime.RuntimeEvent) +} + +func (s *slashCommandRuntimeStub) ListSessions(context.Context) ([]agentsession.Summary, error) { + return nil, nil +} + +func (s *slashCommandRuntimeStub) LoadSession(context.Context, string) (agentsession.Session, error) { + return agentsession.Session{}, nil +} + +func (s *slashCommandRuntimeStub) GetSessionContext(context.Context, string) (any, error) { + return nil, errors.New("not implemented") +} + +func (s *slashCommandRuntimeStub) GetSessionUsage(context.Context, string) (any, error) { + return nil, errors.New("not implemented") +} + +func (s *slashCommandRuntimeStub) GetRunSnapshot(context.Context, string) (any, error) { + return nil, errors.New("not implemented") +} diff --git a/internal/tui/infra/infra_test.go b/internal/tui/infra/infra_test.go index 1e9775ec..845ff639 100644 --- a/internal/tui/infra/infra_test.go +++ b/internal/tui/infra/infra_test.go @@ -271,7 +271,7 @@ func TestDefaultWorkspaceCommandExecutor(t *testing.T) { cfg := config.Config{ Workdir: workdir, Shell: shellName, - ToolTimeoutSec: 1, + ToolTimeoutSec: 5, } if _, err := DefaultWorkspaceCommandExecutor(context.Background(), cfg, "", " "); err == nil { @@ -328,11 +328,11 @@ func workspaceExecutorCommands() (shell string, success string, noOutput string, "Write-Output 'OK'", "$null = 1", "Write-Error 'failed'; exit 2", - "Start-Sleep -Seconds 2" + "Start-Sleep -Seconds 6" } return "bash", "printf 'OK\\n'", "true", "echo failed 1>&2; exit 2", - "sleep 2" + "sleep 6" } From 75ea7475b73c2efacb0db21134273db743fd4825 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Thu, 9 Apr 2026 16:04:20 +0800 Subject: [PATCH 5/6] fix(runtime): address workspace isolation review --- internal/runtime/runtime.go | 12 +-- internal/runtime/runtime_test.go | 120 +++++++++++++++++++++++++++ internal/runtime/workspace_switch.go | 27 +++++- 3 files changed, 152 insertions(+), 7 deletions(-) diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index f3867b9b..c10dfad2 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -209,9 +209,11 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { return errors.New("runtime: input content is empty") } - workspaceRoot, workspaceWorkdir := s.currentWorkspaceState() - defaultWorkdir := effectiveWorkspaceBase(workspaceWorkdir, workspaceRoot) - session, err := s.loadOrCreateSession(ctx, input.SessionID, input.Content, defaultWorkdir, input.Workdir) + store, workspaceRoot, defaultWorkdir, err := s.resolveRunWorkspaceStore(ctx, input) + if err != nil { + return s.handleRunError(ctx, input.RunID, input.SessionID, err) + } + session, err := s.loadOrCreateSession(ctx, store, input.SessionID, input.Content, defaultWorkdir, input.Workdir) if err != nil { return s.handleRunError(ctx, input.RunID, input.SessionID, err) } @@ -224,7 +226,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { } session.Messages = append(session.Messages, userMessage) session.UpdatedAt = time.Now() - if err := s.currentSessionStore().Save(ctx, &session); err != nil { + if err := store.Save(ctx, &session); err != nil { return s.handleRunError(ctx, input.RunID, session.ID, err) } s.emit(ctx, EventUserMessage, input.RunID, session.ID, userMessage) @@ -435,12 +437,12 @@ func (s *Service) LoadSession(ctx context.Context, id string) (agentsession.Sess func (s *Service) loadOrCreateSession( ctx context.Context, + store agentsession.Store, sessionID string, title string, defaultWorkdir string, requestedWorkdir string, ) (agentsession.Session, error) { - store := s.currentSessionStore() if store == nil { return agentsession.Session{}, errors.New("runtime: session store is nil") } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index cb9e25d1..bcf5e078 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -2458,6 +2458,71 @@ func TestServiceRunUsesInputWorkdirForNewSession(t *testing.T) { } } +func TestServiceRunRoutesNewSessionToRequestedWorkspaceStore(t *testing.T) { + manager := newRuntimeConfigManager(t) + workspaceA := t.TempDir() + workspaceB := t.TempDir() + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Workdir = workspaceA + return nil + }); err != nil { + t.Fatalf("update default workdir: %v", err) + } + + router := agentsession.NewScopedStoreRouter(t.TempDir(), workspaceA) + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + builder := &stubContextBuilder{} + scripted := &scriptedProvider{ + streams: [][]providertypes.StreamEvent{ + {providertypes.NewTextDeltaStreamEvent("done")}, + }, + } + + service := NewWithFactory(manager, registry, router, &scriptedProviderFactory{provider: scripted}, builder) + if err := service.Run(context.Background(), UserInput{ + RunID: "run-routed-workspace-store", + Content: "hello", + Workdir: workspaceB, + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + storeA := router.StoreForWorkspace(workspaceA) + summariesA, err := storeA.ListSummaries(context.Background()) + if err != nil { + t.Fatalf("ListSummaries() for workspace A error = %v", err) + } + if len(summariesA) != 0 { + t.Fatalf("expected workspace A to stay empty, got %+v", summariesA) + } + + storeB := router.StoreForWorkspace(workspaceB) + summariesB, err := storeB.ListSummaries(context.Background()) + if err != nil { + t.Fatalf("ListSummaries() for workspace B error = %v", err) + } + if len(summariesB) != 1 { + t.Fatalf("expected exactly one workspace B session, got %+v", summariesB) + } + + created, err := storeB.Load(context.Background(), summariesB[0].ID) + if err != nil { + t.Fatalf("Load() for workspace B session error = %v", err) + } + if created.Workdir != workspaceB { + t.Fatalf("expected session workdir %q, got %q", workspaceB, created.Workdir) + } + if builder.lastInput.Metadata.Workdir != workspaceB { + t.Fatalf("expected context metadata workdir %q, got %q", workspaceB, builder.lastInput.Metadata.Workdir) + } + + workspaceRoot, activeWorkdir := service.currentWorkspaceState() + if workspaceRoot != filepath.Clean(workspaceB) || activeWorkdir != filepath.Clean(workspaceB) { + t.Fatalf("expected runtime workspace state to move to workspace B, got root=%q workdir=%q", workspaceRoot, activeWorkdir) + } +} + func TestServiceSetSessionWorkdir(t *testing.T) { manager := newRuntimeConfigManager(t) repoRoot := t.TempDir() @@ -2792,6 +2857,61 @@ func TestServiceSetSessionWorkdirNoopDoesNotSave(t *testing.T) { } } +func TestServiceSwitchWorkspaceKeepsRuntimeStateWhenSessionSaveFails(t *testing.T) { + manager := newRuntimeConfigManager(t) + repoRoot := t.TempDir() + defaultWorkdir := filepath.Join(repoRoot, "app") + target := filepath.Join(repoRoot, "pkg") + if err := os.MkdirAll(defaultWorkdir, 0o755); err != nil { + t.Fatalf("mkdir default workdir: %v", err) + } + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("mkdir target: %v", err) + } + if output, err := exec.Command("git", "-C", repoRoot, "init").CombinedOutput(); err != nil { + t.Fatalf("git init: %v (%s)", err, string(output)) + } + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Workdir = defaultWorkdir + return nil + }); err != nil { + t.Fatalf("update default workdir: %v", err) + } + + baseStore := newMemoryStore() + session := agentsession.NewWithWorkdir("switch-save-fail", defaultWorkdir) + baseStore.sessions[session.ID] = cloneSession(session) + store := &failingStore{ + Store: baseStore, + saveErr: errors.New("save failed"), + failOnSave: 1, + } + + service := NewWithFactory(manager, tools.NewRegistry(), store, &scriptedProviderFactory{provider: &scriptedProvider{}}, nil) + rootBefore, workdirBefore := service.currentWorkspaceState() + + _, err := service.SwitchWorkspace(context.Background(), WorkspaceSwitchInput{ + SessionID: session.ID, + RequestedPath: target, + }) + if err == nil || !containsError(err, "save failed") { + t.Fatalf("expected save failure, got %v", err) + } + + rootAfter, workdirAfter := service.currentWorkspaceState() + if rootAfter != rootBefore || workdirAfter != workdirBefore { + t.Fatalf("expected runtime workspace state to stay unchanged, before=(%q,%q) after=(%q,%q)", rootBefore, workdirBefore, rootAfter, workdirAfter) + } + + loaded, err := baseStore.Load(context.Background(), session.ID) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if loaded.Workdir != defaultWorkdir { + t.Fatalf("expected persisted session workdir to remain %q, got %q", defaultWorkdir, loaded.Workdir) + } +} + func TestIsRetryableProviderError(t *testing.T) { t.Parallel() diff --git a/internal/runtime/workspace_switch.go b/internal/runtime/workspace_switch.go index 92a76967..b5fcaace 100644 --- a/internal/runtime/workspace_switch.go +++ b/internal/runtime/workspace_switch.go @@ -76,6 +76,27 @@ func resolveWorkspaceSelection(ctx context.Context, baseWorkdir string, requeste return workdir, root, nil } +// resolveRunWorkspaceStore 根据本次 Run 输入预先确定应使用的会话分桶与默认工作目录。 +func (s *Service) resolveRunWorkspaceStore(ctx context.Context, input UserInput) (agentsession.Store, string, string, error) { + currentRoot, currentWorkdir := s.currentWorkspaceState() + workspaceRoot := currentRoot + defaultWorkdir := effectiveWorkspaceBase(currentWorkdir, currentRoot) + + if strings.TrimSpace(input.SessionID) == "" && strings.TrimSpace(input.Workdir) != "" { + _, resolvedRoot, err := resolveWorkspaceSelection(ctx, defaultWorkdir, input.Workdir) + if err != nil { + return nil, "", "", err + } + workspaceRoot = resolvedRoot + } + + store := s.sessionStoreForWorkspace(workspaceRoot) + if store == nil { + return nil, "", "", errors.New("runtime: session store is nil") + } + return store, workspaceRoot, defaultWorkdir, nil +} + // resolveRequestedWorkspacePath 将请求路径解析为存在的绝对目录。 func resolveRequestedWorkspacePath(baseWorkdir string, requestedPath string) (string, error) { base, err := normalizeExistingWorkdir(baseWorkdir) @@ -263,18 +284,19 @@ func (s *Service) SwitchWorkspace(ctx context.Context, input WorkspaceSwitchInpu return result, nil } - s.setWorkspaceState(workspaceRoot, workdir) if strings.TrimSpace(session.ID) == "" { + s.setWorkspaceState(workspaceRoot, workdir) return result, nil } result.KeepSession = true if session.Workdir == workdir { + s.setWorkspaceState(workspaceRoot, workdir) result.Session = session return result, nil } - store := s.currentSessionStore() + store := s.sessionStoreForWorkspace(workspaceRoot) if store == nil { return WorkspaceSwitchResult{}, errors.New("runtime: session store is nil") } @@ -283,6 +305,7 @@ func (s *Service) SwitchWorkspace(ctx context.Context, input WorkspaceSwitchInpu if err := store.Save(ctx, &session); err != nil { return WorkspaceSwitchResult{}, err } + s.setWorkspaceState(workspaceRoot, workdir) result.Session = session return result, nil } From 70ce0b048f780e06caf7459bcc53d95b906d1044 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Thu, 9 Apr 2026 16:13:55 +0800 Subject: [PATCH 6/6] fix(openai): handle canceled stream before EOF --- internal/provider/openai/openai_test.go | 11 ++++++++--- internal/provider/openai/response.go | 9 +++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index d16edc56..6659d7e1 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -547,11 +547,16 @@ func TestConsumeStream_ContextCancellation(t *testing.T) { sseData := `data: {"id":"a","choices":[{"delta":{"content":"should not emit"}}]} -` + ` events := make(chan providertypes.StreamEvent, 1) err = p.consumeStream(ctx, strings.NewReader(sseData), events) - if err != nil && !errors.Is(err, context.Canceled) { - t.Fatalf("expected context.Canceled or nil, got: %v", err) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got: %v", err) + } + select { + case evt := <-events: + t.Fatalf("expected no events after cancellation, got: %#v", evt) + default: } } diff --git a/internal/provider/openai/response.go b/internal/provider/openai/response.go index 5026a15d..acef073c 100644 --- a/internal/provider/openai/response.go +++ b/internal/provider/openai/response.go @@ -78,9 +78,15 @@ func (p *Provider) consumeStream( } for { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } line, err := reader.ReadLine() if err != nil && !errors.Is(err, io.EOF) { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } // 非 EOF 的读取错误:先刷新缓冲的 data 行,再包装为流中断, // 避免中断前最后一段数据丢失。 if flushErr := flushPendingData(); flushErr != nil { @@ -116,6 +122,9 @@ func (p *Provider) consumeStream( } if errors.Is(err, io.EOF) { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } // [DEBUG] 流 EOF 时打印关键状态,用于诊断截断原因 log.Printf("[DEBUG-STREAM] EOF reached: done=%v, finishReason=%q, totalRead=%d, toolCallCount=%d", done, finishReason, reader.totalRead, len(toolCalls))