Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions internal/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -590,4 +621,3 @@ func TestSubagentRegistryHasNoTodoTools(t *testing.T) {
t.Fatalf("expected subagent registry to contain read_file")
}
}

20 changes: 19 additions & 1 deletion internal/executor/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -395,4 +414,3 @@ func TestRunLoop_PreCancelledContextDoesNotInvokeStartTurn(t *testing.T) {
t.Fatal("onStartTurn was invoked despite cancelled context")
}
}

24 changes: 20 additions & 4 deletions internal/orchestrator/base_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -277,14 +277,22 @@ 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)
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer ts.Close()

tmpDir := t.TempDir()
historyPath := filepath.Join(tmpDir, "session.json")
initial := []client.ChatMessage{
{Role: "user", Content: client.TextContent("initial goal")},
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions internal/plugin/example_plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions internal/plugin/project_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Loading