From c4167772b5566977758d55ee119197307c67ef1d Mon Sep 17 00:00:00 2001 From: Emasoft <713559+Emasoft@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:29:43 +0200 Subject: [PATCH 1/2] test: isolate session dir for every test that persists session meta 13 tests in executor/orchestrator/agent wrote *.meta.json sidecars into the real ~/.local/share/late/sessions via the global SessionDir; all now redirect it to t.TempDir(). Production meta placement is unchanged (the resume lister depends on it). --- internal/agent/agent_test.go | 44 +++++++++++++++++++++++++----- internal/executor/executor_test.go | 20 +++++++++++++- internal/orchestrator/base_test.go | 24 +++++++++++++--- 3 files changed, 76 insertions(+), 12 deletions(-) diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 8821e72d..ee8b70b3 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -20,8 +20,14 @@ func TestNewSubagentOrchestratorWithGemmaThinking(t *testing.T) { cfg := client.Config{BaseURL: "http://localhost:8080"} c := client.NewClient(cfg) - // Create a mock parent session - mockHistoryPath := "/tmp/mock-session.json" + // Create a mock parent session. Spawning a subagent reserves the parent's + // next child sequence via NextChildID → UpdateSubagentSeq, which persists + // the parent's .meta.json sidecar into the sessions dir — redirect the + // sessions dir to a temp dir so nothing lands in the real user dir. + tmp := t.TempDir() + setSessionDirForTest(t, tmp) + + mockHistoryPath := filepath.Join(tmp, "mock-session.json") mockHistory := []client.ChatMessage{} mockSession := session.New(c, mockHistoryPath, mockHistory, "mock system prompt", true) parent := orchestrator.NewBaseOrchestrator("parent", mockSession, nil, 100) @@ -101,8 +107,14 @@ func TestNewSubagentOrchestratorGemmaThinkingWithCWD(t *testing.T) { cfg := client.Config{BaseURL: "http://localhost:8080"} c := client.NewClient(cfg) + // Spawning a subagent persists the parent's .meta.json sidecar (see + // TestNewSubagentOrchestratorWithGemmaThinking) — redirect the sessions + // dir to a temp dir. + tmp := t.TempDir() + setSessionDirForTest(t, tmp) + // Create a mock parent session - mockHistoryPath := "/tmp/mock-session.json" + mockHistoryPath := filepath.Join(tmp, "mock-session.json") mockHistory := []client.ChatMessage{} mockSession := session.New(c, mockHistoryPath, mockHistory, "mock system prompt", true) parent := orchestrator.NewBaseOrchestrator("parent", mockSession, nil, 100) @@ -160,9 +172,15 @@ func min(a, b int) int { // TestNewSubagentOrchestratorID verifies that the created subagent ID contains its agent type func TestNewSubagentOrchestratorID(t *testing.T) { + // Spawning a subagent persists the parent's .meta.json sidecar (see + // TestNewSubagentOrchestratorWithGemmaThinking) — redirect the sessions + // dir to a temp dir. + 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) + mockSession := session.New(c, filepath.Join(tmp, "mock-session.json"), []client.ChatMessage{}, "mock system prompt", true) parent := orchestrator.NewBaseOrchestrator("parent", mockSession, nil, 100) child, err := NewSubagentOrchestrator( @@ -187,14 +205,21 @@ 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) { + // Each spawn persists the parent's .meta.json sidecar (see + // TestNewSubagentOrchestratorWithGemmaThinking) — 16 concurrent spawns + // would hammer the real sessions dir without the redirect. + tmp := t.TempDir() + setSessionDirForTest(t, tmp) + 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) + mockSession := session.New(c, filepath.Join(tmp, "mock-session.json"), []client.ChatMessage{}, "mock system prompt", true) parent := orchestrator.NewBaseOrchestrator("parent", mockSession, nil, 10) const numSpawn = 16 @@ -539,10 +564,16 @@ func TestNewSubagentOrchestrator_ResumedParentUsesNextHistoryPath(t *testing.T) // TestSubagentRegistryHasNoTodoTools verifies that a spawned subagent NEVER // receives todo tools in its registry, even when enabledTools includes them. func TestSubagentRegistryHasNoTodoTools(t *testing.T) { + // Spawning a subagent persists the parent's .meta.json sidecar (see + // TestNewSubagentOrchestratorWithGemmaThinking) — redirect the sessions + // dir to a temp dir. + 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) + mockSession := session.New(c, filepath.Join(tmp, "mock-session.json"), []client.ChatMessage{}, "mock system prompt", true) parent := orchestrator.NewBaseOrchestrator("parent", mockSession, nil, 100) enabledTools := map[string]bool{ @@ -590,4 +621,3 @@ func TestSubagentRegistryHasNoTodoTools(t *testing.T) { t.Fatalf("expected subagent registry to contain read_file") } } - diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index 5145e34a..1b6cf77e 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -123,8 +123,25 @@ func TestStreamAccumulator_NameUpdate(t *testing.T) { } } +// isolateSessionDir points the session layer's global SessionDir at a fresh +// temp directory for the duration of the test. SaveSessionMeta derives the +// .meta.json sidecar path from SessionDir() — the shared sessions directory — +// and NOT from the history file's directory (production requires this: the +// --continue lister scans the sessions dir for sidecars), so every test that +// persists history (any Add*Message → saveAndNotify → UpdateSessionMetadata) +// would otherwise write e.g. history.meta.json into the REAL +// ~/.local/share/late/sessions. Redirecting SessionDir keeps all test writes +// inside t.TempDir(). +func isolateSessionDir(t *testing.T) { + t.Helper() + oldDir := session.SessionDir + session.SessionDir = func() (string, error) { return t.TempDir(), nil } + t.Cleanup(func() { session.SessionDir = oldDir }) +} + // TestExecuteToolCalls_NotFound verifies that missing tools produce an error message func TestExecuteToolCalls_NotFound(t *testing.T) { + isolateSessionDir(t) c := client.NewClient(client.Config{BaseURL: "http://localhost:0"}) histPath := filepath.Join(t.TempDir(), "history.json") sess := session.New(c, histPath, nil, "", false) @@ -149,6 +166,7 @@ func TestExecuteToolCalls_NotFound(t *testing.T) { // TestExecuteToolCalls_Denied verifies denied confirmation produces cancel message func TestExecuteToolCalls_Denied(t *testing.T) { + isolateSessionDir(t) c := client.NewClient(client.Config{BaseURL: "http://localhost:0"}) histPath := filepath.Join(t.TempDir(), "history.json") sess := session.New(c, histPath, nil, "", true) @@ -182,6 +200,7 @@ func TestExecuteToolCalls_Denied(t *testing.T) { // TestExecuteToolCalls_NoMiddlewareFailsClosed verifies shell commands cannot // run when confirmation middleware is missing. func TestExecuteToolCalls_NoMiddlewareFailsClosed(t *testing.T) { + isolateSessionDir(t) c := client.NewClient(client.Config{BaseURL: "http://localhost:0"}) histPath := filepath.Join(t.TempDir(), "history.json") sess := session.New(c, histPath, nil, "", true) @@ -395,4 +414,3 @@ func TestRunLoop_PreCancelledContextDoesNotInvokeStartTurn(t *testing.T) { t.Fatal("onStartTurn was invoked despite cancelled context") } } - diff --git a/internal/orchestrator/base_test.go b/internal/orchestrator/base_test.go index 9d07288d..d4e899c3 100644 --- a/internal/orchestrator/base_test.go +++ b/internal/orchestrator/base_test.go @@ -2,14 +2,14 @@ package orchestrator import ( "context" - "fmt" "encoding/json" + "fmt" "late/internal/client" "late/internal/common" "late/internal/session" + "late/internal/tool" "net/http" "net/http/httptest" - "late/internal/tool" "os" "path/filepath" "sync" @@ -277,6 +277,15 @@ func TestNextChildIDPersistsSequenceForResume(t *testing.T) { } func TestBaseOrchestrator_Execute_EmptyTextDoesNotAddMessage(t *testing.T) { + // Execute's run loop commits via AddAssistantMessageWithTools → + // saveAndNotify → UpdateSessionMetadata, which writes the .meta.json + // sidecar into the global sessions dir. Redirect it so nothing leaves + // the temp dir. + tmpDir := t.TempDir() + originalSessionDir := session.SessionDir + session.SessionDir = func() (string, error) { return tmpDir, nil } + t.Cleanup(func() { session.SessionDir = originalSessionDir }) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.WriteHeader(http.StatusOK) @@ -284,7 +293,6 @@ func TestBaseOrchestrator_Execute_EmptyTextDoesNotAddMessage(t *testing.T) { })) defer ts.Close() - tmpDir := t.TempDir() historyPath := filepath.Join(tmpDir, "session.json") initial := []client.ChatMessage{ {Role: "user", Content: client.TextContent("initial goal")}, @@ -370,6 +378,15 @@ func TestBaseOrchestrator_CancelClearsQueuedMessages(t *testing.T) { } func TestBaseOrchestrator_CancelDuringRunDoesNotCommitQueuedMessages(t *testing.T) { + // The "turn 1" submit runs a full turn: AddUserMessage + the run-loop's + // AddAssistantMessageWithTools both persist via saveAndNotify → + // UpdateSessionMetadata, writing the .meta.json sidecar into the global + // sessions dir. Redirect it so nothing leaves the temp dir. + tmpDir := t.TempDir() + originalSessionDir := session.SessionDir + session.SessionDir = func() (string, error) { return tmpDir, nil } + t.Cleanup(func() { session.SessionDir = originalSessionDir }) + // Setup a server that holds the connection until cancelled holdCh := make(chan struct{}) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -381,7 +398,6 @@ func TestBaseOrchestrator_CancelDuringRunDoesNotCommitQueuedMessages(t *testing. defer ts.Close() defer close(holdCh) - tmpDir := t.TempDir() historyPath := filepath.Join(tmpDir, "session.json") c := client.NewClient(client.Config{BaseURL: ts.URL}) sess := session.New(c, historyPath, nil, "", false) From 30dbc41457d08bf18397d0511a61d071027f84d5 Mon Sep 17 00:00:00 2001 From: Emasoft <713559+Emasoft@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:05:38 +0200 Subject: [PATCH 2/2] test(plugin): sandbox user config for tests that write the global plugin state file 10 plugin tests rewrote the real ~/Library/Application Support/late/ plugins.json; they now run under the package's sandboxUserConfig convention so the full suite provably writes nothing outside temp dirs. --- internal/plugin/example_plugin_test.go | 5 ++++ internal/plugin/project_test.go | 41 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/internal/plugin/example_plugin_test.go b/internal/plugin/example_plugin_test.go index 6f05a5a1..11144c62 100644 --- a/internal/plugin/example_plugin_test.go +++ b/internal/plugin/example_plugin_test.go @@ -237,6 +237,11 @@ func TestExamplePlugin_AllAPIs(t *testing.T) { // TestExamplePlugin_CLICommands tests installing via 'late plugin link', // listing, enabling, disabling, and removing. func TestExamplePlugin_CLICommands(t *testing.T) { + // Sandbox the user config dir: link/enable/disable/remove all record + // state in the global plugin state file (plugins.json), which must be + // a throwaway here. + sandboxUserConfig(t) + wd, err := os.Getwd() if err != nil { t.Fatal(err) diff --git a/internal/plugin/project_test.go b/internal/plugin/project_test.go index e30d1916..2685458a 100644 --- a/internal/plugin/project_test.go +++ b/internal/plugin/project_test.go @@ -209,6 +209,10 @@ func TestDiscover_IgnoresNodeModulesAndCache(t *testing.T) { // --------------------------------------------------------------------------- func TestInstallFromLocal_Project(t *testing.T) { + // Sandbox the user config dir: this flow records state in the global + // plugin state file (plugins.json), which must be a throwaway here. + sandboxUserConfig(t) + globalDir := t.TempDir() projectDir := t.TempDir() sourceDir := t.TempDir() @@ -246,6 +250,10 @@ func TestInstallFromLocal_Project(t *testing.T) { } func TestInstallFromLocal_Global(t *testing.T) { + // Sandbox the user config dir: this flow records state in the global + // plugin state file (plugins.json), which must be a throwaway here. + sandboxUserConfig(t) + globalDir := t.TempDir() projectDir := t.TempDir() sourceDir := t.TempDir() @@ -270,6 +278,10 @@ func TestInstallFromLocal_Global(t *testing.T) { // --------------------------------------------------------------------------- func TestLink_Project(t *testing.T) { + // Sandbox the user config dir: Link records state in the global plugin + // state file (plugins.json), which must be a throwaway here. + sandboxUserConfig(t) + globalDir := t.TempDir() projectDir := t.TempDir() sourceDir := t.TempDir() @@ -290,6 +302,10 @@ func TestLink_Project(t *testing.T) { } func TestLink_Global(t *testing.T) { + // Sandbox the user config dir: Link records state in the global plugin + // state file (plugins.json), which must be a throwaway here. + sandboxUserConfig(t) + globalDir := t.TempDir() projectDir := t.TempDir() sourceDir := t.TempDir() @@ -314,6 +330,11 @@ func TestLink_Global(t *testing.T) { // --------------------------------------------------------------------------- func TestRemovePlugin_Project(t *testing.T) { + // Sandbox the user config dir: installing/removing a local plugin + // records and clears state in the global plugin state file + // (plugins.json), which must be a throwaway here. + sandboxUserConfig(t) + globalDir := t.TempDir() projectDir := t.TempDir() sourceDir := t.TempDir() @@ -350,6 +371,11 @@ func TestRemovePlugin_Project(t *testing.T) { } func TestRemovePlugin_Global(t *testing.T) { + // Sandbox the user config dir: installing/removing a local plugin + // records and clears state in the global plugin state file + // (plugins.json), which must be a throwaway here. + sandboxUserConfig(t) + globalDir := t.TempDir() projectDir := t.TempDir() sourceDir := t.TempDir() @@ -571,6 +597,11 @@ func writeScopedPluginLike(t *testing.T, dir, scopedName string) string { } func TestRemovePlugin_ScopedLink_CleansEmptyScopeParent(t *testing.T) { + // Sandbox the user config dir: installing/removing a local plugin + // records and clears state in the global plugin state file + // (plugins.json), which must be a throwaway here. + sandboxUserConfig(t) + globalDir := t.TempDir() sourceDir := t.TempDir() writeScopedPluginLike(t, sourceDir, "@late/scoped-plugin") @@ -598,6 +629,11 @@ func TestRemovePlugin_ScopedLink_CleansEmptyScopeParent(t *testing.T) { } func TestRemovePlugin_ScopedLink_KeepsNonEmptyScopeParent(t *testing.T) { + // Sandbox the user config dir: installing/removing a local plugin + // records and clears state in the global plugin state file + // (plugins.json), which must be a throwaway here. + sandboxUserConfig(t) + globalDir := t.TempDir() srcA := t.TempDir() srcB := t.TempDir() @@ -636,6 +672,11 @@ func TestRemovePlugin_ScopedLink_KeepsNonEmptyScopeParent(t *testing.T) { // installs — the `dir` parameter flows through pm.TargetDir(project), but // without an explicit test the project's branch is never asserted. func TestRemovePlugin_Project_ScopedLink_CleansEmptyScopeParent(t *testing.T) { + // Sandbox the user config dir: installing/removing a local plugin + // records and clears state in the global plugin state file + // (plugins.json), which must be a throwaway here. + sandboxUserConfig(t) + globalDir := t.TempDir() projectDir := t.TempDir() sourceDir := t.TempDir()