From adf9f65e74883eaa122614b935b456992032ad65 Mon Sep 17 00:00:00 2001 From: jpia <6011922+jpia@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:33:39 -0500 Subject: [PATCH 1/5] feat: add support for persisting subagent conversation histories --- cmd/late/main.go | 25 ++- cmd/late/main_test.go | 97 ++++++++ docs/quickstart.md | 3 + docs/quickstart.zh-CN.md | 3 + internal/agent/agent.go | 26 ++- internal/agent/agent_test.go | 261 +++++++++++++++++++++- internal/config/config.go | 18 ++ internal/config/config_test.go | 54 +++++ internal/orchestrator/base.go | 18 +- internal/orchestrator/base_test.go | 73 ++++++ internal/session/models_test.go | 105 +++++++++ internal/session/paths.go | 44 ++++ internal/session/paths_test.go | 138 ++++++++++++ internal/session/session.go | 16 +- internal/session/subagent_session_test.go | 69 ++++++ 15 files changed, 941 insertions(+), 9 deletions(-) create mode 100644 internal/session/paths.go create mode 100644 internal/session/paths_test.go create mode 100644 internal/session/subagent_session_test.go diff --git a/cmd/late/main.go b/cmd/late/main.go index a7fc28c..11a37bc 100644 --- a/cmd/late/main.go +++ b/cmd/late/main.go @@ -39,6 +39,7 @@ func main() { enableSubagentsReq := flag.Bool("enable-subagents", true, "Enable subagent usage") gemmaThinkingReq := flag.Bool("gemma-thinking", false, "Prepend <|think|> token to system prompt for Gemma 4 models") subagentMaxTurns := flag.Int("subagent-max-turns", 500, "Maximum number of turns for subagents (default: 500)") + saveSubagentHistoriesReq := flag.Bool("save-subagent-histories", false, "Persist subagent conversation histories to disk (default: off)") enableSqzReq := flag.Bool("enable-sqz", false, "Enable sqz context compression (if available)") appendSystemPromptReq := flag.String("append-system-prompt", "", "Append text to the system prompt after processing") versionReq := flag.Bool("version", false, "Show version") @@ -174,6 +175,12 @@ func main() { historyPath = loadedHistoryPath } + // Effective session ID for this run — derived from the FINAL history path so + // resumed sessions keep their original ID (the sessionID var above is a fresh + // timestamp even on resume). Used to place subagent histories under the right + // per-session folder. + effectiveSessionID := strings.TrimSuffix(filepath.Base(historyPath), ".json") + // Load existing history history, err := session.LoadHistory(historyPath) if err != nil { @@ -209,6 +216,15 @@ func main() { } } + // Resolve subagent history persistence opt-in (explicit CLI flag > config file). + saveSubagentHistoriesCLI := false + flag.Visit(func(f *flag.Flag) { + if f.Name == "save-subagent-histories" { + saveSubagentHistoriesCLI = true + } + }) + saveSubagentHistories := appconfig.ResolveSaveSubagentHistories(appConfig, saveSubagentHistoriesCLI, *saveSubagentHistoriesReq) + // Initialize Core Components resolvedOpenAIConfig := appconfig.ResolveOpenAISettings(appConfig) resolvedClientConfig := client.Config{ @@ -361,7 +377,7 @@ func main() { currentSubagentClient = subagentClient } - child, err := agent.NewSubagentOrchestrator(currentSubagentClient, goal, ctxFiles, agentType, enabledTools, *injectCWDReq, *gemmaThinkingReq, *subagentMaxTurns, rootAgent, p) + child, err := agent.NewSubagentOrchestrator(currentSubagentClient, goal, ctxFiles, agentType, enabledTools, *injectCWDReq, *gemmaThinkingReq, *subagentMaxTurns, effectiveSessionID, saveSubagentHistories, rootAgent, p) if err != nil { return "", err } @@ -547,6 +563,13 @@ func handleSessionDelete(id string) { os.Exit(1) } + // Delete the session's subagent history folder (hierarchical layout). No-op for + // legacy flat sessions without a folder. Non-fatal: the session itself is already + // gone, so don't block the success message on leftover artifacts. + if err := session.RemoveSessionFolder(meta.ID); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to delete subagent history folder: %v\n", err) + } + fmt.Printf("Deleted session: %s\n", meta.Title) } diff --git a/cmd/late/main_test.go b/cmd/late/main_test.go index 8f82cf7..41d5512 100644 --- a/cmd/late/main_test.go +++ b/cmd/late/main_test.go @@ -3,7 +3,13 @@ package main import ( "context" "encoding/json" + "os" + "path/filepath" "testing" + "time" + + "late/internal/client" + "late/internal/session" ) type mcpToolStub struct { @@ -29,3 +35,94 @@ func TestMCPToolEnabledSupportsBareNames(t *testing.T) { t.Fatal("namespaced setting did not override bare-name setting") } } + +// writeTestSession creates a flat session in the injected sessions directory: +// /.json (history) and /.meta.json. It returns both paths. +func writeTestSession(t *testing.T, sessionsDir, id string) (metaPath, historyPath string) { + t.Helper() + + historyPath = filepath.Join(sessionsDir, id+".json") + if err := session.SaveHistory(historyPath, []client.ChatMessage{ + {Role: "user", Content: client.TextContent("hello")}, + }); err != nil { + t.Fatalf("SaveHistory(%s): %v", id, err) + } + + if err := session.SaveSessionMeta(session.SessionMeta{ + ID: id, + Title: "Test session " + id, + CreatedAt: time.Now(), + LastUpdated: time.Now(), + HistoryPath: historyPath, + MessageCount: 1, + }); err != nil { + t.Fatalf("SaveSessionMeta(%s): %v", id, err) + } + + return filepath.Join(sessionsDir, id+".meta.json"), historyPath +} + +// injectSessionDir points session.SessionDir at a temp dir for the test's duration. +func injectSessionDir(t *testing.T) string { + t.Helper() + + tmp := t.TempDir() + oldDir := session.SessionDir + session.SessionDir = func() (string, error) { return tmp, nil } + t.Cleanup(func() { session.SessionDir = oldDir }) + + return tmp +} + +func assertFileGone(t *testing.T, path string) { + t.Helper() + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected %s to be removed, but it still exists", path) + } +} + +func assertFileExists(t *testing.T, path string) { + t.Helper() + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected %s to still exist: %v", path, err) + } +} + +func TestHandleSessionDelete_RemovesSubagentFolder(t *testing.T) { + tmp := injectSessionDir(t) + + metaA, historyA := writeTestSession(t, tmp, "session-20250101-123456") + // Session A also has the hierarchical subagent history folder. + folderA := filepath.Join(tmp, "session-20250101-123456") + subagentsDir := filepath.Join(folderA, "subagents") + if err := os.MkdirAll(subagentsDir, 0700); err != nil { + t.Fatalf("creating subagent dir: %v", err) + } + if err := os.WriteFile(filepath.Join(subagentsDir, "researcher-subagent-0.json"), []byte("[]"), 0600); err != nil { + t.Fatalf("writing subagent history: %v", err) + } + + metaB, historyB := writeTestSession(t, tmp, "session-20250102-999999") + + handleSessionDelete("session-20250101-123456") + + // Session A: meta, history, and the entire subagent folder are all gone. + assertFileGone(t, metaA) + assertFileGone(t, historyA) + assertFileGone(t, folderA) + + // Session B is untouched. + assertFileExists(t, metaB) + assertFileExists(t, historyB) +} + +func TestHandleSessionDelete_LegacyFlatSession(t *testing.T) { + tmp := injectSessionDir(t) + + metaC, historyC := writeTestSession(t, tmp, "session-20250103-000000") + + handleSessionDelete("session-20250103-000000") + + assertFileGone(t, metaC) + assertFileGone(t, historyC) +} diff --git a/docs/quickstart.md b/docs/quickstart.md index 058f11f..fa4edf0 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -242,6 +242,7 @@ You can also create an `.llmignore` file alongside your `.gitignore` to specific | `--subagent-max-turns ` | Set max turns per subagent (default: 500) | | `--append-system-prompt "..."` | Append text to the system prompt (e.g. further instructions) | | `--enable-images` | Treat models as supporting images (for none llama.cpp servers) | +| `--save-subagent-histories` | Persist subagent conversation histories to disk. Off by default (subagent transcripts are large); can also be enabled via `save_subagent_histories` in the config file | ## Sessions @@ -254,6 +255,8 @@ late session load # Resume a previous session late session delete # Delete a session ``` +By default, subagent conversations are kept in memory and discarded. With `--save-subagent-histories` (or `"save_subagent_histories": true` in the config file), each subagent's history is saved under the session folder — `~/.local/share/late/sessions//subagents/-subagent-.json` — while the parent session files stay in place. `late session delete ` removes the subagent folder along with the session. + ## Git Worktrees Late is designed for parallel development. You can manage Git worktrees directly to run separate agent instances in isolated environments: diff --git a/docs/quickstart.zh-CN.md b/docs/quickstart.zh-CN.md index f53e64f..28c0258 100644 --- a/docs/quickstart.zh-CN.md +++ b/docs/quickstart.zh-CN.md @@ -240,6 +240,7 @@ Late 的原生搜索工具会自动遵循你的项目 `.gitignore`,通过排 | `--subagent-max-turns ` | 设置每个子智能体的最大交互轮数 (默认:500) | | `--append-system-prompt "..."` | 向系统提示词的末尾追加文本(例如自定义的补充说明) | | `--enable-images` | 将模型视为支持图像(适用于非 llama.cpp 的服务器) | +| `--save-subagent-histories` | 将子智能体的对话历史记录持久化保存到磁盘。默认关闭(子智能体转录内容较大);也可以在配置文件中通过 `save_subagent_histories` 开启 | ## 会话管理 (Sessions) @@ -252,6 +253,8 @@ late session load # 恢复指定的历史会话 late session delete # 删除指定的会话 ``` +默认情况下,子智能体的对话仅保存在内存中,结束后即被丢弃。使用 `--save-subagent-histories`(或在配置文件中设置 `"save_subagent_histories": true`)后,每个子智能体的对话历史记录都会被保存到会话文件夹下 —— `~/.local/share/late/sessions//subagents/-subagent-.json` —— 而父会话的原始文件位置保持不变。运行 `late session delete ` 删除会话时,子智能体文件夹也会随之被一并移除。 + ## Git 工作树 (Git Worktrees) Late 是专为并行开发设计的。你可以直接管理 Git 工作树,从而在隔离的环境中运行多个独立的智能体实例: diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 269aeb1..8293a95 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -23,6 +23,8 @@ func NewSubagentOrchestrator( injectCWD bool, gemmaThinking bool, maxTurns int, + parentSessionID string, + saveSubagentHistory bool, parent common.Orchestrator, messenger tui.Messenger, ) (common.Orchestrator, error) { @@ -60,8 +62,27 @@ func NewSubagentOrchestrator( systemPrompt = "<|think|>" + systemPrompt } - // 2. Setup Subagent Session (Isolated History) - sess := session.New(c, "", []client.ChatMessage{}, systemPrompt, true) + // Mint the child ID up-front so it can be embedded in the subagent history path. + var id string + if p, ok := parent.(*orchestrator.BaseOrchestrator); ok { + id = p.NextChildID(agentType) + } else if parent != nil { + // Fallback for non-BaseOrchestrator parents (test fakes only). + id = fmt.Sprintf("%s-subagent-%d", agentType, len(parent.Children())) + } else { + id = fmt.Sprintf("%s-subagent-0", agentType) + } + + // 2. Setup Subagent Session (Isolated History; persisted only when opted in) + var subagentHistoryPath string + if saveSubagentHistory && parentSessionID != "" { + path, err := session.SubagentHistoryPath(parentSessionID, id) + if err != nil { + return nil, fmt.Errorf("failed to resolve subagent history path: %w", err) + } + subagentHistoryPath = path + } + sess := session.NewSubagentSession(c, subagentHistoryPath, []client.ChatMessage{}, systemPrompt) // Inherit all tools from parent (including MCP tools) if parent != nil && parent.Registry() != nil { @@ -101,7 +122,6 @@ func NewSubagentOrchestrator( } // 4. Create Orchestrator - id := fmt.Sprintf("%s-subagent-%d", agentType, len(parent.Children())) mws := parent.Middlewares() if messenger != nil { diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index b846378..df9157b 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -1,8 +1,11 @@ package agent import ( + "encoding/json" "os" + "path/filepath" "strings" + "sync" "testing" "late/internal/client" @@ -34,6 +37,8 @@ func TestNewSubagentOrchestratorWithGemmaThinking(t *testing.T) { false, // injectCWD true, // gemmaThinking 100, // maxTurns + "", // parentSessionID + false, // saveSubagentHistory parent, nil, // messenger ) @@ -66,6 +71,8 @@ func TestNewSubagentOrchestratorWithGemmaThinking(t *testing.T) { false, // injectCWD false, // gemmaThinking 100, // maxTurns + "", // parentSessionID + false, // saveSubagentHistory parent, nil, // messenger ) @@ -107,9 +114,11 @@ func TestNewSubagentOrchestratorGemmaThinkingWithCWD(t *testing.T) { []string{}, "coder", enabledTools, - true, // injectCWD - true, // gemmaThinking - 100, // maxTurns + true, // injectCWD + true, // gemmaThinking + 100, // maxTurns + "", // parentSessionID + false, // saveSubagentHistory parent, nil, // messenger ) @@ -165,6 +174,8 @@ func TestNewSubagentOrchestratorID(t *testing.T) { false, false, 100, + "", + false, parent, nil, ) @@ -176,3 +187,247 @@ func TestNewSubagentOrchestratorID(t *testing.T) { t.Errorf("Expected child ID to contain 'coder', got %s", child.ID()) } } + +// TestNewSubagentOrchestrator_ConcurrentSpawn is the FR2 regression test: +// concurrent spawns against a shared parent must never mint duplicate child IDs. +func TestNewSubagentOrchestrator_ConcurrentSpawn(t *testing.T) { + cfg := client.Config{BaseURL: "http://localhost:8080"} + c := client.NewClient(cfg) + + // Create a shared mock parent session and orchestrator + mockSession := session.New(c, "/tmp/mock-session.json", []client.ChatMessage{}, "mock system prompt", true) + parent := orchestrator.NewBaseOrchestrator("parent", mockSession, nil, 10) + + const numSpawn = 16 + var wg sync.WaitGroup + var mu sync.Mutex + ids := make([]string, 0, numSpawn) + + for i := 0; i < numSpawn; i++ { + wg.Add(1) + go func() { + defer wg.Done() + + child, err := NewSubagentOrchestrator( + c, + "goal", + nil, + "researcher", + map[string]bool{}, // enabledTools + false, // injectCWD + false, // gemmaThinking + 10, // maxTurns + "", // parentSessionID + false, // saveSubagentHistory + parent, + nil, // messenger + ) + if err != nil { + t.Errorf("Failed to create subagent orchestrator: %v", err) + return + } + + mu.Lock() + ids = append(ids, child.ID()) + mu.Unlock() + }() + } + wg.Wait() + + if len(ids) != numSpawn { + t.Fatalf("Expected %d spawned orchestrators, got %d", numSpawn, len(ids)) + } + + // Assert all IDs are unique (no collisions) + seen := make(map[string]bool, numSpawn) + for _, id := range ids { + if seen[id] { + t.Errorf("Duplicate child ID minted by concurrent spawns: %s", id) + } + seen[id] = true + } + + if n := len(parent.Children()); n != numSpawn { + t.Errorf("Expected parent to have %d children, got %d", numSpawn, n) + } +} + +// setSessionDirForTest points session.SessionDir at dir for the duration of +// the test, restoring the previous value on cleanup. +func setSessionDirForTest(t *testing.T, dir string) { + t.Helper() + oldDir := session.SessionDir + session.SessionDir = func() (string, error) { return dir, nil } + t.Cleanup(func() { session.SessionDir = oldDir }) +} + +// walkRegularFiles returns the paths of all regular files under root. +func walkRegularFiles(t *testing.T, root string) []string { + t.Helper() + var files []string + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.Mode().IsRegular() { + files = append(files, path) + } + return nil + }) + if err != nil { + t.Fatalf("Failed to walk %s: %v", root, err) + } + return files +} + +// TestNewSubagentOrchestrator_PersistsWhenOptedIn verifies that with +// saveSubagentHistory=true the subagent's initial goal message is persisted to +// //subagents/.json, and that subagent +// sessions never write a .meta.json sidecar. +func TestNewSubagentOrchestrator_PersistsWhenOptedIn(t *testing.T) { + tmp := t.TempDir() + setSessionDirForTest(t, tmp) + + cfg := client.Config{BaseURL: "http://localhost:8080"} + c := client.NewClient(cfg) + + mockSession := session.New(c, "/tmp/mock-session.json", []client.ChatMessage{}, "mock system prompt", true) + parent := orchestrator.NewBaseOrchestrator("parent", mockSession, nil, 10) + + const goal = "persist me" + _, err := NewSubagentOrchestrator( + c, + goal, + []string{}, + "researcher", + map[string]bool{}, // enabledTools + false, // injectCWD + false, // gemmaThinking + 10, // maxTurns + "session-test", // parentSessionID + true, // saveSubagentHistory + parent, + nil, // messenger + ) + if err != nil { + t.Fatalf("Failed to create subagent orchestrator: %v", err) + } + + historyPath := filepath.Join(tmp, "session-test", "subagents", "researcher-subagent-0.json") + if _, err := os.Stat(historyPath); err != nil { + t.Fatalf("Expected subagent history file %s to exist: %v", historyPath, err) + } + + data, err := os.ReadFile(historyPath) + if err != nil { + t.Fatalf("Failed to read subagent history file: %v", err) + } + + var msgs []client.ChatMessage + if err := json.Unmarshal(data, &msgs); err != nil { + t.Fatalf("Failed to unmarshal subagent history file: %v", err) + } + if len(msgs) < 1 { + t.Fatalf("Expected at least 1 message in subagent history, got %d", len(msgs)) + } + + foundGoal := false + for _, msg := range msgs { + if strings.Contains(msg.Content.String(), goal) { + foundGoal = true + break + } + } + if !foundGoal { + t.Errorf("Expected a message containing goal %q, got %d messages", goal, len(msgs)) + } + + // Meta-skip invariant: subagent sessions must never write .meta.json sidecars. + for _, f := range walkRegularFiles(t, tmp) { + if strings.HasSuffix(f, ".meta.json") { + t.Errorf("Unexpected .meta.json file written by subagent session: %s", f) + } + } +} + +// TestNewSubagentOrchestrator_InMemoryByDefault verifies that with +// saveSubagentHistory=false the subagent session is fully in-memory: nothing +// (not even a directory artifact) is written to the session directory. +func TestNewSubagentOrchestrator_InMemoryByDefault(t *testing.T) { + tmp := t.TempDir() + setSessionDirForTest(t, tmp) + + cfg := client.Config{BaseURL: "http://localhost:8080"} + c := client.NewClient(cfg) + + mockSession := session.New(c, "/tmp/mock-session.json", []client.ChatMessage{}, "mock system prompt", true) + parent := orchestrator.NewBaseOrchestrator("parent", mockSession, nil, 10) + + _, err := NewSubagentOrchestrator( + c, + "test goal", + []string{}, + "researcher", + map[string]bool{}, // enabledTools + false, // injectCWD + false, // gemmaThinking + 10, // maxTurns + "session-test", // parentSessionID + false, // saveSubagentHistory + parent, + nil, // messenger + ) + if err != nil { + t.Fatalf("Failed to create subagent orchestrator: %v", err) + } + + for _, f := range walkRegularFiles(t, tmp) { + t.Errorf("Expected no regular files in session dir when saveSubagentHistory=false, found: %s", f) + } +} + +// TestNewSubagentOrchestrator_SecondSpawnGetsNextID verifies that two spawns +// against the same parent mint distinct child IDs, producing two separate +// history files (no overwrite/collision). +func TestNewSubagentOrchestrator_SecondSpawnGetsNextID(t *testing.T) { + tmp := t.TempDir() + setSessionDirForTest(t, tmp) + + cfg := client.Config{BaseURL: "http://localhost:8080"} + c := client.NewClient(cfg) + + mockSession := session.New(c, "/tmp/mock-session.json", []client.ChatMessage{}, "mock system prompt", true) + parent := orchestrator.NewBaseOrchestrator("parent", mockSession, nil, 10) + + const goal = "persist me" + spawn := func() { + _, err := NewSubagentOrchestrator( + c, + goal, + []string{}, + "researcher", + map[string]bool{}, // enabledTools + false, // injectCWD + false, // gemmaThinking + 10, // maxTurns + "session-test", // parentSessionID + true, // saveSubagentHistory + parent, + nil, // messenger + ) + if err != nil { + t.Fatalf("Failed to create subagent orchestrator: %v", err) + } + } + + spawn() + spawn() + + subagentsDir := filepath.Join(tmp, "session-test", "subagents") + for _, name := range []string{"researcher-subagent-0.json", "researcher-subagent-1.json"} { + p := filepath.Join(subagentsDir, name) + if _, err := os.Stat(p); err != nil { + t.Errorf("Expected subagent history file %s to exist: %v", p, err) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 79eaf93..e448d52 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -56,6 +56,11 @@ type Config struct { LateSubagentAPIKey string `json:"late_subagent_api_key,omitempty"` LateSubagentModel string `json:"late_subagent_model,omitempty"` + // SaveSubagentHistories opts in to persisting subagent conversation + // histories under //subagents/. Default false. + // Enable via config file or the --save-subagent-histories CLI flag. + SaveSubagentHistories bool `json:"save_subagent_histories,omitempty"` + // Legacy subagent fields for backward compatibility SubagentBaseURL string `json:"subagent_base_url,omitempty"` SubagentAPIKey string `json:"subagent_api_key,omitempty"` @@ -218,6 +223,19 @@ func ResolveSubagentSettingsWithEnv(cfg *Config, openAI OpenAISettings, lookup E return resolved } +// ResolveSaveSubagentHistories determines whether subagent history +// persistence is enabled. Precedence: explicit CLI flag > config file. +// There is intentionally no environment-variable override. +func ResolveSaveSubagentHistories(cfg *Config, cliExplicit bool, cliValue bool) bool { + if cliExplicit { + return cliValue + } + if cfg != nil { + return cfg.SaveSubagentHistories + } + return false +} + func nonEmptyEnv(lookup EnvLookup, key string) (string, bool) { if lookup == nil { return "", false diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0bcdadb..d6c323b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -528,6 +528,60 @@ func TestResolveSubagentSettings(t *testing.T) { } } +func TestResolveSaveSubagentHistories(t *testing.T) { + tests := []struct { + name string + cfg *Config + cliExplicit bool + cliValue bool + want bool + }{ + { + name: "explicit flag on wins over config off", + cfg: &Config{SaveSubagentHistories: false}, + cliExplicit: true, + cliValue: true, + want: true, + }, + { + name: "explicit flag off wins over config on", + cfg: &Config{SaveSubagentHistories: true}, + cliExplicit: true, + cliValue: false, + want: false, + }, + { + name: "no flag uses config on", + cfg: &Config{SaveSubagentHistories: true}, + cliExplicit: false, + cliValue: false, + want: true, + }, + { + name: "no flag uses config off", + cfg: &Config{SaveSubagentHistories: false}, + cliExplicit: false, + cliValue: false, + want: false, + }, + { + name: "no flag and nil config defaults to off", + cfg: nil, + cliExplicit: false, + cliValue: false, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ResolveSaveSubagentHistories(tt.cfg, tt.cliExplicit, tt.cliValue); got != tt.want { + t.Fatalf("ResolveSaveSubagentHistories() = %v, want %v", got, tt.want) + } + }) + } +} + func TestConfig_GetModelForAgent(t *testing.T) { cfg := &Config{ Models: []ModelSetting{ diff --git a/internal/orchestrator/base.go b/internal/orchestrator/base.go index 70e46ad..4546087 100644 --- a/internal/orchestrator/base.go +++ b/internal/orchestrator/base.go @@ -26,6 +26,9 @@ type BaseOrchestrator struct { parent common.Orchestrator children []common.Orchestrator + // childSeq is a monotonic counter for minting child IDs; guarded by mu + childSeq int + // Running state tracker isRunning bool pendingMsgs []client.ChatMessage @@ -440,7 +443,9 @@ func (o *BaseOrchestrator) Registry() *common.ToolRegistry { func (o *BaseOrchestrator) Children() []common.Orchestrator { o.mu.RLock() defer o.mu.RUnlock() - return o.children + out := make([]common.Orchestrator, len(o.children)) + copy(out, o.children) + return out } func (o *BaseOrchestrator) Parent() common.Orchestrator { @@ -469,6 +474,17 @@ func (o *BaseOrchestrator) Rewind(index int) error { return nil } +// NextChildID atomically mints the next child ID under o.mu. The counter is +// monotonic and independent of len(children), so concurrent spawns can never +// produce duplicate IDs. +func (o *BaseOrchestrator) NextChildID(agentType string) string { + o.mu.Lock() + defer o.mu.Unlock() + id := fmt.Sprintf("%s-subagent-%d", agentType, o.childSeq) + o.childSeq++ + return id +} + func (o *BaseOrchestrator) AddChild(child common.Orchestrator) { o.mu.Lock() o.children = append(o.children, child) diff --git a/internal/orchestrator/base_test.go b/internal/orchestrator/base_test.go index 59a76af..c049bde 100644 --- a/internal/orchestrator/base_test.go +++ b/internal/orchestrator/base_test.go @@ -2,11 +2,13 @@ package orchestrator import ( "context" + "fmt" "late/internal/client" "late/internal/common" "late/internal/session" "os" "path/filepath" + "sync" "testing" ) @@ -133,3 +135,74 @@ func TestBaseOrchestrator_ResetStartsNewConversation(t *testing.T) { t.Fatalf("saving the new conversation changed original history: %#v", preserved) } } + +func TestNextChildID_FormatAndMonotonic(t *testing.T) { + o := NewBaseOrchestrator("parent", session.New(nil, "", nil, "", false), nil, 0) + + var got []string + for i := 0; i < 3; i++ { + got = append(got, o.NextChildID("researcher")) + } + got = append(got, o.NextChildID("coder")) + + want := []string{ + "researcher-subagent-0", + "researcher-subagent-1", + "researcher-subagent-2", + // The counter is global per parent, NOT per type. + "coder-subagent-3", + } + + if len(got) != len(want) { + t.Fatalf("got %d IDs, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("ID %d = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestNextChildID_Concurrent(t *testing.T) { + o := NewBaseOrchestrator("parent", session.New(nil, "", nil, "", false), nil, 0) + + const goroutines = 64 + const perGoroutine = 16 + + var mu sync.Mutex + ids := make([]string, 0, goroutines*perGoroutine) + + var wg sync.WaitGroup + for g := 0; g < goroutines; g++ { + wg.Add(1) + go func(g int) { + defer wg.Done() + for i := 0; i < perGoroutine; i++ { + id := o.NextChildID("researcher") + mu.Lock() + ids = append(ids, id) + mu.Unlock() + } + // Interleave lightweight child registration to ensure concurrent + // AddChild calls don't interfere with ID minting. + o.AddChild(NewBaseOrchestrator(fmt.Sprintf("dummy-%d", g), nil, nil, 0)) + }(g) + } + wg.Wait() + + if len(ids) != goroutines*perGoroutine { + t.Fatalf("minted %d IDs, want %d", len(ids), goroutines*perGoroutine) + } + + seen := make(map[string]struct{}, len(ids)) + for _, id := range ids { + if _, dup := seen[id]; dup { + t.Fatalf("duplicate ID minted: %q", id) + } + seen[id] = struct{}{} + } + + if n := len(o.Children()); n != goroutines { + t.Errorf("Children() length = %d, want %d", n, goroutines) + } +} diff --git a/internal/session/models_test.go b/internal/session/models_test.go index b0d17bf..b14e042 100644 --- a/internal/session/models_test.go +++ b/internal/session/models_test.go @@ -125,3 +125,108 @@ func TestGetLatestSession(t *testing.T) { } } +// setupSubagentFolderFixture swaps SessionDir to a fresh temp dir containing: +// - a legacy flat session (history file + meta file) with ID "session-20250101-123456" +// - the hierarchical subagent artifacts of another session: a directory +// "session-20250102-999999/subagents/" holding subagent history files, +// intentionally WITHOUT a meta file of its own +// +// SessionDir is restored when the test finishes. +func setupSubagentFolderFixture(t *testing.T) { + t.Helper() + + tmpDir := t.TempDir() + + // Mock SessionDir + oldSessionDir := SessionDir + SessionDir = func() (string, error) { + return tmpDir, nil + } + t.Cleanup(func() { SessionDir = oldSessionDir }) + + // Legacy session: flat history file + matching meta + const legacyID = "session-20250101-123456" + historyPath := filepath.Join(tmpDir, legacyID+".json") + history := []client.ChatMessage{{Role: "user", Content: client.TextContent("Hello")}} + if err := SaveHistory(historyPath, history); err != nil { + t.Fatalf("Failed to save history: %v", err) + } + + meta := SessionMeta{ + ID: legacyID, + Title: "Legacy Session", + CreatedAt: time.Now().Add(-1 * time.Hour), + LastUpdated: time.Now(), + HistoryPath: historyPath, + } + if err := SaveSessionMeta(meta); err != nil { + t.Fatalf("Failed to save meta: %v", err) + } + + // Hierarchical subagent artifacts of another session (no meta file) + subagentsDir := filepath.Join(tmpDir, "session-20250102-999999", "subagents") + if err := os.MkdirAll(subagentsDir, 0700); err != nil { + t.Fatalf("Failed to create subagents dir: %v", err) + } + for _, name := range []string{"coder-subagent-0.json", "researcher-subagent-1.json"} { + if err := os.WriteFile(filepath.Join(subagentsDir, name), []byte("[]"), 0600); err != nil { + t.Fatalf("Failed to write subagent history %s: %v", name, err) + } + } +} + +func TestListSessions_IgnoresSubagentFolders(t *testing.T) { + setupSubagentFolderFixture(t) + + metas, err := ListSessions() + if err != nil { + t.Fatalf("Expected no error from ListSessions, got %v", err) + } + if len(metas) != 1 { + t.Fatalf("Expected exactly 1 session, got %d: %v", len(metas), metas) + } + if metas[0].ID != "session-20250101-123456" { + t.Errorf("Expected session ID 'session-20250101-123456', got %q", metas[0].ID) + } + + latest, err := GetLatestSession() + if err != nil { + t.Fatalf("Expected no error from GetLatestSession, got %v", err) + } + if latest == nil || latest.ID != "session-20250101-123456" { + t.Errorf("Expected latest session 'session-20250101-123456', got %v", latest) + } +} + +func TestLoadSessionMeta_IgnoresSubagentFolders(t *testing.T) { + setupSubagentFolderFixture(t) + + // Exact match still works + exact, err := LoadSessionMeta("session-20250101-123456") + if err != nil || exact == nil { + t.Fatalf("Failed to load meta exactly: %v", err) + } + if exact.ID != "session-20250101-123456" { + t.Errorf("Expected loaded ID 'session-20250101-123456', got %q", exact.ID) + } + + // Prefix matching only the legacy session: the "session-20250102-999999" + // directory must be skipped by the prefix scan, introducing no new match or ambiguity + byPrefix, err := LoadSessionMeta("session-2025") + if err != nil || byPrefix == nil { + t.Fatalf("Failed to load meta by prefix: %v", err) + } + if byPrefix.ID != "session-20250101-123456" { + t.Errorf("Expected loaded prefix ID 'session-20250101-123456', got %q", byPrefix.ID) + } + + // Nonexistent ID behaves as before: (nil, nil) + notFound, err := LoadSessionMeta("nonexistent") + if err != nil { + t.Fatalf("Expected no error for nonexistent session, got %v", err) + } + if notFound != nil { + t.Errorf("Expected nil meta for nonexistent session, got %v", notFound) + } +} + diff --git a/internal/session/paths.go b/internal/session/paths.go new file mode 100644 index 0000000..0cec2fa --- /dev/null +++ b/internal/session/paths.go @@ -0,0 +1,44 @@ +package session + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// SubagentHistoryDir returns the directory holding a session's subagent +// histories: //subagents. It is created lazily by +// SaveHistory (MkdirAll 0700), so this helper does not create it. +func SubagentHistoryDir(sessionID string) (string, error) { + sessionsDir, err := SessionDir() + if err != nil { + return "", err + } + return filepath.Join(sessionsDir, sessionID, "subagents"), nil +} + +// SubagentHistoryPath returns the full path of a subagent history file: +// //subagents/.json. +func SubagentHistoryPath(parentSessionID, childID string) (string, error) { + dir, err := SubagentHistoryDir(parentSessionID) + if err != nil { + return "", err + } + return filepath.Join(dir, childID+".json"), nil +} + +// RemoveSessionFolder deletes a session's folder (containing its subagent +// histories) if it exists. Flat files .json / .meta.json are +// distinct names and are never touched. Returns nil when the folder does +// not exist (legacy sessions) or for IDs containing path separators. +func RemoveSessionFolder(sessionID string) error { + if sessionID == "" || strings.ContainsAny(sessionID, "/\\") { + return nil + } + sessionsDir, err := SessionDir() + if err != nil { + return fmt.Errorf("failed to get session directory: %w", err) + } + return os.RemoveAll(filepath.Join(sessionsDir, sessionID)) +} diff --git a/internal/session/paths_test.go b/internal/session/paths_test.go new file mode 100644 index 0000000..18f46a8 --- /dev/null +++ b/internal/session/paths_test.go @@ -0,0 +1,138 @@ +package session + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSubagentHistoryPath(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "late-session-paths-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Mock SessionDir + oldSessionDir := SessionDir + SessionDir = func() (string, error) { + return tmpDir, nil + } + defer func() { SessionDir = oldSessionDir }() + + const sessionID = "session-20250101-123456" + const childID = "researcher-subagent-0" + + wantDir := filepath.Join(tmpDir, sessionID, "subagents") + gotDir, err := SubagentHistoryDir(sessionID) + if err != nil { + t.Fatalf("SubagentHistoryDir returned error: %v", err) + } + if gotDir != wantDir { + t.Errorf("Expected SubagentHistoryDir %q, got %q", wantDir, gotDir) + } + + wantPath := filepath.Join(wantDir, childID+".json") + gotPath, err := SubagentHistoryPath(sessionID, childID) + if err != nil { + t.Fatalf("SubagentHistoryPath returned error: %v", err) + } + if gotPath != wantPath { + t.Errorf("Expected SubagentHistoryPath %q, got %q", wantPath, gotPath) + } + + // Neither helper must pre-create directories. + if _, err := os.Stat(filepath.Join(tmpDir, sessionID)); !os.IsNotExist(err) { + t.Errorf("Expected no pre-created %q folder, got stat err=%v", sessionID, err) + } +} + +func TestRemoveSessionFolder_RemovesFolderKeepsFlatFiles(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "late-session-rmtest-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Mock SessionDir + oldSessionDir := SessionDir + SessionDir = func() (string, error) { + return tmpDir, nil + } + defer func() { SessionDir = oldSessionDir }() + + const sessionID = "session-X" + subagentsDir := filepath.Join(tmpDir, sessionID, "subagents") + if err := os.MkdirAll(subagentsDir, 0700); err != nil { + t.Fatal(err) + } + childPath := filepath.Join(subagentsDir, "coder-subagent-0.json") + if err := os.WriteFile(childPath, []byte("[]"), 0600); err != nil { + t.Fatal(err) + } + + flatJSON := filepath.Join(tmpDir, sessionID+".json") + flatMeta := filepath.Join(tmpDir, sessionID+".meta.json") + if err := os.WriteFile(flatJSON, []byte("[]"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(flatMeta, []byte("{}"), 0600); err != nil { + t.Fatal(err) + } + + if err := RemoveSessionFolder(sessionID); err != nil { + t.Fatalf("RemoveSessionFolder returned error: %v", err) + } + + if _, err := os.Stat(filepath.Join(tmpDir, sessionID)); !os.IsNotExist(err) { + t.Errorf("Expected %q folder to be removed, got stat err=%v", sessionID, err) + } + if _, err := os.Stat(childPath); !os.IsNotExist(err) { + t.Errorf("Expected subagent history %q to be removed, got stat err=%v", childPath, err) + } + if _, err := os.Stat(flatJSON); err != nil { + t.Errorf("Expected flat file %q to survive, got stat err=%v", flatJSON, err) + } + if _, err := os.Stat(flatMeta); err != nil { + t.Errorf("Expected flat file %q to survive, got stat err=%v", flatMeta, err) + } +} + +func TestRemoveSessionFolder_NoopForMissingAndUnsafeIDs(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "late-session-noop-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Mock SessionDir + oldSessionDir := SessionDir + SessionDir = func() (string, error) { + return tmpDir, nil + } + defer func() { SessionDir = oldSessionDir }() + + // Never-created ID: no error, nothing deleted. + if err := RemoveSessionFolder("session-does-not-exist"); err != nil { + t.Errorf("Expected nil error for missing session ID, got %v", err) + } + + // Unsafe IDs: no error, nothing deleted, no panic. + for _, unsafeID := range []string{"", "../evil", "a/b", "..\\windows-evil"} { + if err := RemoveSessionFolder(unsafeID); err != nil { + t.Errorf("Expected nil error for unsafe session ID %q, got %v", unsafeID, err) + } + if _, err := os.Stat(filepath.Join(tmpDir, "..", "evil")); !os.IsNotExist(err) { + t.Errorf("Expected no deletion outside temp dir for %q, got stat err=%v", unsafeID, err) + } + } + + // The temp dir itself must still exist with an empty listing. + entries, err := os.ReadDir(tmpDir) + if err != nil { + t.Fatalf("Failed to read temp dir: %v", err) + } + if len(entries) != 0 { + t.Errorf("Expected temp dir to remain empty, found %d entries: %v", len(entries), entries) + } +} diff --git a/internal/session/session.go b/internal/session/session.go index 4cd4f27..80d256d 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -21,6 +21,7 @@ type Session struct { History []client.ChatMessage systemPrompt string useTools bool + skipMetadata bool // when true, no top-level .meta.json sidecar is written (subagents) Registry *tool.Registry } @@ -35,6 +36,16 @@ func New(c *client.Client, historyPath string, history []client.ChatMessage, sys } } +// NewSubagentSession creates a session for a subagent. History is persisted +// to historyPath when non-empty (in-memory otherwise), but the session never +// writes a top-level .meta.json sidecar, keeping the shared sessions +// directory free of subagent entries. +func NewSubagentSession(c *client.Client, historyPath string, history []client.ChatMessage, systemPrompt string) *Session { + s := New(c, historyPath, history, systemPrompt, true) + s.skipMetadata = true + return s +} + // ExecuteTool executes a tool call and returns the response as a string. func (s *Session) ExecuteTool(ctx context.Context, tc client.ToolCall) (string, error) { // First check registry @@ -283,6 +294,9 @@ func (s *Session) GenerateSessionMeta() SessionMeta { // UpdateSessionMetadata updates the session metadata file func (s *Session) UpdateSessionMetadata() error { + if s.skipMetadata { + return nil + } meta := s.GenerateSessionMeta() return SaveSessionMeta(meta) } @@ -326,7 +340,7 @@ func (s *Session) saveAndNotify() error { return nil } if s.HistoryPath == "" { - return nil // Skip saving if no path provided (e.g., subagents) + return nil // Skip saving if no path provided (e.g., in-memory sessions, subagents without history opt-in) } if err := SaveHistory(s.HistoryPath, s.History); err != nil { return err diff --git a/internal/session/subagent_session_test.go b/internal/session/subagent_session_test.go new file mode 100644 index 0000000..a8fd7f8 --- /dev/null +++ b/internal/session/subagent_session_test.go @@ -0,0 +1,69 @@ +package session + +import ( + "encoding/json" + "late/internal/client" + "os" + "path/filepath" + "testing" +) + +func TestSubagentSession_SavesHistoryWithoutMeta(t *testing.T) { + tmpDir := t.TempDir() + + // Mock SessionDir + oldSessionDir := SessionDir + SessionDir = func() (string, error) { + return tmpDir, nil + } + t.Cleanup(func() { SessionDir = oldSessionDir }) + + historyPath := filepath.Join(tmpDir, "session-test", "subagents", "coder-subagent-0.json") + s := NewSubagentSession(nil, historyPath, nil, "sp") + + if err := s.AddUserMessage("hello"); err != nil { + t.Fatalf("AddUserMessage returned error: %v", err) + } + + // History must be persisted to the nested subagent path. + data, err := os.ReadFile(historyPath) + if err != nil { + t.Fatalf("Expected subagent history file %q to exist, got: %v", historyPath, err) + } + var history []client.ChatMessage + if err := json.Unmarshal(data, &history); err != nil { + t.Fatalf("Failed to unmarshal subagent history: %v", err) + } + if len(history) == 0 { + t.Errorf("Expected non-empty subagent history, got 0 messages") + } + + // No top-level .meta.json sidecar must be written for subagents. + metaPath := filepath.Join(tmpDir, "coder-subagent-0.meta.json") + if _, err := os.Stat(metaPath); !os.IsNotExist(err) { + t.Errorf("Expected no top-level meta file %q, got stat err=%v", metaPath, err) + } +} + +func TestRegularSession_StillWritesMeta(t *testing.T) { + tmpDir := t.TempDir() + + // Mock SessionDir + oldSessionDir := SessionDir + SessionDir = func() (string, error) { + return tmpDir, nil + } + t.Cleanup(func() { SessionDir = oldSessionDir }) + + historyPath := filepath.Join(tmpDir, "session-test.json") + s := New(nil, historyPath, nil, "sp", true) + + if err := s.AddUserMessage("hello"); err != nil { + t.Fatalf("AddUserMessage returned error: %v", err) + } + + metaPath := filepath.Join(tmpDir, "session-test.meta.json") + if _, err := os.Stat(metaPath); err != nil { + t.Errorf("Expected top-level meta file %q to exist, got stat err=%v", metaPath, err) + } +} From 6e1eb93ec71aad6040fb951721806ce1b500ec97 Mon Sep 17 00:00:00 2001 From: jpia <6011922+jpia@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:50:14 -0500 Subject: [PATCH 2/5] fix(session): reject unsafe path elements in subagent history helpers --- internal/session/paths.go | 24 +++++++++++-- internal/session/paths_test.go | 63 +++++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/internal/session/paths.go b/internal/session/paths.go index 0cec2fa..316254c 100644 --- a/internal/session/paths.go +++ b/internal/session/paths.go @@ -7,10 +7,23 @@ import ( "strings" ) +// isValidPathElement reports whether id is a safe single path element +// under the sessions directory: non-empty, not "." or "..", and free of +// path separators (/ and \) and NUL bytes. +func isValidPathElement(id string) bool { + if id == "" || id == "." || id == ".." { + return false + } + return !strings.ContainsAny(id, "/\\") && !strings.ContainsRune(id, 0) +} + // SubagentHistoryDir returns the directory holding a session's subagent // histories: //subagents. It is created lazily by // SaveHistory (MkdirAll 0700), so this helper does not create it. func SubagentHistoryDir(sessionID string) (string, error) { + if !isValidPathElement(sessionID) { + return "", fmt.Errorf("invalid session ID: %q", sessionID) + } sessionsDir, err := SessionDir() if err != nil { return "", err @@ -21,6 +34,12 @@ func SubagentHistoryDir(sessionID string) (string, error) { // SubagentHistoryPath returns the full path of a subagent history file: // //subagents/.json. func SubagentHistoryPath(parentSessionID, childID string) (string, error) { + if !isValidPathElement(parentSessionID) { + return "", fmt.Errorf("invalid session ID: %q", parentSessionID) + } + if !isValidPathElement(childID) { + return "", fmt.Errorf("invalid child ID: %q", childID) + } dir, err := SubagentHistoryDir(parentSessionID) if err != nil { return "", err @@ -31,9 +50,10 @@ func SubagentHistoryPath(parentSessionID, childID string) (string, error) { // RemoveSessionFolder deletes a session's folder (containing its subagent // histories) if it exists. Flat files .json / .meta.json are // distinct names and are never touched. Returns nil when the folder does -// not exist (legacy sessions) or for IDs containing path separators. +// not exist (legacy sessions) or for unsafe IDs (empty, ".", "..", path +// separators, NUL bytes). func RemoveSessionFolder(sessionID string) error { - if sessionID == "" || strings.ContainsAny(sessionID, "/\\") { + if !isValidPathElement(sessionID) { return nil } sessionsDir, err := SessionDir() diff --git a/internal/session/paths_test.go b/internal/session/paths_test.go index 18f46a8..5684ef8 100644 --- a/internal/session/paths_test.go +++ b/internal/session/paths_test.go @@ -118,7 +118,7 @@ func TestRemoveSessionFolder_NoopForMissingAndUnsafeIDs(t *testing.T) { } // Unsafe IDs: no error, nothing deleted, no panic. - for _, unsafeID := range []string{"", "../evil", "a/b", "..\\windows-evil"} { + for _, unsafeID := range []string{"", ".", "..", "../evil", "a/b", "..\\windows-evil"} { if err := RemoveSessionFolder(unsafeID); err != nil { t.Errorf("Expected nil error for unsafe session ID %q, got %v", unsafeID, err) } @@ -136,3 +136,64 @@ func TestRemoveSessionFolder_NoopForMissingAndUnsafeIDs(t *testing.T) { t.Errorf("Expected temp dir to remain empty, found %d entries: %v", len(entries), entries) } } + +func TestSubagentHistoryPathRejectsUnsafeIDs(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "late-session-unsafe-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Mock SessionDir + oldSessionDir := SessionDir + SessionDir = func() (string, error) { + return tmpDir, nil + } + defer func() { SessionDir = oldSessionDir }() + + for _, unsafeID := range []string{"", ".", "..", "../x", "a/b"} { + if _, err := SubagentHistoryDir(unsafeID); err == nil { + t.Errorf("Expected error from SubagentHistoryDir for unsafe session ID %q, got nil", unsafeID) + } + if _, err := SubagentHistoryPath(unsafeID, "coder"); err == nil { + t.Errorf("Expected error from SubagentHistoryPath for unsafe session ID %q, got nil", unsafeID) + } + } + + if _, err := SubagentHistoryPath("session-x", ".."); err == nil { + t.Errorf("Expected error from SubagentHistoryPath for unsafe child ID %q, got nil", "..") + } + + // Nothing may have been created on disk. + entries, err := os.ReadDir(tmpDir) + if err != nil { + t.Fatalf("Failed to read temp dir: %v", err) + } + if len(entries) != 0 { + t.Errorf("Expected temp dir to remain empty, found %d entries: %v", len(entries), entries) + } +} + +func TestSubagentHistoryPathValidInputs(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "late-session-valid-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Mock SessionDir + oldSessionDir := SessionDir + SessionDir = func() (string, error) { + return tmpDir, nil + } + defer func() { SessionDir = oldSessionDir }() + + gotPath, err := SubagentHistoryPath("session-x", "coder-subagent-0") + if err != nil { + t.Fatalf("SubagentHistoryPath returned error: %v", err) + } + wantPath := filepath.Join(tmpDir, "session-x", "subagents", "coder-subagent-0.json") + if gotPath != wantPath { + t.Errorf("Expected SubagentHistoryPath %q, got %q", wantPath, gotPath) + } +} From c8f1c6742b7d8d4b2dc8acf044e8b36efbed14d1 Mon Sep 17 00:00:00 2001 From: jpia <6011922+jpia@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:57:05 -0500 Subject: [PATCH 3/5] fix(cmd): fall back to in-memory subagent histories for unsafe session IDs --- cmd/late/main.go | 19 ++++++++++++++++-- cmd/late/main_test.go | 46 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/cmd/late/main.go b/cmd/late/main.go index 11a37bc..9d3948c 100644 --- a/cmd/late/main.go +++ b/cmd/late/main.go @@ -178,8 +178,10 @@ func main() { // Effective session ID for this run — derived from the FINAL history path so // resumed sessions keep their original ID (the sessionID var above is a fresh // timestamp even on resume). Used to place subagent histories under the right - // per-session folder. - effectiveSessionID := strings.TrimSuffix(filepath.Base(historyPath), ".json") + // per-session folder. The helper falls back to "" for empty or unsafe IDs, + // which disables subagent history persistence (in-memory fallback) instead of + // writing files outside the session folder. + effectiveSessionID := deriveEffectiveSessionID(historyPath) // Load existing history history, err := session.LoadHistory(historyPath) @@ -405,6 +407,19 @@ func main() { } } +// deriveEffectiveSessionID derives this run's session ID from the FINAL +// history path so resumed sessions keep their original ID. It returns "" +// for empty or unsafe results (a crafted meta file could claim an ID like +// ".."), which disables subagent history persistence for the run +// (in-memory fallback) instead of writing files outside the session folder. +func deriveEffectiveSessionID(historyPath string) string { + id := strings.TrimSuffix(filepath.Base(historyPath), ".json") + if id == "" || id == "." || id == ".." { + return "" + } + return id +} + func mcpToolEnabled(t tool.Tool, enabledTools map[string]bool) bool { if enabled, exists := enabledTools[t.Name()]; exists { return enabled diff --git a/cmd/late/main_test.go b/cmd/late/main_test.go index 41d5512..67e6d71 100644 --- a/cmd/late/main_test.go +++ b/cmd/late/main_test.go @@ -126,3 +126,49 @@ func TestHandleSessionDelete_LegacyFlatSession(t *testing.T) { assertFileGone(t, metaC) assertFileGone(t, historyC) } + +func TestDeriveEffectiveSessionID(t *testing.T) { + tests := []struct { + name string + historyPath string + want string + }{ + { + name: "plain session history file", + historyPath: "session-20260815-123456.json", + want: "session-20260815-123456", + }, + { + name: "full path uses base name", + historyPath: "/tmp/sessions/session-abc.json", + want: "session-abc", + }, + { + name: "parent directory reference", + historyPath: "..", + want: "", + }, + { + name: "full path ending in parent directory", + historyPath: "/some/dir/..", + want: "", + }, + { + name: "current directory reference", + historyPath: ".", + want: "", + }, + { + name: "json suffix only", + historyPath: ".json", + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := deriveEffectiveSessionID(tt.historyPath); got != tt.want { + t.Errorf("deriveEffectiveSessionID(%q) = %q, want %q", tt.historyPath, got, tt.want) + } + }) + } +} From 33d8ca23c3c49f4d1b86ac4a35c07b7e70dc6e5b Mon Sep 17 00:00:00 2001 From: jpia <6011922+jpia@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:05:17 -0500 Subject: [PATCH 4/5] refactor(agent): require BaseOrchestrator parent, drop dead child-ID fallbacks --- internal/agent/agent.go | 16 ++++++------- internal/agent/agent_test.go | 45 ++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 8293a95..f445eac 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -62,16 +62,14 @@ func NewSubagentOrchestrator( systemPrompt = "<|think|>" + systemPrompt } - // Mint the child ID up-front so it can be embedded in the subagent history path. - var id string - if p, ok := parent.(*orchestrator.BaseOrchestrator); ok { - id = p.NextChildID(agentType) - } else if parent != nil { - // Fallback for non-BaseOrchestrator parents (test fakes only). - id = fmt.Sprintf("%s-subagent-%d", agentType, len(parent.Children())) - } else { - id = fmt.Sprintf("%s-subagent-0", agentType) + // Mint the child ID up-front so it can be embedded in the subagent history + // path. The parent must be a *BaseOrchestrator: its mutex-protected counter + // is the only ID source that cannot collide under concurrent spawns. + baseParent, ok := parent.(*orchestrator.BaseOrchestrator) + if !ok { + return nil, fmt.Errorf("subagent parent must be a *orchestrator.BaseOrchestrator") } + id := baseParent.NextChildID(agentType) // 2. Setup Subagent Session (Isolated History; persisted only when opted in) var subagentHistoryPath string diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index df9157b..cbdba61 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -350,6 +350,51 @@ func TestNewSubagentOrchestrator_PersistsWhenOptedIn(t *testing.T) { } } +// TestNewSubagentOrchestrator_RejectsUnsafeParentSessionID verifies that an +// unsafe parentSessionID (e.g. "..") is rejected at history path resolution +// before anything is written to the session directory (defense in depth; +// Phase 2 makes this unreachable from production). +func TestNewSubagentOrchestrator_RejectsUnsafeParentSessionID(t *testing.T) { + tmp := t.TempDir() + setSessionDirForTest(t, tmp) + + cfg := client.Config{BaseURL: "http://localhost:8080"} + c := client.NewClient(cfg) + + mockSession := session.New(c, "/tmp/mock-session.json", []client.ChatMessage{}, "mock system prompt", true) + parent := orchestrator.NewBaseOrchestrator("parent", mockSession, nil, 10) + + const goal = "persist me" + _, err := NewSubagentOrchestrator( + c, + goal, + []string{}, + "coder", + map[string]bool{}, // enabledTools + false, // injectCWD + false, // gemmaThinking + 10, // maxTurns + "..", // parentSessionID + true, // saveSubagentHistory + parent, + nil, // messenger + ) + if err == nil { + t.Fatalf("Expected error for unsafe parentSessionID, got nil") + } + if !strings.Contains(err.Error(), "failed to resolve subagent history path") { + t.Errorf("Expected error to contain %q, got: %v", "failed to resolve subagent history path", err) + } + + entries, err := os.ReadDir(tmp) + if err != nil { + t.Fatalf("Failed to read temp sessions dir: %v", err) + } + if len(entries) != 0 { + t.Errorf("Expected zero entries in temp sessions dir, got %d: %v", len(entries), entries) + } +} + // TestNewSubagentOrchestrator_InMemoryByDefault verifies that with // saveSubagentHistory=false the subagent session is fully in-memory: nothing // (not even a directory artifact) is written to the session directory. From 686b8bad72afa343ffb901ff8f686d91e1e5b6c9 Mon Sep 17 00:00:00 2001 From: jpia <6011922+jpia@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:15:53 -0500 Subject: [PATCH 5/5] chore: warning casing, NextChildID docs, transcript sensitivity note --- cmd/late/main.go | 2 +- docs/quickstart.md | 2 +- docs/quickstart.zh-CN.md | 2 +- internal/orchestrator/base.go | 4 +++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/cmd/late/main.go b/cmd/late/main.go index 9d3948c..587a948 100644 --- a/cmd/late/main.go +++ b/cmd/late/main.go @@ -582,7 +582,7 @@ func handleSessionDelete(id string) { // legacy flat sessions without a folder. Non-fatal: the session itself is already // gone, so don't block the success message on leftover artifacts. if err := session.RemoveSessionFolder(meta.ID); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to delete subagent history folder: %v\n", err) + fmt.Fprintf(os.Stderr, "Warning: Failed to delete subagent history folder: %v\n", err) } fmt.Printf("Deleted session: %s\n", meta.Title) diff --git a/docs/quickstart.md b/docs/quickstart.md index fa4edf0..3be6860 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -255,7 +255,7 @@ late session load # Resume a previous session late session delete # Delete a session ``` -By default, subagent conversations are kept in memory and discarded. With `--save-subagent-histories` (or `"save_subagent_histories": true` in the config file), each subagent's history is saved under the session folder — `~/.local/share/late/sessions//subagents/-subagent-.json` — while the parent session files stay in place. `late session delete ` removes the subagent folder along with the session. +By default, subagent conversations are kept in memory and discarded. With `--save-subagent-histories` (or `"save_subagent_histories": true` in the config file), each subagent's history is saved under the session folder — `~/.local/share/late/sessions//subagents/-subagent-.json` — while the parent session files stay in place. `late session delete ` removes the subagent folder along with the session. Note that saved transcripts may contain sensitive content (file contents, tool outputs); they are written with user-only permissions. ## Git Worktrees diff --git a/docs/quickstart.zh-CN.md b/docs/quickstart.zh-CN.md index 28c0258..8386996 100644 --- a/docs/quickstart.zh-CN.md +++ b/docs/quickstart.zh-CN.md @@ -253,7 +253,7 @@ late session load # 恢复指定的历史会话 late session delete # 删除指定的会话 ``` -默认情况下,子智能体的对话仅保存在内存中,结束后即被丢弃。使用 `--save-subagent-histories`(或在配置文件中设置 `"save_subagent_histories": true`)后,每个子智能体的对话历史记录都会被保存到会话文件夹下 —— `~/.local/share/late/sessions//subagents/-subagent-.json` —— 而父会话的原始文件位置保持不变。运行 `late session delete ` 删除会话时,子智能体文件夹也会随之被一并移除。 +默认情况下,子智能体的对话仅保存在内存中,结束后即被丢弃。使用 `--save-subagent-histories`(或在配置文件中设置 `"save_subagent_histories": true`)后,每个子智能体的对话历史记录都会被保存到会话文件夹下 —— `~/.local/share/late/sessions//subagents/-subagent-.json` —— 而父会话的原始文件位置保持不变。运行 `late session delete ` 删除会话时,子智能体文件夹也会随之被一并移除。请注意,保存的对话记录可能包含敏感内容(文件内容、工具输出);这些文件以仅限用户读取的权限写入。 ## Git 工作树 (Git Worktrees) diff --git a/internal/orchestrator/base.go b/internal/orchestrator/base.go index 4546087..4bdb0c6 100644 --- a/internal/orchestrator/base.go +++ b/internal/orchestrator/base.go @@ -476,7 +476,9 @@ func (o *BaseOrchestrator) Rewind(index int) error { // NextChildID atomically mints the next child ID under o.mu. The counter is // monotonic and independent of len(children), so concurrent spawns can never -// produce duplicate IDs. +// produce duplicate IDs. The counter is shared across agent types (e.g., +// `researcher-subagent-0`, then `coder-subagent-1`), matching the legacy +// `len(children)` numbering scheme. func (o *BaseOrchestrator) NextChildID(agentType string) string { o.mu.Lock() defer o.mu.Unlock()